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(USER_IDENTITY_QUERY);
 80  const user: AuthUser | null = data?.repository?.userIdentity ?? null;
 81  const mode: AuthMode = loading ? "local" : user ? "local" : "readonly";
 82  return (
 83    <AuthContext.Provider value={{ user, mode, loginProviders, loading }}>
 84      {children}
 85    </AuthContext.Provider>
 86  );
 87}
 88
 89// ── External (OAuth / OIDC) mode ──────────────────────────────────────────────
 90
 91// ExternalAuthProvider fetches the current user from the REST endpoint that the
 92// Go auth handler exposes. A 401 response means "not logged in" (user is null),
 93// not an error.
 94function ExternalAuthProvider({
 95  children,
 96  loginProviders,
 97}: {
 98  children: ReactNode;
 99  loginProviders: string[];
100}) {
101  const [user, setUser] = useState<AuthUser | null>(null);
102  const [loading, setLoading] = useState(true);
103
104  useEffect(() => {
105    void fetch("/auth/user", { credentials: "include" })
106      .then((res) => {
107        if (res.status === 401) return null;
108        if (!res.ok) throw new Error(`/auth/user returned ${res.status}`);
109        return res.json() as Promise<AuthUser>;
110      })
111      .then((u) => setUser(u))
112      .catch(() => setUser(null))
113      .finally(() => setLoading(false));
114  }, []);
115
116  return (
117    <AuthContext.Provider value={{ user, mode: "external", loginProviders, loading }}>
118      {children}
119    </AuthContext.Provider>
120  );
121}
122
123// ── Read-only mode ────────────────────────────────────────────────────────────
124
125function ReadonlyAuthProvider({ children }: { children: ReactNode }) {
126  return (
127    <AuthContext.Provider
128      value={{ user: null, mode: "readonly", loginProviders: [], loading: false }}
129    >
130      {children}
131    </AuthContext.Provider>
132  );
133}
134
135// ── Root provider ─────────────────────────────────────────────────────────────
136
137// AuthProvider first fetches serverConfig to learn the auth mode, then renders
138// the appropriate sub-provider. The split avoids conditional hook calls.
139export function AuthProvider({ children }: { children: ReactNode }) {
140  const { data, loading } = useServerConfigQuery();
141
142  if (loading || !data) {
143    // Keep the default context (readonly + loading:true) while the config loads.
144    return (
145      <AuthContext.Provider
146        value={{ user: null, mode: "readonly", loginProviders: [], loading: true }}
147      >
148        {children}
149      </AuthContext.Provider>
150    );
151  }
152
153  const { authMode, loginProviders } = data.serverConfig;
154
155  if (authMode === "readonly") {
156    return <ReadonlyAuthProvider>{children}</ReadonlyAuthProvider>;
157  }
158
159  if (authMode === "external") {
160    return <ExternalAuthProvider loginProviders={loginProviders}>{children}</ExternalAuthProvider>;
161  }
162
163  // Default: 'local'
164  return <LocalAuthProvider loginProviders={loginProviders}>{children}</LocalAuthProvider>;
165}
166
167export function useAuth(): AuthContextValue {
168  return useContext(AuthContext);
169}