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;