auth.tsx

  1// auth.tsx — authentication context for the webui.
  2//
  3// Auth has three modes determined by the server's serverConfig.authMode:
  4//
  5//   local    Single-user mode. The identity is taken from git config at
  6//            server startup. No login UI is needed.
  7//
  8//   external Multi-user mode. Users log in via an OAuth or OIDC provider.
  9//            The current user is fetched from GET /auth/user and can be null
 10//            (not logged in) even while the server is running.
 11//
 12//   readonly No writes allowed. No identity is ever returned.
 13//
 14// All three modes expose the same AuthContextValue shape, so the rest of the
 15// component tree doesn't need to know which mode is active.
 16
 17import { gql } from "@apollo/client";
 18import { useQuery } from "@apollo/client/react";
 19import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
 20import * as v from "valibot";
 21
 22import { useServerConfigQuery } from "@/__generated__/graphql";
 23
 24const authUserSchema = v.object({
 25  id: v.string(),
 26  humanId: v.string(),
 27  name: v.nullable(v.string()),
 28  displayName: v.string(),
 29  avatarUrl: v.nullable(v.string()),
 30  email: v.nullable(v.string()),
 31  login: v.nullable(v.string()),
 32});
 33
 34// AuthUser matches the Identity type fields we care about for auth purposes.
 35export type AuthUser = v.InferOutput<typeof authUserSchema>;
 36
 37// 'local'    — single-user mode, identity from git config
 38// 'external' — multi-user mode, identity from OAuth/OIDC session
 39// 'readonly' — no identity, write operations disabled
 40export type AuthMode = "local" | "external" | "readonly";
 41
 42export interface AuthContextValue {
 43  user: AuthUser | null;
 44  mode: AuthMode;
 45  // List of enabled login provider names, e.g. ['github']. Only set in external mode.
 46  loginProviders: string[];
 47  loading: boolean;
 48}
 49
 50const AuthContext = createContext<AuthContextValue>({
 51  user: null,
 52  mode: "readonly",
 53  loginProviders: [],
 54  loading: true,
 55});
 56
 57// ── Local mode ────────────────────────────────────────────────────────────────
 58
 59const USER_IDENTITY_QUERY = gql`
 60  query UserIdentity {
 61    repository {
 62      userIdentity {
 63        id
 64        humanId
 65        name
 66        displayName
 67        avatarUrl
 68        email
 69        login
 70      }
 71    }
 72  }
 73`;
 74
 75function LocalAuthProvider({
 76  children,
 77  loginProviders,
 78}: {
 79  children: ReactNode;
 80  loginProviders: string[];
 81}) {
 82  const { data, loading } = useQuery<{ repository: { userIdentity: AuthUser | null } }>(
 83    USER_IDENTITY_QUERY,
 84  );
 85  const user: AuthUser | null = data?.repository?.userIdentity ?? null;
 86  const mode: AuthMode = loading ? "local" : user ? "local" : "readonly";
 87  return (
 88    <AuthContext.Provider value={{ user, mode, loginProviders, loading }}>
 89      {children}
 90    </AuthContext.Provider>
 91  );
 92}
 93
 94// ── External (OAuth / OIDC) mode ──────────────────────────────────────────────
 95
 96// ExternalAuthProvider fetches the current user from the REST endpoint that the
 97// Go auth handler exposes. A 401 response means "not logged in" (user is null),
 98// not an error.
 99function ExternalAuthProvider({
100  children,
101  loginProviders,
102}: {
103  children: ReactNode;
104  loginProviders: string[];
105}) {
106  const [user, setUser] = useState<AuthUser | null>(null);
107  const [loading, setLoading] = useState(true);
108
109  useEffect(() => {
110    void fetch("/auth/user", { credentials: "include" })
111      .then(async (res) => {
112        if (res.status === 401) return null;
113        if (!res.ok) throw new Error(`/auth/user returned ${res.status}`);
114        return v.parse(authUserSchema, await res.json());
115      })
116      .then((u) => setUser(u))
117      .catch(() => setUser(null))
118      .finally(() => setLoading(false));
119  }, []);
120
121  return (
122    <AuthContext.Provider value={{ user, mode: "external", loginProviders, loading }}>
123      {children}
124    </AuthContext.Provider>
125  );
126}
127
128// ── Read-only mode ────────────────────────────────────────────────────────────
129
130function ReadonlyAuthProvider({ children }: { children: ReactNode }) {
131  return (
132    <AuthContext.Provider
133      value={{ user: null, mode: "readonly", loginProviders: [], loading: false }}
134    >
135      {children}
136    </AuthContext.Provider>
137  );
138}
139
140// ── Root provider ─────────────────────────────────────────────────────────────
141
142// AuthProvider first fetches serverConfig to learn the auth mode, then renders
143// the appropriate sub-provider. The split avoids conditional hook calls.
144export function AuthProvider({ children }: { children: ReactNode }) {
145  const { data, loading } = useServerConfigQuery();
146
147  if (loading || !data) {
148    // Keep the default context (readonly + loading:true) while the config loads.
149    return (
150      <AuthContext.Provider
151        value={{ user: null, mode: "readonly", loginProviders: [], loading: true }}
152      >
153        {children}
154      </AuthContext.Provider>
155    );
156  }
157
158  const { authMode, loginProviders } = data.serverConfig;
159
160  if (authMode === "readonly") {
161    return <ReadonlyAuthProvider>{children}</ReadonlyAuthProvider>;
162  }
163
164  if (authMode === "external") {
165    return <ExternalAuthProvider loginProviders={loginProviders}>{children}</ExternalAuthProvider>;
166  }
167
168  // Default: 'local'
169  return <LocalAuthProvider loginProviders={loginProviders}>{children}</LocalAuthProvider>;
170}
171
172export function useAuth(): AuthContextValue {
173  return useContext(AuthContext);
174}