diff --git a/frontend/public/Login/authentik.svg b/frontend/public/Login/authentik.svg new file mode 100644 index 000000000..26dc0189e --- /dev/null +++ b/frontend/public/Login/authentik.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/Login/cloudron.svg b/frontend/public/Login/cloudron.svg new file mode 100644 index 000000000..a4b50c421 --- /dev/null +++ b/frontend/public/Login/cloudron.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/Login/keycloak.svg b/frontend/public/Login/keycloak.svg new file mode 100644 index 000000000..30628c7b2 --- /dev/null +++ b/frontend/public/Login/keycloak.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/Login/oidc.svg b/frontend/public/Login/oidc.svg new file mode 100644 index 000000000..440b54487 --- /dev/null +++ b/frontend/public/Login/oidc.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/core/setupTests.ts b/frontend/src/core/setupTests.ts index 3ebb22fda..0e5e9737a 100644 --- a/frontend/src/core/setupTests.ts +++ b/frontend/src/core/setupTests.ts @@ -1,6 +1,40 @@ import '@testing-library/jest-dom'; import { vi } from 'vitest'; +// Mock localStorage for tests +class LocalStorageMock implements Storage { + private store: Record = {}; + + get length(): number { + return Object.keys(this.store).length; + } + + clear(): void { + this.store = {}; + } + + getItem(key: string): string | null { + return this.store[key] ?? null; + } + + key(index: number): string | null { + return Object.keys(this.store)[index] ?? null; + } + + removeItem(key: string): void { + delete this.store[key]; + } + + setItem(key: string, value: string): void { + this.store[key] = value; + } +} + +Object.defineProperty(window, 'localStorage', { + value: new LocalStorageMock(), + writable: true, +}); + // Mock i18next for tests vi.mock('react-i18next', () => ({ useTranslation: () => ({ diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx index 4390131ca..80a12e1d0 100644 --- a/frontend/src/proprietary/routes/Login.tsx +++ b/frontend/src/proprietary/routes/Login.tsx @@ -107,6 +107,7 @@ export default function Login() { const providerIds = Object.keys(data.providerList || {}) .map(key => key.split('/').pop()) .filter((id): id is string => id !== undefined); + setEnabledProviders(providerIds); } catch (err) { console.error('[Login] Failed to fetch enabled providers:', err); @@ -225,16 +226,25 @@ export default function Login() { ); } - const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc') => { + // Known OAuth providers that have dedicated backend support + const KNOWN_OAUTH_PROVIDERS = ['github', 'google', 'apple', 'azure', 'keycloak', 'oidc'] as const; + type KnownOAuthProvider = typeof KNOWN_OAUTH_PROVIDERS[number]; + + const signInWithProvider = async (provider: string) => { try { setIsSigningIn(true); setError(null); - console.log(`[Login] Signing in with ${provider}`); + // Map unknown providers to 'oidc' for the backend redirect + const backendProvider: KnownOAuthProvider = KNOWN_OAUTH_PROVIDERS.includes(provider as KnownOAuthProvider) + ? (provider as KnownOAuthProvider) + : 'oidc'; + + console.log(`[Login] Signing in with ${provider} (backend: ${backendProvider})`); // Redirect to Spring OAuth2 endpoint const { error } = await springAuth.signInWithOAuth({ - provider, + provider: backendProvider, options: { redirectTo: `${BASE_PATH}/auth/callback` } }); diff --git a/frontend/src/proprietary/routes/login/OAuthButtons.tsx b/frontend/src/proprietary/routes/login/OAuthButtons.tsx index b0cd4a89e..aaa280519 100644 --- a/frontend/src/proprietary/routes/login/OAuthButtons.tsx +++ b/frontend/src/proprietary/routes/login/OAuthButtons.tsx @@ -6,19 +6,23 @@ import { BASE_PATH } from '@app/constants/app'; export const DEBUG_SHOW_ALL_PROVIDERS = false; // OAuth provider configuration - maps provider ID to display info -export const oauthProviderConfig = { +// Known providers get custom icons; unknown providers use generic SSO icon +export const oauthProviderConfig: Record = { google: { label: 'Google', file: 'google.svg' }, github: { label: 'GitHub', file: 'github.svg' }, apple: { label: 'Apple', file: 'apple.svg' }, azure: { label: 'Microsoft', file: 'microsoft.svg' }, - // microsoft and azure are the same, keycloak and oidc need their own icons - // These are commented out from debug view since they need proper icons or backend doesn't use them - // keycloak: { label: 'Keycloak', file: 'keycloak.svg' }, - // oidc: { label: 'OIDC', file: 'oidc.svg' } + keycloak: { label: 'Keycloak', file: 'keycloak.svg' }, + cloudron: { label: 'Cloudron', file: 'cloudron.svg' }, + authentik: { label: 'Authentik', file: 'authentik.svg' }, + oidc: { label: 'OIDC', file: 'oidc.svg' } }; +// Generic fallback for unknown providers +const GENERIC_PROVIDER_ICON = 'oidc.svg'; + interface OAuthButtonsProps { - onProviderClick: (provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc') => void + onProviderClick: (provider: string) => void isSubmitting: boolean layout?: 'vertical' | 'grid' | 'icons' enabledProviders?: string[] // List of enabled provider IDs from backend @@ -32,13 +36,22 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = ' ? Object.keys(oauthProviderConfig) : enabledProviders; - // Filter to only show enabled providers from backend - const providers = providersToShow - .filter(id => id in oauthProviderConfig) - .map(id => ({ + // Build provider list - use provider ID to determine icon and label + const providers = providersToShow.map(id => { + if (id in oauthProviderConfig) { + // Known provider - use predefined icon and label + return { + id, + ...oauthProviderConfig[id] + }; + } + // Unknown provider - use generic icon and capitalize ID for label + return { id, - ...oauthProviderConfig[id as keyof typeof oauthProviderConfig] - })); + label: id.charAt(0).toUpperCase() + id.slice(1), + file: GENERIC_PROVIDER_ICON + }; + }); // If no providers are enabled, don't render anything if (providers.length === 0) { @@ -51,7 +64,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = ' {providers.map((p) => (