Barazo default frontend
barazo.forum
1/**
2 * Auth context provider for AT Protocol OAuth.
3 * Access token held in useRef (memory only, never localStorage/sessionStorage).
4 * Silent refresh on mount via HTTP-only cookie.
5 * @see specs/prd-web.md Section M3 (Auth Flow)
6 */
7
8'use client'
9
10import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react'
11import type { ReactNode } from 'react'
12import type { AuthSession, AuthUser } from '@/lib/api/types'
13import {
14 initiateLogin,
15 initiateCrossPostAuth,
16 refreshSession,
17 logout as apiLogout,
18} from '@/lib/api/client'
19import { createAuthFetch } from '@/lib/api/auth-fetch'
20
21export interface AuthContextValue {
22 /** The current authenticated user, or null */
23 user: AuthUser | null
24 /** Whether the user is authenticated */
25 isAuthenticated: boolean
26 /** Whether auth state is still loading (initial refresh) */
27 isLoading: boolean
28 /** Whether the user has authorized cross-post scopes */
29 crossPostScopesGranted: boolean
30 /** Get the current access token (stable function ref) */
31 getAccessToken: () => string | null
32 /** Initiate login flow -- redirects to PDS OAuth */
33 login: (handle: string) => Promise<void>
34 /** Log out and clear auth state */
35 logout: () => Promise<void>
36 /** Set session from OAuth callback (stores token in memory) */
37 setSessionFromCallback: (session: AuthSession) => void
38 /** Initiate cross-post authorization flow (redirects to PDS OAuth with expanded scopes) */
39 requestCrossPostAuth: () => Promise<void>
40 /** Auth-aware fetch that auto-refreshes on 401 */
41 authFetch: <T>(
42 path: string,
43 options?: {
44 method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
45 headers?: Record<string, string>
46 body?: unknown
47 signal?: AbortSignal
48 }
49 ) => Promise<T>
50}
51
52export const AuthContext = createContext<AuthContextValue | null>(null)
53
54interface AuthProviderProps {
55 children: ReactNode
56}
57
58export function AuthProvider({ children }: AuthProviderProps) {
59 const [user, setUser] = useState<AuthUser | null>(null)
60 const [isLoading, setIsLoading] = useState(true)
61 const [crossPostScopesGranted, setCrossPostScopesGranted] = useState(false)
62 const tokenRef = useRef<string | null>(null)
63
64 const getAccessToken = useCallback(() => tokenRef.current, [])
65
66 const setSession = useCallback((session: AuthSession) => {
67 tokenRef.current = session.accessToken
68 setUser({
69 did: session.did,
70 handle: session.handle,
71 displayName: session.displayName,
72 avatarUrl: session.avatarUrl,
73 role: session.role,
74 })
75 setCrossPostScopesGranted(session.crossPostScopesGranted ?? false)
76 }, [])
77
78 const clearSession = useCallback(() => {
79 tokenRef.current = null
80 setUser(null)
81 setCrossPostScopesGranted(false)
82 }, [])
83
84 const handleAuthFailure = useCallback(() => {
85 clearSession()
86 }, [clearSession])
87
88 const authFetch = useMemo(
89 () =>
90 createAuthFetch({
91 getToken: () => tokenRef.current,
92 setToken: setSession,
93 onAuthFailure: handleAuthFailure,
94 }),
95 [setSession, handleAuthFailure]
96 )
97
98 // Silent refresh on mount
99 useEffect(() => {
100 let cancelled = false
101
102 async function attemptRefresh() {
103 try {
104 const session = await refreshSession()
105 if (!cancelled) {
106 setSession(session)
107 }
108 } catch {
109 // No valid refresh cookie -- user is not logged in
110 } finally {
111 if (!cancelled) {
112 setIsLoading(false)
113 }
114 }
115 }
116
117 void attemptRefresh()
118 return () => {
119 cancelled = true
120 }
121 }, [setSession])
122
123 const login = useCallback(async (handle: string) => {
124 const { url } = await initiateLogin(handle)
125 window.location.href = url
126 }, [])
127
128 const logout = useCallback(async () => {
129 const token = tokenRef.current
130 if (token) {
131 try {
132 await apiLogout(token)
133 } catch {
134 // Best-effort server-side logout
135 }
136 }
137 clearSession()
138 }, [clearSession])
139
140 const requestCrossPostAuth = useCallback(async () => {
141 const token = tokenRef.current
142 if (!token) return
143 sessionStorage.setItem('auth_returnTo', window.location.href)
144 const { url } = await initiateCrossPostAuth(token)
145 window.location.href = url
146 }, [])
147
148 const value = useMemo<AuthContextValue>(
149 () => ({
150 user,
151 isAuthenticated: user !== null,
152 isLoading,
153 crossPostScopesGranted,
154 getAccessToken,
155 login,
156 logout,
157 setSessionFromCallback: setSession,
158 requestCrossPostAuth,
159 authFetch,
160 }),
161 [
162 user,
163 isLoading,
164 crossPostScopesGranted,
165 getAccessToken,
166 login,
167 logout,
168 setSession,
169 requestCrossPostAuth,
170 authFetch,
171 ]
172 )
173
174 return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
175}