diff --git a/apps/frontend/src/components/auth/login.tsx b/apps/frontend/src/components/auth/login.tsx index 2ac62effbc..5d92d8691d 100644 --- a/apps/frontend/src/components/auth/login.tsx +++ b/apps/frontend/src/components/auth/login.tsx @@ -80,7 +80,7 @@ export function Login() {
{isGeneral && genericOauth ? (
- +

{t( 'sso_description', diff --git a/apps/frontend/src/components/auth/providers/oauth.provider.tsx b/apps/frontend/src/components/auth/providers/oauth.provider.tsx index cf0a560dcd..48e9b84eeb 100644 --- a/apps/frontend/src/components/auth/providers/oauth.provider.tsx +++ b/apps/frontend/src/components/auth/providers/oauth.provider.tsx @@ -1,15 +1,29 @@ 'use client'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import SafeImage from '@gitroom/react/helpers/safe.image'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { useVariables } from '@gitroom/react/helpers/variable.context'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; -export const OauthProvider = () => { + +// Session key shared with the auth error state (register.tsx): a freshly +// initiated SSO flow resets the error-page retry budget. +export const DOS_OAUTH_RETRY_KEY = 'dos_oauth_retry_count'; + +// Cap for the automatic redirect only: if we auto-started a flow and are +// back on an auth page without completing it within 10s (IdP error bounce, +// remembered-deny, callback without code), show the manual button instead of +// contributing to a browser/IdP redirect storm. Manual clicks are exempt. +const DOS_OAUTH_AUTOSTART_TS_KEY = 'dos_oauth_autostart_ts'; + +export const OauthProvider = ({ autoStart = false }: { autoStart?: boolean }) => { const fetch = useFetch(); const { oauthLogoUrl, oauthDisplayName } = useVariables(); const t = useT(); - const gotoLogin = useCallback(async () => { + const [autoFailed, setAutoFailed] = useState(false); + const startedRef = useRef(false); + + const gotoLogin = useCallback(async (): Promise => { try { const response = await fetch('/auth/oauth/GENERIC'); if (!response.ok) { @@ -18,11 +32,63 @@ export const OauthProvider = () => { ); } const link = await response.text(); + // A deliberately initiated SSO flow starts fresh: clear the error-page + // retry budget so the user gets their full retry allowance. + window.sessionStorage.removeItem(DOS_OAUTH_RETRY_KEY); window.location.href = link; + return true; } catch (error) { console.error('Failed to get generic oauth login link:', error); + return false; } }, []); + + useEffect(() => { + if (!autoStart || startedRef.current) return; + startedRef.current = true; + const lastStart = Number( + window.sessionStorage.getItem(DOS_OAUTH_AUTOSTART_TS_KEY) || '0' + ); + if (Date.now() - lastStart < 10_000) { + setAutoFailed(true); + return; + } + window.sessionStorage.setItem( + DOS_OAUTH_AUTOSTART_TS_KEY, + String(Date.now()) + ); + gotoLogin().then((ok) => { + if (!ok) { + // Auto-start could not even fetch the link - fall back to the + // manual button instead of a dead redirecting state. + window.sessionStorage.removeItem(DOS_OAUTH_AUTOSTART_TS_KEY); + setAutoFailed(true); + } + }); + }, [autoStart, gotoLogin]); + + if (autoStart && !autoFailed) { + return ( +

+
+ +
+
+ {t('redirecting_to', 'Redirecting to')}  + {oauthDisplayName || 'DOS ID'}... +
+
+ ); + } + return (
(null); useEffect(() => { if (code) { load(); } }, []); const load = useCallback(async () => { + setError(null); try { const response = await fetch( `/auth/oauth/${provider?.toUpperCase() || 'GENERIC'}/exists`, @@ -54,21 +59,28 @@ export function Register() { } ); if (!response.ok) { - setShow(true); + // The exchange failed server-side. Never masquerade this failure as + // a fresh signup: surface it with a loop-guarded retry instead. + setError({ status: response.status, message: '' }); return; } const data = await response.json(); if (data?.token) { + window.sessionStorage.removeItem(DOS_OAUTH_RETRY_KEY); setCode(data.token); setShow(true); } else { + window.sessionStorage.removeItem(DOS_OAUTH_RETRY_KEY); window.location.href = '/'; } } catch (e) { console.error('Failed to verify oauth code:', e); - setShow(true); + setError({ message: (e as Error)?.message || '' }); } }, [provider, code, state]); + if (error) { + return ; + } if (!code && !getQuery?.get('provider')) { return ; } @@ -79,6 +91,74 @@ export function Register() { ); } + +// A failed OAuth exchange (state cookie mismatch, upstream token error, ...) +// used to fall through to the signup form, so a broken sign-in looked like a +// fresh registration - the exact confusion reported on 2026-09-21. This state +// shows what happened and offers a loop-guarded retry: up to RETRY_LIMIT +// automatic SSO restarts (a live id.dos.me session makes that one click), +// then a manual link so a persistent failure cannot ping-pong forever. +const RETRY_LIMIT = 2; + +function AuthErrorState({ + status, + message, +}: { + status?: number; + message: string; +}) { + const t = useT(); + const fetch = useFetch(); + const attempts = Number(window.sessionStorage.getItem(DOS_OAUTH_RETRY_KEY) || '0'); + const retry = useCallback(async () => { + try { + window.sessionStorage.setItem(DOS_OAUTH_RETRY_KEY, String(attempts + 1)); + const response = await fetch('/auth/oauth/GENERIC'); + if (response.ok) { + window.location.href = await response.text(); + return; + } + } catch (e) { + console.error('Failed to restart the SSO flow:', e); + } + window.location.href = '/auth/login'; + }, [attempts]); + return ( +
+

+ {t('sign_in_failed', 'Sign-in failed')} +

+

+ {t( + 'sign_in_failed_body', + 'We could not complete your sign-in. This is usually temporary - try again below.' + )} + {status ? ` (HTTP ${status})` : ''} +

+ {!!message && ( +

{message}

+ )} + {attempts < RETRY_LIMIT ? ( + + ) : ( + + {t('try_again', 'Try again')} + + )} +

+ {t('already_have_an_account', 'Already Have An Account?')}  + + {t('sign_in', 'Sign In')} + +

+
+ ); +} function getHelpfulReasonForRegistrationFailure(httpCode: number) { switch (httpCode) { case 400: @@ -172,7 +252,7 @@ export function RegisterAfter({
{!isAfterProvider && isGeneral && genericOauth ? (
- +

{t( 'sso_description',