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//   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}