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// oauth Multi-user mode. Users log in via an OAuth provider. The
9// 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 { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
18import { gql, useQuery } from '@apollo/client'
19import { useServerConfigQuery } from '@/__generated__/graphql'
20
21// AuthUser matches the Identity type fields we care about for auth purposes.
22export interface AuthUser {
23 id: string
24 humanId: string
25 name: string | null
26 displayName: string
27 avatarUrl: string | null
28 email: string | null
29 login: string | null
30}
31
32// 'local' — single-user mode, identity from git config
33// 'oauth' — multi-user mode, identity from OAuth session
34// 'readonly' — no identity, write operations disabled
35export type AuthMode = 'local' | 'oauth' | 'readonly'
36
37export interface AuthContextValue {
38 user: AuthUser | null
39 mode: AuthMode
40 // List of enabled OAuth provider names, e.g. ['github']. Only set in oauth mode.
41 oauthProviders: string[]
42 loading: boolean
43}
44
45const AuthContext = createContext<AuthContextValue>({
46 user: null,
47 mode: 'readonly',
48 oauthProviders: [],
49 loading: true,
50})
51
52// ── Local mode ────────────────────────────────────────────────────────────────
53
54const USER_IDENTITY_QUERY = gql`
55 query UserIdentity {
56 repository {
57 userIdentity {
58 id
59 humanId
60 name
61 displayName
62 avatarUrl
63 email
64 login
65 }
66 }
67 }
68`
69
70function LocalAuthProvider({
71 children,
72 oauthProviders,
73}: {
74 children: ReactNode
75 oauthProviders: string[]
76}) {
77 const { data, loading } = useQuery(USER_IDENTITY_QUERY)
78 const user: AuthUser | null = data?.repository?.userIdentity ?? null
79 const mode: AuthMode = loading ? 'local' : user ? 'local' : 'readonly'
80 return (
81 <AuthContext.Provider value={{ user, mode, oauthProviders, loading }}>
82 {children}
83 </AuthContext.Provider>
84 )
85}
86
87// ── OAuth mode ────────────────────────────────────────────────────────────────
88
89// OAuthAuthProvider fetches the current user from the REST endpoint that the
90// Go auth handler exposes. A 401 response means "not logged in" (user is null),
91// not an error.
92function OAuthAuthProvider({
93 children,
94 oauthProviders,
95}: {
96 children: ReactNode
97 oauthProviders: string[]
98}) {
99 const [user, setUser] = useState<AuthUser | null>(null)
100 const [loading, setLoading] = useState(true)
101
102 useEffect(() => {
103 fetch('/auth/user', { credentials: 'include' })
104 .then((res) => {
105 if (res.status === 401) return null
106 if (!res.ok) throw new Error(`/auth/user returned ${res.status}`)
107 return res.json() as Promise<AuthUser>
108 })
109 .then((u) => setUser(u))
110 .catch(() => setUser(null))
111 .finally(() => setLoading(false))
112 }, [])
113
114 return (
115 <AuthContext.Provider
116 value={{ user, mode: 'oauth', oauthProviders, loading }}
117 >
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', oauthProviders: [], 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', oauthProviders: [], loading: true }}
147 >
148 {children}
149 </AuthContext.Provider>
150 )
151 }
152
153 const { authMode, oauthProviders } = data.serverConfig
154
155 if (authMode === 'readonly') {
156 return <ReadonlyAuthProvider>{children}</ReadonlyAuthProvider>
157 }
158
159 if (authMode === 'oauth') {
160 return (
161 <OAuthAuthProvider oauthProviders={oauthProviders}>
162 {children}
163 </OAuthAuthProvider>
164 )
165 }
166
167 // Default: 'local'
168 return (
169 <LocalAuthProvider oauthProviders={oauthProviders}>
170 {children}
171 </LocalAuthProvider>
172 )
173}
174
175export function useAuth(): AuthContextValue {
176 return useContext(AuthContext)
177}