OAuth Provider Buttons (#5103)

PR to allow other OAuth providers to be displayed on the login screen.
Will show a generic icon when the provider is not in the known set of
providers.
<img width="424" height="205" alt="47ab288dadbc889fd84cc83c9ded0829"
src="https://github.com/user-attachments/assets/2877eb3d-2ade-406f-a2bf-dc404793e30f"
/>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ethan <ethan@MacBook-Pro.local>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
This commit is contained in:
Dario Ghunney Ware 2025-12-03 10:54:53 +00:00 committed by GitHub
parent 65a3eeca76
commit f902e8aca9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 92 additions and 18 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,6 +1,40 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Mock localStorage for tests
class LocalStorageMock implements Storage {
private store: Record<string, string> = {};
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: () => ({

View File

@ -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` }
});

View File

@ -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<string, { label: string; file: string }> = {
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) => (
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
<button
onClick={() => onProviderClick(p.id as any)}
onClick={() => onProviderClick(p.id)}
disabled={isSubmitting}
className="oauth-button-icon"
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
@ -70,7 +83,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
{providers.map((p) => (
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
<button
onClick={() => onProviderClick(p.id as any)}
onClick={() => onProviderClick(p.id)}
disabled={isSubmitting}
className="oauth-button-grid"
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
@ -88,7 +101,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
{providers.map((p) => (
<button
key={p.id}
onClick={() => onProviderClick(p.id as any)}
onClick={() => onProviderClick(p.id)}
disabled={isSubmitting}
className="oauth-button-vertical"
title={p.label}