Files
api/web/components/SsoButton.ce.vue
Eli Bosley 3b00fec5fd chore: Remove legacy store modules and add new API key and reporting services (#1536)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added developer CLI tools for toggling GraphQL sandbox and modal
testing utilities.
* Introduced a "Show Activation Modal" developer component for UI
testing.
  * Added system initial setup detection and related GraphQL queries.
* Enhanced login and welcome pages with dynamic server info and initial
setup state.
  * Improved SSO button with internationalization and error handling.
* Added internal CLI admin API key management service and internal
GraphQL client service.
* Introduced comprehensive API report generation service for system and
service status.
* Added CLI commands and GraphQL mutations/queries for plugin and SSO
user management.
* Added new modal target components and improved teleport target
detection.

* **Enhancements**
* Refined modal dialog targeting and teleportation for flexible UI
placement.
* Updated modal components and stores for improved activation/welcome
modal control.
  * Improved plugin and SSO user management via CLI through GraphQL API.
* Refactored partner logo components to use props instead of store
dependencies.
  * Enhanced styling and accessibility for buttons and modals.
* Streamlined Tailwind CSS integration with shared styles and updated
theme variables.
* Improved GraphQL module configuration to avoid directive conflicts in
tests.
  * Adjusted Vite config for better dependency handling in test mode.
  * Improved error handling and logging in CLI commands and services.
* Reordered imports and refined component class bindings for UI
consistency.

* **Bug Fixes**
* Resolved issues with duplicate script tags and component registration
in the web UI.
* Fixed modal close button visibility and activation modal state
handling.
* Added error handling and logging improvements across CLI commands and
services.
  * Fixed newline issues in last-download-time fixture files.

* **Chores**
* Added and updated numerous tests for CLI commands, services, and UI
components.
* Updated translation files and localization resources for new UI
messages.
* Adjusted environment, configuration, and dependency files for improved
development and test workflows.
  * Cleaned up unused imports and mocks in tests.
  * Reorganized exports and barrel files in shared and UI modules.
  * Added integration and dependency resolution tests for core modules.

* **Removals & Refactoring**
* Removed legacy Redux state management, configuration, and UPnP logic
from the backend.
* Eliminated deprecated GraphQL subscriptions and client code related to
registration and mothership.
* Removed direct store manipulation and replaced with service-based
approaches in CLI commands.
  * Deleted unused or redundant test files and configuration listeners.
* Refactored SSO user service to consolidate add/remove operations into
a single update method.
* Simplified API key services with new methods for automatic key
management.
* Replaced direct plugin and SSO user service calls with GraphQL client
interactions in CLI commands.
* Removed complex theme fallback and dark mode CSS rules, replacing with
streamlined static theme variables.
* Cleaned up Tailwind CSS configuration and removed deprecated local
styles.
* Removed multiple internal utility files and replaced with simplified
or centralized implementations.
* Removed deprecated local configuration and synchronization files and
listeners.
  * Removed UPnP helper functions and job management classes.
* Refactored server resolver to dynamically construct local server data
internally.
* Removed CORS handler and replaced with simplified or externalized
logic.
* Removed store synchronization and registration event pubsub handling.
* Removed GraphQL client creation utilities for internal API
communication.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-25 15:07:37 -04:00

208 lines
6.4 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useQuery } from '@vue/apollo-composable';
import { useI18n } from 'vue-i18n';
import { SSO_ENABLED } from '~/store/account.fragment';
import { BrandButton } from '@unraid/ui';
import { ACCOUNT } from '~/helpers/urls';
type CurrentState = 'loading' | 'idle' | 'error';
const { t } = useI18n();
const currentState = ref<CurrentState>('idle');
const error = ref<string | null>(null);
const { result } = useQuery(SSO_ENABLED);
const isSsoEnabled = computed<boolean>(
() => result.value?.isSSOEnabled ?? false
);
const getInputFields = (): {
form: HTMLFormElement;
passwordField: HTMLInputElement;
usernameField: HTMLInputElement;
} => {
const form = document.querySelector('form[action="/login"]') as HTMLFormElement;
const passwordField = document.querySelector('input[name=password]') as HTMLInputElement;
const usernameField = document.querySelector('input[name=username]') as HTMLInputElement;
if (!form || !passwordField || !usernameField) {
console.warn('Could not find form, username, or password field');
}
return { form, passwordField, usernameField };
};
const enterCallbackTokenIntoField = (token: string) => {
const { form, passwordField, usernameField } = getInputFields();
if (!passwordField || !usernameField || !form) {
console.warn('Could not find form, username, or password field');
} else {
usernameField.value = 'root';
passwordField.value = token;
// Submit the form
form.requestSubmit();
}
};
const getStateToken = (): string | null => {
const state = sessionStorage.getItem('sso_state');
return state ?? null;
};
const generateStateToken = (): string => {
const array = new Uint8Array(32);
window.crypto.getRandomValues(array);
const state = Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
sessionStorage.setItem('sso_state', state);
return state;
};
const disableFormOnSubmit = () => {
const { form } = getInputFields();
if (form) {
form.style.display = 'none';
}
};
const reEnableFormOnError = () => {
const { form } = getInputFields();
if (form) {
form.style.display = 'block';
}
};
onMounted(async () => {
try {
const search = new URLSearchParams(window.location.search);
const code = search.get('code') ?? '';
const state = search.get('state') ?? '';
const ssoError = search.get('sso_error') ?? '';
const sessionState = getStateToken();
// Check for SSO error parameter
if (ssoError) {
currentState.value = 'error';
// Map common SSO errors to user-friendly messages with translation support
const errorMap: Record<string, string> = {
'invalid_credentials': t('Invalid Unraid.net credentials'),
'user_not_authorized': t('This Unraid.net account is not authorized to access this server'),
'sso_disabled': t('SSO login is not enabled on this server'),
'token_expired': t('Login session expired. Please try again'),
'network_error': t('Network error. Please check your connection'),
};
error.value = errorMap[ssoError] || t('SSO login failed. Please try again');
// Clean up the URL
const url = new URL(window.location.href);
url.searchParams.delete('sso_error');
window.history.replaceState({}, document.title, url.pathname + url.search);
return;
}
if (code && state === sessionState) {
disableFormOnSubmit();
currentState.value = 'loading';
const token = await fetch(new URL('/api/oauth2/token', ACCOUNT), {
method: 'POST',
body: new URLSearchParams({
code,
client_id: 'CONNECT_SERVER_SSO',
grant_type: 'authorization_code',
}),
});
if (token.ok) {
const tokenBody = await token.json();
if (!tokenBody.access_token) {
throw new Error('Token body did not contain access_token');
}
enterCallbackTokenIntoField(tokenBody.access_token);
if (window.location.search) {
window.history.replaceState({}, document.title, window.location.pathname);
}
} else {
throw new Error('Failed to fetch token');
}
}
} catch (err) {
console.error('Error fetching token', err);
currentState.value = 'error';
error.value = t('Error fetching token');
reEnableFormOnError();
}
});
const buttonText = computed<string>(() => {
switch (currentState.value) {
case 'loading':
return t('Logging in...');
case 'error':
return t('Try Again');
default:
return t('Log In With Unraid.net');
}
});
const navigateToExternalSSOUrl = () => {
const url = new URL('sso', ACCOUNT);
const callbackUrlLogin = new URL('login', window.location.origin);
const state = generateStateToken();
url.searchParams.append('callbackUrl', callbackUrlLogin.toString());
url.searchParams.append('state', state);
window.location.href = url.toString();
};
</script>
<template>
<div>
<template v-if="isSsoEnabled">
<div class="w-full flex flex-col gap-1 my-1">
<p v-if="currentState === 'idle' || currentState === 'error'" class="text-center">{{ t('or') }}</p>
<p v-if="currentState === 'error'" class="text-red-500 text-center">{{ error }}</p>
<BrandButton
:disabled="currentState === 'loading'"
variant="outline-primary"
class="sso-button"
@click="navigateToExternalSSOUrl"
>{{ buttonText }}</BrandButton
>
</div>
</template>
</div>
</template>
<style>
/* Font size overrides for 16px base (standard Tailwind sizing) */
:host {
/* Text sizes - standard Tailwind rem values */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--text-4xl: 2.25rem; /* 36px */
--text-5xl: 3rem; /* 48px */
--text-6xl: 3.75rem; /* 60px */
--text-7xl: 4.5rem; /* 72px */
--text-8xl: 6rem; /* 96px */
--text-9xl: 8rem; /* 128px */
/* Spacing - standard Tailwind value */
--spacing: 0.25rem; /* 4px */
}
.sso-button {
font-size: 0.875rem !important;
font-weight: 600 !important;
line-height: 1 !important;
text-transform: uppercase !important;
letter-spacing: 2px !important;
padding: 0.75rem 1.5rem !important;
border-radius: 0.125rem !important;
}
</style>