"use client"; import { createContext, useContext, useEffect, useMemo, useRef, useState, useCallback, type ReactNode, } from "react"; import { getSupabase } from "./supabase"; import { getDesktopBridge } from "./desktop/bridge"; import { DESKTOP_CALLBACK_FLAG } from "./desktop/config"; import { oauthCallbackUrl } from "./oauth-callback-url"; import { beginDesktopAuthTurn } from "./desktop/auth-turn"; import type { DesktopAuthLink } from "./desktop/auth-link"; import { clearPersistedQueryCache } from "./query-provider"; import { clearAppTabsWindowState } from "./app-tabs-storage"; import { leaveProtectedScreenAfterSignOut } from "./signed-out-redirect"; import { readInterfaceLocale } from "./interface-locale"; import { useAnalytics } from "./use-analytics"; import { browserRuntimeConfig } from "./runtime-config-provider"; import { signalRealtimeRekey } from "./realtime-topic"; import { HttpResponseError, isBackendUnavailableError } from "./backend-availability"; import { withClientRequestDeadline } from "./client-request-deadline"; import type { User, Session } from "@supabase/supabase-js"; export type OAuthProvider = "google" | "github"; interface AuthContextValue { user: User | null; session: Session | null; loading: boolean; oauthProviders: OAuthProvider[]; signInWithPassword: (email: string, password: string) => Promise; signUpWithPassword: ( email: string, password: string, options?: { fullName?: string; avatarSeed?: string; avatarUploadToken?: string; } ) => Promise<{ requiresEmailConfirmation: boolean }>; /** * Sends the password reset link (MIN-297). NEVER say * if the address has an account: GoTrue responds the same way in both cases, * and the screen displays the same sentence — a form that distinguishes the two is * a revealer of accounts. */ sendPasswordReset: (email: string) => Promise; /** Starts an OAuth sign-in flow for an enabled Supabase external provider. */ signInWithOAuth: ( provider: OAuthProvider, redirectAfter?: string ) => Promise; /** * Terminates a connection returned by deep link in the desktop app (MIN-291), * and makes the destination where to go next. This is where the code is exchanged, * and nowhere else: the PKCE verifier is in this storage. */ completeDesktopSignIn: (link: DesktopAuthLink) => Promise; signOut: () => Promise; updateUser: (attributes: { password?: string; data?: Record; }) => Promise; /** * Writes a patch to `user_metadata` in a MERGE-SAFE and SERIALIZED manner. THE * settings toggles each write a field; without serialization, two * quick toggles read the same base metadata and the last one overwrites the * field of the other. Here each write is chained and merges the patch into * the freshest metadata — so toggles can remain optimistic and * NOT locked (more than `disabled` during GoTrue backup). Rejects * if the write fails, so the caller can return to its optimistic state. */ updateUserMetadata: (patch: Record) => Promise; /** * Re-pull the account from Supabase Auth into local state. Needed when * `user_metadata` changes OUTSIDE the client session — e.g. Numo editing the * account settings server-side via the admin API, which fires no client auth * event, so the UI would otherwise keep reading the stale JWT. */ refreshUser: () => Promise; /** * TOTP enrollment (MIN-132): creates a factor `unverified` and returns what * display it — the QR in SVG inline, and the secret for manual entry. */ enrollTotp: (friendlyName: string) => Promise<{ factorId: string; qrCode: string; secret: string; }>; /** * Presents a six-digit code. This is what checks the factor * enrollment, and what sets the session to `aal2` at each connection — * the GoTrue API is the same in both cases. */ verifyTotp: (factorId: string, code: string) => Promise; /** Removes a Postman — used to clean up an abandoned enlistment. */ unenrollTotp: (factorId: string) => Promise; /** The first TOTP factor of the account, verified or not. */ firstTotpFactorId: () => Promise; /** * True when the account has a verified factor but the session remains in * `aal1` — there is therefore one code left to present. LOCAL reading (the JWT and the * session factors), no network round trip. */ needsMfaChallenge: () => Promise; /** * Revokes all OTHER sessions from the account. Called for activation of the * 2FA: without this, an already stolen token would remain valid until its own * refreshment, that is to say precisely what we just wanted to cut. */ signOutOtherSessions: () => Promise; } // Safety net: if Supabase is unreachable, onAuthStateChange may never fire // INITIAL_SESSION and the app would hang on `loading`. Bail out after this. const AUTH_INIT_TIMEOUT_MS = 8_000; const AuthContext = createContext(null); /** * True when two session snapshots describe the same account in the same state. * `getSession()` parses the stored session on every call, so a foreground * recovery hands back a new `user` object even when nothing changed. The SDK * serializes both sides the same way, so the JSON comparison is stable; a key * order difference only costs one extra render. */ function sameUser(prev: User | null, next: User | null): boolean { if (prev === next) return true; if (!prev || !next || prev.id !== next.id) return false; return JSON.stringify(prev) === JSON.stringify(next); } export function AuthProvider({ children, discoverOAuthProviders = true, }: { children: ReactNode; /** * Reads the enabled external providers from Supabase Auth. Only the sign-in * and sign-up screens show them, so the authenticated shell turns this off * to skip a cross-origin request and a context update during startup. */ discoverOAuthProviders?: boolean; }) { const [user, setUser] = useState(null); const explicitSignOut = useRef(false); const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); const [oauthProviders, setOauthProviders] = useState([]); const { track, identify, reset } = useAnalytics(); // Snapshot of freshest user, read by updateUserMetadata to merge on // the correct basis (the state in closure would be out of date in a chain of writes). const userRef = useRef(null); useEffect(() => { userRef.current = user; }, [user]); // String of serialized metadata writes (see updateUserMetadata). const metaWriteChain = useRef>(Promise.resolve()); useEffect(() => { if (!discoverOAuthProviders) return; const { supabaseUrl, supabaseAnonKey } = browserRuntimeConfig(); const controller = new AbortController(); let disposed = false; let checking = false; let resolved = false; let retryTimer: number | undefined; const checkProviders = async () => { if (disposed || checking || resolved) return; window.clearTimeout(retryTimer); checking = true; try { const body: unknown = await withClientRequestDeadline(async (signal) => { const response = await fetch(`${supabaseUrl}/auth/v1/settings`, { headers: { apikey: supabaseAnonKey, Authorization: `Bearer ${supabaseAnonKey}`, }, signal, }); if (!response.ok) throw new HttpResponseError("Auth settings unavailable", response.status); return response.json(); }, AUTH_INIT_TIMEOUT_MS, controller.signal); if (disposed) return; resolved = true; const external = typeof body === "object" && body !== null && "external" in body ? (body as { external?: unknown }).external : null; if (!external || typeof external !== "object") return; setOauthProviders( (["google", "github"] as const).filter( (provider) => (external as Record)[provider] === true, ), ); } catch (error) { // Keep provider buttons hidden until the server confirms availability, // but allow an offline desktop startup to recover without a reload. if (!disposed && isBackendUnavailableError(error)) { retryTimer = window.setTimeout(() => { void checkProviders(); }, AUTH_INIT_TIMEOUT_MS); } } finally { checking = false; } }; const resume = () => { void checkProviders(); }; const resumeVisible = () => { if (document.visibilityState === "visible") resume(); }; resume(); window.addEventListener("online", resume); window.addEventListener("focus", resume); document.addEventListener("visibilitychange", resumeVisible); return () => { disposed = true; controller.abort(); window.clearTimeout(retryTimer); window.removeEventListener("online", resume); window.removeEventListener("focus", resume); document.removeEventListener("visibilitychange", resumeVisible); }; }, [discoverOAuthProviders]); useEffect(() => { const supabase = getSupabase(); let resolved = false; let disposed = false; let revision = 0; let recovering = false; let recoveryTimer: number | undefined; let snapshotOwner: string | null | undefined; const recoveryController = new AbortController(); const timeoutId = window.setTimeout(() => { if (resolved) return; setLoading(false); void recoverSession(); }, AUTH_INIT_TIMEOUT_MS); const applySession = (event: string, s: Session | null) => { resolved = true; window.clearTimeout(timeoutId); const nextOwner = s?.user.id ?? null; if (snapshotOwner !== undefined && snapshotOwner !== nextOwner && event !== "SIGNED_OUT") { clearPersistedQueryCache(); clearAppTabsWindowState(); } snapshotOwner = nextOwner; // ⚠ Bail-out on the TOKEN, not on the item (MIN-315). supabase-js re-issues // `SIGNED_IN` / `TOKEN_REFRESHED` when returning to the foreground and each time // token refresh, ALWAYS with new objects. Gold // `AuthProvider` is the highest provider in the tree: a session // new identity re-makes all its consumers, and the cascade is // verifiable end-to-end — `useProjectsQuery` reads `useAuth`, so // `ProjectsProvider` returns, therefore `CreateProvider`, therefore the board, // so ALL the cards. And “when returning to the foreground” is the gesture // more common in an office shell. setSession((prev) => (prev?.access_token === s?.access_token ? prev : s)); // Bail out only on identical CONTENT. Foreground recovery re-reads the // same stored session on every focus, tab return or reconnect; a new // identity would re-render every consumer for nothing. Any real change // (metadata, factors, another account, sign-out) still propagates, and // `refreshUser` / `updateUserMetadata` set their own result directly. setUser((prev) => { const next = s?.user ?? null; return sameUser(prev, next) ? prev : next; }); setLoading(false); // The database rotates every private topic when MFA assurance changes. // Supabase emits this event after saving the replacement AAL2 session, so // reconnect only now; TOKEN_REFRESHED is routine and must not churn sockets. if (event === "MFA_CHALLENGE_VERIFIED") signalRealtimeRekey(); // Analytics (MIN-78): attaches the following events to the account. THE // registration/login events themselves are emitted by the SERVER // (app/auth/callback), which reliably distinguishes a first // connection — the customer heuristic “account created less than a year ago // minute” from AutoKap was abandoned for this reason. if ((event === "SIGNED_IN" || event === "INITIAL_SESSION") && s?.user) { identify( s.user.id, { email: s.user.email, name: (s.user.user_metadata?.full_name as string | undefined) ?? null, }, { signup_date: s.user.created_at, signup_method: s.user.app_metadata?.provider ?? "email", } ); } else if (event === "SIGNED_OUT") { clearPersistedQueryCache(); clearAppTabsWindowState(); track("user_signed_out", {}); reset(); // The session ended on its own (revoked, expired, refused refresh): // leave the protected screen instead of polling into 401s (MIN-691). // An explicit sign-out navigates by itself. if (!explicitSignOut.current) leaveProtectedScreenAfterSignOut(); } }; // INITIAL_SESSION can be null after a temporary refresh failure even though // Supabase retains the refresh token. Retry without requiring a new sign-in. const recoverSession = async () => { if (disposed || recovering) return; window.clearTimeout(recoveryTimer); recovering = true; const startedAtRevision = revision; try { // SDK initialization and refresh retries can outlive a single network // request. Bound the whole read so a stalled attempt cannot suppress // every later foreground or network-return recovery. const { data, error } = await withClientRequestDeadline( () => supabase.auth.getSession(), AUTH_INIT_TIMEOUT_MS, recoveryController.signal, ); if (disposed || revision !== startedAtRevision) return; if (error) throw error; applySession("INITIAL_SESSION", data.session); } catch (error) { if (!disposed && revision === startedAtRevision) { if (isBackendUnavailableError(error)) { recoveryTimer = window.setTimeout(() => { void recoverSession(); }, AUTH_INIT_TIMEOUT_MS); } else { resolved = true; window.clearTimeout(timeoutId); setLoading(false); } } } finally { recovering = false; } }; const { data: { subscription }, } = supabase.auth.onAuthStateChange((event, s) => { window.clearTimeout(recoveryTimer); if (event === "INITIAL_SESSION" && !s) { // A null initial notification does not distinguish missing credentials // from a temporary refresh failure. Let getSession confirm it before // clearing an account already known by this provider or releasing the // startup gate. Otherwise desktop login can redirect to signup before // a retained session finishes restoring. The watchdog still bounds it. // Leave the auth callback before calling Supabase again: callbacks may // run while the SDK owns its session lock. recoveryTimer = window.setTimeout(() => { void recoverSession(); }, 0); return; } revision++; applySession(event, s); }); const resumeSession = () => { void recoverSession(); }; const resumeVisibleSession = () => { if (document.visibilityState === "visible") resumeSession(); }; window.addEventListener("online", resumeSession); window.addEventListener("focus", resumeSession); document.addEventListener("visibilitychange", resumeVisibleSession); return () => { disposed = true; recoveryController.abort(); window.clearTimeout(timeoutId); window.clearTimeout(recoveryTimer); window.removeEventListener("online", resumeSession); window.removeEventListener("focus", resumeSession); document.removeEventListener("visibilitychange", resumeVisibleSession); subscription.unsubscribe(); }; }, [identify, reset, track]); const signInWithPassword = useCallback(async (email: string, password: string) => { const { error } = await getSupabase().auth.signInWithPassword({ email, password, }); if (error) throw error; }, []); const signUpWithPassword = useCallback( async ( email: string, password: string, options?: { fullName?: string; avatarSeed?: string; avatarUploadToken?: string; } ) => { // The confirmation link will open in the DEFAULT browser, what // what we do — an email does not open in Electron. Marked `desktop=1`, // the callback will return the token to it instead of consuming it, and that's // the app that will have the session (MIN-291). Without this marker, register from // the app would connect the browser and leave the app on its screen // connexion. const confirmUrl = new URL(`${window.location.origin}/auth/callback`); if (getDesktopBridge()) confirmUrl.searchParams.set(DESKTOP_CALLBACK_FLAG, "1"); const { data, error } = await getSupabase().auth.signUp({ email, password, options: { // No `?next=` here (MIN-117): this is the GoTrue email TEMPLATE which // sets the final destination (/auth/confirmed). What is at stake in // this URL is the ORIGIN — dev and previews must receive a link // towards theirs, not towards production. GoTrue only retains it if it // appears in the “Redirect URLs” allowlist; otherwise it falls back on the // Site URL, and the confirmation link goes to the wrong domain. emailRedirectTo: confirmUrl.toString(), // The wizard runs before an account exists. Its Lorelei seed or // single-use staged-image token therefore travels in auth metadata // and is claimed on the first authenticated arrival. data: { ...(options?.fullName ? { full_name: options.fullName } : {}), ...(options?.avatarSeed ? { avatar_seed: options.avatarSeed } : {}), ...(options?.avatarUploadToken ? { avatar_upload_token: options.avatarUploadToken } : {}), // `locale`: the language of the interface at the time of registration. // This is what the GoTrue template of the confirmation email will read // — the very first sending of the account, and the only one that leaves BEFORE // that a session could have put anything together. Without this field, // this email comes out in English no matter what. locale: readInterfaceLocale(), }, }, }); if (error) throw error; return { requiresEmailConfirmation: !data.session }; }, [] ); const sendPasswordReset = useCallback(async (email: string) => { // Same URL as the registration confirmation, and for the same reasons: // it is the ORIGIN that is at stake here (dev, preview or prod), and the marker // `desktop=1` passes the token back to the app instead of connecting the // system browser (MIN-291). The final DESTINATION is posed by // the email template — `next=/reset-password` (MIN-117, MIN-297): nothing // what we add here in query does not survive the GoTrue allowlist. const callbackUrl = new URL(`${window.location.origin}/auth/callback`); if (getDesktopBridge()) callbackUrl.searchParams.set(DESKTOP_CALLBACK_FLAG, "1"); const { error } = await getSupabase().auth.resetPasswordForEmail(email, { redirectTo: callbackUrl.toString(), }); if (error) throw error; }, []); const signInWithOAuth = useCallback( async (provider: "google" | "github", redirectAfter?: string) => { const desktop = getDesktopBridge(); const callbackUrl = oauthCallbackUrl( window.location.origin, redirectAfter, desktop ? beginDesktopAuthTurn() : undefined, ); const { data, error } = await getSupabase().auth.signInWithOAuth({ provider, options: { redirectTo: callbackUrl.toString(), // Google rejects OAuth in embedded browsers. The desktop app requests // the URL and opens it in the system browser instead of navigating. ...(desktop ? { skipBrowserRedirect: true } : {}), }, }); if (error) throw error; if (desktop && data?.url) desktop.openExternal(data.url); }, [] ); const completeDesktopSignIn = useCallback(async (link: DesktopAuthLink) => { const supabase = getSupabase(); if (link.kind === "error") throw new Error(link.error); if (link.kind === "code") { const { error } = await supabase.auth.exchangeCodeForSession(link.code); if (error) throw error; } else { // The email link was NOT consumed by the callback: it transmitted it as // which, precisely so that the session is born here. const { error } = await supabase.auth.verifyOtp({ token_hash: link.tokenHash, type: link.type, }); if (error) throw error; } return link.next; }, []); const signOut = useCallback(async () => { explicitSignOut.current = true; // `scope: "local"` — WITHOUT it, supabase-js goes GLOBAL in scope and revokes // tokens from ALL devices in the account. Disconnect from your workstation // dev therefore also closed production, telephone and preview. Close one // session closes ONE session; cutting off others is a distinct gesture, and it // already has its call (`signOutOtherSessions`: the Security settings' // “Sign out other devices” control, and upon activation of 2FA). const { error } = await getSupabase().auth.signOut({ scope: "local" }); if (error) { explicitSignOut.current = false; throw error; } // The React Query cache is persisted on disk (MIN-89): without purging, the // next account on this machine would rehydrate the data of this one // before his own requests are successful. clearPersistedQueryCache(); clearAppTabsWindowState(); window.location.href = "/login"; }, []); const updateUser = useCallback( async (attributes: { password?: string; data?: Record }) => { const { data, error } = await getSupabase().auth.updateUser(attributes); if (error) throw error; if (data.user) { userRef.current = data.user; setUser(data.user); } }, [] ); const updateUserMetadata = useCallback((patch: Record) => { // Chain: each write waits for the previous one then merges its patch into // the MOST fresh metadata (userRef, updated synchronously here to // that the next link starts from the correct base). The channel survives a failure // (catch) but the caller sees the rejection and can revert. const run = metaWriteChain.current.then(async () => { const currentMeta = (userRef.current?.user_metadata ?? {}) as Record< string, unknown >; const { data, error } = await getSupabase().auth.updateUser({ data: { ...currentMeta, ...patch }, }); if (error) throw error; if (data.user) { userRef.current = data.user; setUser(data.user); } }); metaWriteChain.current = run.catch(() => {}); return run; }, []); // Paste `user_metadata.locale` onto the interface language. // // This field is NOT used by the app — it reads the `NEXT_LOCALE` cookie. It serves // to authentication emails, delivered by GoTrue without request or cookie: // `user_metadata` is all it knows about the recipient (see // `lib/interface-locale.ts` and `supabase/email-templates/`). The setting of // language of preferences already writes it itself; the other paths, no — // the public footer selector, Numo setting the language on the server side, // an OAuth connection, and any accounts created before this field existed. // // Hence this refresher course at each session: a complete writing // first time, nothing after that (the comparison cuts it short). This is what // which repairs the existing fleet — but only when the account reopens the app, // which leaves a requested password reset by then to go // en anglais. useEffect(() => { if (!user) return; const wanted = readInterfaceLocale(); if (user.user_metadata?.locale === wanted) return; void updateUserMetadata({ locale: wanted }).catch(() => {}); }, [user, updateUserMetadata]); const refreshUser = useCallback(async () => { const supabase = getSupabase(); // refreshSession mints a new JWT carrying the current user_metadata (so a // later reload / server request also sees the change) and updates state. const { data, error } = await supabase.auth.refreshSession(); if (!error && data.user) { userRef.current = data.user; setUser(data.user); if (data.session) { setSession(data.session); // Manual refreshes follow security-state changes such as MFA removal. // Reconnect after saving the new assurance claim so a rekey received // moments earlier cannot leave dedicated channels offline. signalRealtimeRekey(); } return; } // Fallback: fetch the fresh account without a token refresh. const { data: userData } = await supabase.auth.getUser(); if (userData.user) { userRef.current = userData.user; setUser(userData.user); } }, []); const enrollTotp = useCallback(async (friendlyName: string) => { const { data, error } = await getSupabase().auth.mfa.enroll({ factorType: "totp", friendlyName, }); if (error) throw error; return { factorId: data.id, qrCode: data.totp.qr_code, secret: data.totp.secret, }; }, []); const verifyTotp = useCallback(async (factorId: string, code: string) => { // `challengeAndVerify` = challenge + verify in one round trip. Success // replaces the current session with a `aal2` session; `onAuthStateChange` // propagates it, so nothing to copy here. const { error } = await getSupabase().auth.mfa.challengeAndVerify({ factorId, code, }); if (error) throw error; }, []); const unenrollTotp = useCallback(async (factorId: string) => { const supabase = getSupabase(); const { error } = await supabase.auth.mfa.unenroll({ factorId }); if (error) throw error; // Removing the last factor invalidates the old AAL2 claim. Mint AAL1 before // re-resolving the user and project topic generations rotated by the DB. const refreshed = await supabase.auth.refreshSession(); if (refreshed.error) throw refreshed.error; signalRealtimeRekey(); }, []); const firstTotpFactorId = useCallback(async () => { const { data, error } = await getSupabase().auth.mfa.listFactors(); if (error) throw error; return data.totp[0]?.id ?? null; }, []); const needsMfaChallenge = useCallback(async () => { const { data, error } = await getSupabase().auth.mfa.getAuthenticatorAssuranceLevel(); if (error || !data) return false; return data.currentLevel === "aal1" && data.nextLevel === "aal2"; }, []); const signOutOtherSessions = useCallback(async () => { const { error } = await getSupabase().auth.signOut({ scope: "others" }); if (error) throw error; }, []); // The value is STORED (MIN-315). An object literal here would re-render all // consumers with each rendering of this provider — and it is the highest of // the tree. The handlers above are all `useCallback`: the only ones // Dependencies that really move are the three states. const value = useMemo( () => ({ user, session, loading, oauthProviders, signInWithPassword, signUpWithPassword, sendPasswordReset, signInWithOAuth, completeDesktopSignIn, signOut, updateUser, updateUserMetadata, refreshUser, enrollTotp, verifyTotp, unenrollTotp, firstTotpFactorId, needsMfaChallenge, signOutOtherSessions, }), [ user, session, loading, oauthProviders, signInWithPassword, signUpWithPassword, sendPasswordReset, signInWithOAuth, completeDesktopSignIn, signOut, updateUser, updateUserMetadata, refreshUser, enrollTotp, verifyTotp, unenrollTotp, firstTotpFactorId, needsMfaChallenge, signOutOtherSessions, ] ); return {children}; } export function useAuth() { const ctx = useContext(AuthContext); if (!ctx) throw new Error("useAuth must be used within AuthProvider"); return ctx; } /** * The logged in user, or `null` OUTSIDE provider — for surfaces that * can be mounted on both sides of the authentication. * * There is now: a published page (MIN-283) mounts the real editor of * page, therefore the editor node views, for an anonymous visitor. `useAuth` * y lifts, and drops the entire page — on a component that only read one * account preference. Same bias as `useTaskSurface`: out of context, the * view renders what it can render rather than nothing. */ export function useAuthOptional() { return useContext(AuthContext); }