lib/auth-context.tsx
Rama main · réplica de lectura del repositorio original.
"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);
}