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}