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}