go-auth
GuidesClient

Auth Provider

A React context built on authApi that turns the raw client into app-wide user/loading state, with an auto-signout when a session dies elsewhere.

Auth Provider

authApi and sessionsApi are request helpers. This React context keeps user and loading state in one <AuthProvider> and exposes it through useAuth().

With app permissions disabled (the default), user role remains supported as "admin" or "user" and determines account access. With app permissions enabled, user role is omitted. Keep app access in a separate account-scoped snapshot from /auth/access, which is available only in enabled mode; see the app roles and permissions client for permission-aware controls and clearing or refreshing that state.

"use client";

import {
  createContext,
  useContext,
  useState,
  useEffect,
  useCallback,
  type ReactNode,
} from "react";
import { authApi } from "./authApi";
import { setUnauthorizedHandler } from "./client";

interface User {
  id: string;
  email: string;
  name: string;
  role?: "admin" | "user"; // present when app permissions are disabled
  appRoleId?: string;
  appRoleAssignmentRevision?: number;
  isVerified: boolean;
  hasPassword: boolean;
}

interface AuthOutcome {
  step: "authenticated" | "verify_email" | "two_factor";
  challengeId?: string;
  expiresAt?: string;
  codeSent?: boolean;
  message?: string;
}

interface AuthContextValue {
  user: User | null;
  loading: boolean;
  error: string | null;
  refresh: () => Promise<void>;
  login: (input: { email: string; password: string }) => Promise<AuthOutcome>;
  register: (input: { email: string; password: string; name: string }) => Promise<AuthOutcome>;
  verifyTwoFactor: (challengeId: string, code: string) => Promise<void>;
  resendTwoFactor: (challengeId: string) => Promise<void>;
  logout: () => Promise<void>;
}

const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const refresh = useCallback(async () => {
    try {
      setUser(await authApi.me());
      setError(null);
    } catch {
      setUser(null); // 401 here just means "not logged in", not a bug
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    setUnauthorizedHandler(() => setUser(null));
    refresh();
  }, [refresh]);

  const login = useCallback(async (input: { email: string; password: string }) => {
    setError(null);
    try {
      const result = await authApi.login(input);
      if (result.step === "verify_email" || result.step === "two_factor") return result;
      await refresh();
      return result;
    } catch (err: any) {
      setError(err.message ?? err.error);
      throw err;
    }
  }, [refresh]);

  const register = useCallback(async (input: { email: string; password: string; name: string }) => {
    setError(null);
    try {
      const result = await authApi.register(input);
      if (result.step === "verify_email" || result.step === "two_factor") return result;
      await refresh();
      return result;
    } catch (err: any) {
      setError(err.message ?? err.error);
      throw err;
    }
  }, [refresh]);

  const verifyTwoFactor = useCallback(async (challengeId: string, code: string) => {
    setError(null);
    try {
      await authApi.verifyTwoFactor(challengeId, code);
      await refresh();
    } catch (err: any) {
      setError(err.message ?? err.error);
      throw err;
    }
  }, [refresh]);

  const resendTwoFactor = useCallback(async (challengeId: string) => {
    setError(null);
    try {
      await authApi.resendTwoFactor(challengeId);
    } catch (err: any) {
      setError(err.message ?? err.error);
      throw err;
    }
  }, []);

  const logout = useCallback(async () => {
    setError(null);
    try {
      await authApi.logout();
      setUser(null);
      if (typeof window !== "undefined") window.location.href = "/login";
    } catch (err: any) {
      setError(err.message ?? err.error);
      throw err;
    }
  }, []);

  return (
    <AuthContext.Provider value={{ user, loading, error, refresh, login, register, verifyTwoFactor, resendTwoFactor, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth(): AuthContextValue {
  const ctx = useContext(AuthContext);
  if (!ctx) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return ctx;
}

How the provider works

Mount-time refresh(). useEffect(() => { refresh() }, []) calls GET /auth/me once to hydrate user from an existing goauth_session cookie.

loading starts as true. Before the first refresh() resolves, the client has not determined whether a user exists. Gate route rendering on loading === false, not only on user being non-null.

setUnauthorizedHandler(() => setUser(null)). This clears app-wide user state when any request receives 401 or 403 user_banned for a revoked, banned, or expired session.

Gated login and registration. A response with step: "verify_email" or step: "two_factor" does not create a session, so login and register return that response without calling refresh(). Store challengeId while showing a 2FA prompt, then call verifyTwoFactor; only a successful verification refreshes user.

Why login/register call refresh() after a session is issued. This keeps refresh() as the one code path that populates user, at the cost of one request.

Using it

Mount once, near the root:

// app/layout.tsx
import { AuthProvider } from "./auth-provider";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <AuthProvider>{children}</AuthProvider>
      </body>
    </html>
  );
}

Then anywhere below it:

function AccountMenu() {
  const { user, loading, logout } = useAuth();

  if (loading) return <Skeleton />;
  if (!user) return <a href="/login">Log in</a>;

  return (
    <div>
      {user.name}
      <button onClick={logout}>Log out</button>
    </div>
  );
}

Next

  • Setup — the apiRequest helper and setUnauthorizedHandler this is built on
  • Authentication client — authApi, including me and two-factor methods
  • App roles and permissions client — current access, role management, and permission-aware controls
  • Middleware — the server-side counterpart: redirecting unauthenticated requests before a page even renders

On this page