Ctrl K
ComunidadGuías…

lib/auth-context.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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<void>;
  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<void>;
  /** Starts an OAuth sign-in flow for an enabled Supabase external provider. */
  signInWithOAuth: (
    provider: OAuthProvider,
    redirectAfter?: string
  ) => Promise<void>;
  /**
   * 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<string>;
  signOut: () => Promise<void>;
  updateUser: (attributes: {
    password?: string;
    data?: Record<string, unknown>;
  }) => Promise<void>;
  /**
   * 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<string, unknown>) => Promise<void>;
  /**
   * 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<void>;
  /**
   * 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<void>;
  /** Removes a Postman — used to clean up an abandoned enlistment. */
  unenrollTotp: (factorId: string) => Promise<void>;
  /** The first TOTP factor of the account, verified or not. */
  firstTotpFactorId: () => Promise<string | null>;
  /**
   * 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<boolean>;
  /**
   * 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<void>;
}

// 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<AuthContextValue | null>(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<User | null>(null);
  const explicitSignOut = useRef(false);
  const [session, setSession] = useState<Session | null>(null);
  const [loading, setLoading] = useState(true);
  const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([]);
  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<User | null>(null);
  useEffect(() => {
    userRef.current = user;
  }, [user]);
  // String of serialized metadata writes (see updateUserMetadata).
  const metaWriteChain = useRef<Promise<void>>(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<string, unknown>)[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<string, unknown> }) => {
      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<string, unknown>) => {
    // 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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

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);
}