From 7398d805bef5e5f3c2fbe5901bc52c360eb4fbe1 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 29 May 2026 17:42:43 -0700 Subject: [PATCH] fix(web): auto-logout on legacy sign-in + magic-link disabled fallback; hub /healthz alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three prod-access fixes (user locked out of app.remo-code.com): 1. useProfile auto-logout race (PRIMARY — "can't login"): the `loading` flag was never reset to true when `token` transitions null→set on sign-in, so it stayed at the stale `false` left by the no-token branch. For one render after a successful legacy password login, App.tsx's dead-credential cleanup effect saw `!profileLoading && !profile && token` and immediately fired signOut() — logging the user straight back out. HAR confirmed: POST /api/auth/login 200 + GET /api/profile 200 + POST /api/auth/logout all at the same timestamp. Fix: setLoading(true) at the start of fetchProfile. 2. Magic-link disabled UX: under TITANIUM_BYPASS the request-link endpoint 503s `titanium_disabled`, but the UI swallowed it and showed "check your inbox" for an email that never sends. requestMagicLink now throws `magic_link_disabled` on that specific (config-wide, non-enumerating) state and Login.tsx routes the user to the password form. The only working auth path under bypass. 3. /healthz liveness alias: the Coolify probe hits /healthz; the hub only served /health and bearer-gated /healthz/deep. Added a plain /healthz alias. Co-Authored-By: Claude Opus 4.8 (1M context) --- hub/src/index.ts | 5 ++++- web/src/hooks/useProfile.ts | 6 ++++++ web/src/lib/auth.ts | 14 ++++++++++++-- web/src/pages/Login.tsx | 14 +++++++++++--- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/hub/src/index.ts b/hub/src/index.ts index 3e079045..22d05176 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 976674fe..5d7980da 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 5b1e2ad3..3ecd7ff3 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 feb6f0bb..1ab85f27 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) }