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}