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";
 20
 21import { useServerConfigQuery } from "@/__generated__/graphql";
 22
 23// AuthUser matches the Identity type fields we care about for auth purposes.
 24export interface AuthUser {
 25  id: string;
 26  humanId: string;
 27  name: string | null;
 28  displayName: string;
 29  avatarUrl: string | null;
 30  email: string | null;
 31  login: string | null;
 32}
 33
 34// 'local'    — single-user mode, identity from git config
 35// 'external' — multi-user mode, identity from OAuth/OIDC session
 36// 'readonly' — no identity, write operations disabled
 37export type AuthMode = "local" | "external" | "readonly";
 38
 39export interface AuthContextValue {
 40  user: AuthUser | null;
 41  mode: AuthMode;
 42  // List of enabled login provider names, e.g. ['github']. Only set in external mode.
 43  loginProviders: string[];
 44  loading: boolean;
 45}
 46
 47const AuthContext = createContext<AuthContextValue>({
 48  user: null,
 49  mode: "readonly",
 50  loginProviders: [],
 51  loading: true,
 52});
 53
 54// ── Local mode ────────────────────────────────────────────────────────────────
 55
 56const USER_IDENTITY_QUERY = gql`
 57  query UserIdentity {
 58    repository {
 59      userIdentity {
 60        id
 61        humanId
 62        name
 63        displayName
 64        avatarUrl
 65        email
 66        login
 67      }
 68    }
 69  }
 70`;
 71
 72function LocalAuthProvider({
 73  children,
 74  loginProviders,
 75}: {
 76  children: ReactNode;
 77  loginProviders: string[];
 78}) {
 79  const { data, loading } = useQuery<{ repository: { userIdentity: AuthUser | null } }>(
 80    USER_IDENTITY_QUERY,
 81  );
 82  const user: AuthUser | null = data?.repository?.userIdentity ?? null;
 83  const mode: AuthMode = loading ? "local" : user ? "local" : "readonly";
 84  return (
 85    <AuthContext.Provider value={{ user, mode, loginProviders, loading }}>
 86      {children}
 87    </AuthContext.Provider>
 88  );
 89}
 90
 91// ── External (OAuth / OIDC) mode ──────────────────────────────────────────────
 92
 93// ExternalAuthProvider fetches the current user from the REST endpoint that the
 94// Go auth handler exposes. A 401 response means "not logged in" (user is null),
 95// not an error.
 96function ExternalAuthProvider({
 97  children,
 98  loginProviders,
 99}: {
100  children: ReactNode;
101  loginProviders: string[];
102}) {
103  const [user, setUser] = useState<AuthUser | null>(null);
104  const [loading, setLoading] = useState(true);
105
106  useEffect(() => {
107    void fetch("/auth/user", { credentials: "include" })
108      .then((res) => {
109        if (res.status === 401) return null;
110        if (!res.ok) throw new Error(`/auth/user returned ${res.status}`);
111        return res.json() as Promise<AuthUser>;
112      })
113      .then((u) => setUser(u))
114      .catch(() => setUser(null))
115      .finally(() => setLoading(false));
116  }, []);
117
118  return (
119    <AuthContext.Provider value={{ user, mode: "external", loginProviders, loading }}>
120      {children}
121    </AuthContext.Provider>
122  );
123}
124
125// ── Read-only mode ────────────────────────────────────────────────────────────
126
127function ReadonlyAuthProvider({ children }: { children: ReactNode }) {
128  return (
129    <AuthContext.Provider
130      value={{ user: null, mode: "readonly", loginProviders: [], loading: false }}
131    >
132      {children}
133    </AuthContext.Provider>
134  );
135}
136
137// ── Root provider ─────────────────────────────────────────────────────────────
138
139// AuthProvider first fetches serverConfig to learn the auth mode, then renders
140// the appropriate sub-provider. The split avoids conditional hook calls.
141export function AuthProvider({ children }: { children: ReactNode }) {
142  const { data, loading } = useServerConfigQuery();
143
144  if (loading || !data) {
145    // Keep the default context (readonly + loading:true) while the config loads.
146    return (
147      <AuthContext.Provider
148        value={{ user: null, mode: "readonly", loginProviders: [], loading: true }}
149      >
150        {children}
151      </AuthContext.Provider>
152    );
153  }
154
155  const { authMode, loginProviders } = data.serverConfig;
156
157  if (authMode === "readonly") {
158    return <ReadonlyAuthProvider>{children}</ReadonlyAuthProvider>;
159  }
160
161  if (authMode === "external") {
162    return <ExternalAuthProvider loginProviders={loginProviders}>{children}</ExternalAuthProvider>;
163  }
164
165  // Default: 'local'
166  return <LocalAuthProvider loginProviders={loginProviders}>{children}</LocalAuthProvider>;
167}
168
169export function useAuth(): AuthContextValue {
170  return useContext(AuthContext);
171}