diff --git a/hub/src/index.ts b/hub/src/index.ts index 3e07904..22d0517 100644 --- a/hub/src/index.ts +++ b/hub/src/index.ts @@ -135,8 +135,11 @@ app.use('/api/*', cors({ credentials: true, })) -// Health check +// Health check. Both `/health` and `/healthz` are liveness aliases (the Coolify +// probe + tooling hit `/healthz`); `/healthz/deep` (introspectApi below) is the +// bearer-gated readiness endpoint, NOT a substitute for this cheap liveness ping. app.get('/health', (c) => c.json({ ok: true })) +app.get('/healthz', (c) => c.json({ ok: true })) // B4 (obs): /healthz/deep + /metrics. Bearer-gated via HUB_INTROSPECT_TOKEN. // Mounted at root — bypasses /api/* auth, CSRF, license-gate, rate-limit diff --git a/web/src/hooks/useProfile.ts b/web/src/hooks/useProfile.ts index 976674f..5d7980d 100644 --- a/web/src/hooks/useProfile.ts +++ b/web/src/hooks/useProfile.ts @@ -20,6 +20,12 @@ export function useProfile(token: string | null) { const fetchProfile = useCallback(async () => { if (!token) { setLoading(false); return } + // Reset to loading on every (re)fetch — critically when `token` transitions + // null → set on sign-in. Without this the flag stays at the stale `false` + // left by the no-token branch, so App's dead-credential effect sees + // `!profileLoading && !profile && token` for one render and force-signs-out + // the user the instant they log in (auto-logout race). + setLoading(true) try { const p = await hubFetch(token, '/api/profile') setProfile(p) diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index 5b1e2ad..3ecd7ff 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -27,12 +27,22 @@ export interface AuthUser { * regardless of outcome. */ export async function requestMagicLink(email: string): Promise { - await fetch(`${HUB_URL}/api/auth/login/request-link`, { + const res = await fetch(`${HUB_URL}/api/auth/login/request-link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), credentials: 'include', - }).catch(() => {}) + }).catch(() => null) + // Magic-link is globally disabled when the hub runs Titanium-bypassed + // (503 `{ error: "titanium_disabled" }`). That is a deployment-wide config + // state, NOT a per-email signal — surfacing it leaks no enumeration info, and + // silently rendering "check your inbox" would strand the user waiting for an + // email that will never send. Signal the caller to fall back to password + // sign-in. Every other outcome stays silent (enumeration safety). + if (res && res.status === 503) { + const body = (await res.json().catch(() => null)) as { error?: string } | null + if (body?.error === 'titanium_disabled') throw new Error('magic_link_disabled') + } } /** diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index feb6f0b..1ab85f2 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -30,9 +30,17 @@ export function Login({ onLegacyAuth }: Props) { await requestMagicLink(email) // Always show the success state regardless of API response — enumeration prevention. setSent(true) - } catch { - // Network-level failure — still show success (don't leak signal). - setSent(true) + } catch (err) { + // Magic-link globally disabled (Titanium bypass) — route to password + // sign-in instead of faking "check your inbox" for an email that the + // hub will never send. This is a config state, not a per-email signal. + if (err instanceof Error && err.message === 'magic_link_disabled') { + setMode('password') + setError('Magic-link sign-in is unavailable right now — please sign in with your password.') + } else { + // Network-level failure — still show success (don't leak signal). + setSent(true) + } } finally { setLoading(false) }