diff --git a/ui/src/auth/LoginPage.test.tsx b/ui/src/auth/LoginPage.test.tsx index 6d357fda..acf7436f 100644 --- a/ui/src/auth/LoginPage.test.tsx +++ b/ui/src/auth/LoginPage.test.tsx @@ -13,6 +13,7 @@ vi.mock('./auth-store', () => ({ })); import { api as apiClient } from '../api/client'; +import { useAuthStore } from './auth-store'; import { LoginPage } from './LoginPage'; function wrapper(initialEntries: string[]) { @@ -127,4 +128,29 @@ describe('LoginPage', () => { Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }); } }); + + it('SSO button click: when /auth/oidc/config fails, button unlocks and error is set', async () => { + const setStateMock = vi.fn(); + const useAuthStoreMock = vi.mocked(useAuthStore) as unknown as { setState: typeof setStateMock }; + useAuthStoreMock.setState = setStateMock; + + (apiClient.GET as any).mockImplementation((path: string) => { + if (path === '/auth/capabilities') return Promise.resolve({ + data: { oidc: { enabled: true, providerName: 'Logto', primary: true }, localAccounts: { enabled: true, adminRecoveryOnly: true } }, + error: null, + }); + if (path === '/auth/oidc/config') return Promise.reject(new Error('network down')); + return Promise.resolve({ data: undefined, error: { message: 'unexpected' } }); + }); + + render(, { wrapper: wrapper(['/login']) }); + const btn = await screen.findByRole('button', { name: /sign in with logto/i }); + fireEvent.click(btn); + + await waitFor(() => expect(setStateMock).toHaveBeenCalled()); + const errorPayload = setStateMock.mock.calls[0][0]; + expect(errorPayload.error).toMatch(/OIDC configuration unavailable/i); + // Button should not stay locked in "Redirecting…" + await waitFor(() => expect(btn).not.toHaveTextContent(/redirecting/i)); + }); }); diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx index e1812218..881b17aa 100644 --- a/ui/src/auth/LoginPage.tsx +++ b/ui/src/auth/LoginPage.tsx @@ -68,27 +68,32 @@ export function LoginPage() { const handleOidcLogin = async () => { setOidcLoading(true); - const { data } = await api.GET('/auth/oidc/config'); - if (!data?.authorizationEndpoint || !data?.clientId) { - setOidcLoading(false); + try { + const { data } = await api.GET('/auth/oidc/config'); + if (!data?.authorizationEndpoint || !data?.clientId) { + useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' }); + return; + } + if (data.endSessionEndpoint) { + localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint); + } + const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; + const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(data.additionalScopes || [])]; + const params = new URLSearchParams({ + response_type: 'code', + client_id: data.clientId, + redirect_uri: redirectUri, + scope: scopes.join(' '), + }); + if (data.resource) params.set('resource', data.resource); + // Note: NO prompt=none. Per RFC 9700 §4.4, that's silent re-auth only; + // for first-time login it returns login_required and traps users on a local form. + window.location.href = `${data.authorizationEndpoint}?${params}`; + } catch { useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' }); - return; + } finally { + setOidcLoading(false); } - if (data.endSessionEndpoint) { - localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint); - } - const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; - const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(data.additionalScopes || [])]; - const params = new URLSearchParams({ - response_type: 'code', - client_id: data.clientId, - redirect_uri: redirectUri, - scope: scopes.join(' '), - }); - if (data.resource) params.set('resource', data.resource); - // Note: NO prompt=none. Per RFC 9700 §4.4, that's silent re-auth only; - // for first-time login it returns login_required and traps users on a local form. - window.location.href = `${data.authorizationEndpoint}?${params}`; }; return ( diff --git a/ui/src/auth/auth-store.ts b/ui/src/auth/auth-store.ts index 9c83808e..4b3e9e3c 100644 --- a/ui/src/auth/auth-store.ts +++ b/ui/src/auth/auth-store.ts @@ -154,11 +154,11 @@ export const useAuthStore = create((set, get) => ({ isAuthenticated: false, error: null, }); - const loginUrl = `${config.basePath}login?local`; + const loginUrl = `${config.basePath}login`; if (endSessionEndpoint && idToken) { const params = new URLSearchParams({ id_token_hint: idToken, - post_logout_redirect_uri: `${window.location.origin}${config.basePath}login?local`, + post_logout_redirect_uri: `${window.location.origin}${config.basePath}login`, }); fetch(`${endSessionEndpoint}?${params}`, { mode: 'no-cors' }).finally(() => { window.location.href = loginUrl;