diff --git a/app/actions/auth.ts b/app/actions/auth.ts index fca3de3..2aef7e3 100644 --- a/app/actions/auth.ts +++ b/app/actions/auth.ts @@ -1,143 +1,144 @@ -'use server' - -import { cookies } from 'next/headers' - -const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081' - -interface AuthResponse { - access_token: string - refresh_token: string - id_token: string - expires_in: number -} - -interface UserPayload { - sub: string - email?: string - totp_enabled?: boolean -} - -export async function exchangeAuthCode(code: string, codeVerifier: string, redirectUri: string) { - try { - const res = await fetch(`${AUTH_API_URL}/oauth/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - grant_type: 'authorization_code', - code, - client_id: 'analytics-app', - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }), - }) - - if (!res.ok) { - const data = await res.json() - throw new Error(data.error || 'Failed to exchange token') - } - - const data: AuthResponse = await res.json() - - // * Decode payload (without verification, we trust the direct channel to Auth Server) - const payloadPart = data.access_token.split('.')[1] - const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) - - // * Set Cookies - const cookieStore = await cookies() - - // * Access Token - cookieStore.set('access_token', data.access_token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 60 * 15 // 15 minutes (short lived) - }) - - // * Refresh Token (Long lived) - cookieStore.set('refresh_token', data.refresh_token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 60 * 60 * 24 * 30 // 30 days - }) - - return { - success: true, - user: { - id: payload.sub, - email: payload.email || 'user@ciphera.net', - totp_enabled: payload.totp_enabled || false - } - } - - } catch (error: any) { - console.error('Auth Exchange Error:', error) - return { success: false, error: error.message } - } -} - -export async function setSessionAction(accessToken: string, refreshToken: string) { - try { - const payloadPart = accessToken.split('.')[1] - const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) - - const cookieStore = await cookies() - - cookieStore.set('access_token', accessToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 60 * 15 - }) - - cookieStore.set('refresh_token', refreshToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 60 * 60 * 24 * 30 - }) - - return { - success: true, - user: { - id: payload.sub, - email: payload.email || 'user@ciphera.net', - totp_enabled: payload.totp_enabled || false - } - } - } catch (e) { - return { success: false, error: 'Invalid token' } - } -} - -export async function logoutAction() { - const cookieStore = await cookies() - cookieStore.delete('access_token') - cookieStore.delete('refresh_token') - return { success: true } -} - -export async function getSessionAction() { - const cookieStore = await cookies() - const token = cookieStore.get('access_token') - - if (!token) return null - - try { - const payloadPart = token.value.split('.')[1] - const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) - return { - id: payload.sub, - email: payload.email || 'user@ciphera.net', - totp_enabled: payload.totp_enabled || false - } - } catch { - return null - } -} + 1|'use server' + 2| + 3|import { cookies } from 'next/headers' + 4| + 5|const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081' + 6| + 7|interface AuthResponse { + 8| access_token: string + 9| refresh_token: string + 10| id_token: string + 11| expires_in: number + 12|} + 13| + 14|interface UserPayload { + 15| sub: string + 16| email?: string + 17| totp_enabled?: boolean + 18|} + 19| + 20|export async function exchangeAuthCode(code: string, codeVerifier: string, redirectUri: string) { + 21| try { + 22| const res = await fetch(`${AUTH_API_URL}/oauth/token`, { + 23| method: 'POST', + 24| headers: { + 25| 'Content-Type': 'application/json', + 26| }, + 27| body: JSON.stringify({ + 28| grant_type: 'authorization_code', + 29| code, + 30| client_id: 'analytics-app', + 31| redirect_uri: redirectUri, + 32| code_verifier: codeVerifier, + 33| }), + 34| }) + 35| + 36| if (!res.ok) { + 37| const data = await res.json() + 38| throw new Error(data.error || 'Failed to exchange token') + 39| } + 40| + 41| const data: AuthResponse = await res.json() + 42| + 43| // * Decode payload (without verification, we trust the direct channel to Auth Server) + 44| const payloadPart = data.access_token.split('.')[1] + 45| const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) + 46| + 47| // * Set Cookies + 48| const cookieStore = await cookies() + 49| + 50| // * Access Token + 51| cookieStore.set('access_token', data.access_token, { + 52| httpOnly: true, + 53| secure: process.env.NODE_ENV === 'production', + 54| sameSite: 'lax', + 55| path: '/', + 56| maxAge: 60 * 15 // 15 minutes (short lived) + 57| }) + 58| + 59| // * Refresh Token (Long lived) + 60| cookieStore.set('refresh_token', data.refresh_token, { + 61| httpOnly: true, + 62| secure: process.env.NODE_ENV === 'production', + 63| sameSite: 'lax', + 64| path: '/', + 65| maxAge: 60 * 60 * 24 * 30 // 30 days + 66| }) + 67| + 68| return { + 69| success: true, + 70| user: { + 71| id: payload.sub, + 72| email: payload.email || 'user@ciphera.net', + 73| totp_enabled: payload.totp_enabled || false + 74| } + 75| } + 76| + 77| } catch (error: any) { + 78| console.error('Auth Exchange Error:', error) + 79| return { success: false, error: error.message } + 80| } + 81|} + 82| + 83|export async function setSessionAction(accessToken: string, refreshToken: string) { + 84| try { + 85| const payloadPart = accessToken.split('.')[1] + 86| const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) + 87| + 88| const cookieStore = await cookies() + 89| + 90| cookieStore.set('access_token', accessToken, { + 91| httpOnly: true, + 92| secure: process.env.NODE_ENV === 'production', + 93| sameSite: 'lax', + 94| path: '/', + 95| maxAge: 60 * 15 + 96| }) + 97| + 98| cookieStore.set('refresh_token', refreshToken, { + 99| httpOnly: true, + 100| secure: process.env.NODE_ENV === 'production', + 101| sameSite: 'lax', + 102| path: '/', + 103| maxAge: 60 * 60 * 24 * 30 + 104| }) + 105| + 106| return { + 107| success: true, + 108| user: { + 109| id: payload.sub, + 110| email: payload.email || 'user@ciphera.net', + 111| totp_enabled: payload.totp_enabled || false + 112| } + 113| } + 114| } catch (e) { + 115| return { success: false, error: 'Invalid token' } + 116| } + 117|} + 118| + 119|export async function logoutAction() { + 120| const cookieStore = await cookies() + 121| cookieStore.delete('access_token') + 122| cookieStore.delete('refresh_token') + 123| return { success: true } + 124|} + 125| + 126|export async function getSessionAction() { + 127| const cookieStore = await cookies() + 128| const token = cookieStore.get('access_token') + 129| + 130| if (!token) return null + 131| + 132| try { + 133| const payloadPart = token.value.split('.')[1] + 134| const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) + 135| return { + 136| id: payload.sub, + 137| email: payload.email || 'user@ciphera.net', + 138| totp_enabled: payload.totp_enabled || false + 139| } + 140| } catch { + 141| return null + 142| } + 143|} + 144| \ No newline at end of file diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index 0d452b7..7b9b7c2 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -1,134 +1,135 @@ -'use client' - -import { useEffect, useState, Suspense, useRef } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import { useAuth } from '@/lib/auth/context' -import { AUTH_URL } from '@/lib/api/client' -import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth' - -function AuthCallbackContent() { - const router = useRouter() - const searchParams = useSearchParams() - const { login } = useAuth() - const [error, setError] = useState(null) - const processedRef = useRef(false) - - useEffect(() => { - // * Prevent double execution (React Strict Mode or fast re-renders) - if (processedRef.current) return - - // * Check for direct token passing (from auth-frontend direct login) - // * TODO: This flow exposes tokens in URL, should be deprecated in favor of Authorization Code flow - const token = searchParams.get('token') - const refreshToken = searchParams.get('refresh_token') - - if (token && refreshToken) { - processedRef.current = true - - const handleDirectTokens = async () => { - const result = await setSessionAction(token, refreshToken) - if (result.success && result.user) { - login(result.user) - const returnTo = searchParams.get('returnTo') || '/' - router.push(returnTo) - } else { - setError('Invalid token received') - } - } - handleDirectTokens() - return - } - - const code = searchParams.get('code') - const state = searchParams.get('state') - - // * Skip if params are missing (might be initial render before params are ready) - if (!code || !state) return - - processedRef.current = true - - const storedState = localStorage.getItem('oauth_state') - const codeVerifier = localStorage.getItem('oauth_code_verifier') - - if (!code || !state) { - setError('Missing code or state') - return - } - - if (state !== storedState) { - console.error('State mismatch', { received: state, stored: storedState }) - setError('Invalid state') - return - } - - if (!codeVerifier) { - setError('Missing code verifier') - return - } - - const exchangeCode = async () => { - try { - const redirectUri = window.location.origin + '/auth/callback' - const result = await exchangeAuthCode(code, codeVerifier, redirectUri) - - if (!result.success || !result.user) { - throw new Error(result.error || 'Failed to exchange token') - } - - login(result.user) - - // * Cleanup - localStorage.removeItem('oauth_state') - localStorage.removeItem('oauth_code_verifier') - - router.push('/') - } catch (err: any) { - setError(err.message) - } - } - - exchangeCode() - }, [searchParams, login, router]) - - if (error) { - return ( -
-
- Error: {error} -
- -
-
-
- ) - } - - return ( -
-
-
-

Completing sign in...

-
-
- ) -} - -export default function AuthCallback() { - return ( - -
-
-

Loading...

-
- - }> - -
- ) -} + 1|'use client' + 2| + 3|import { useEffect, useState, Suspense, useRef } from 'react' + 4|import { useRouter, useSearchParams } from 'next/navigation' + 5|import { useAuth } from '@/lib/auth/context' + 6|import { AUTH_URL } from '@/lib/api/client' + 7|import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth' + 8| + 9|function AuthCallbackContent() { + 10| const router = useRouter() + 11| const searchParams = useSearchParams() + 12| const { login } = useAuth() + 13| const [error, setError] = useState(null) + 14| const processedRef = useRef(false) + 15| + 16| useEffect(() => { + 17| // * Prevent double execution (React Strict Mode or fast re-renders) + 18| if (processedRef.current) return + 19| + 20| // * Check for direct token passing (from auth-frontend direct login) + 21| // * TODO: This flow exposes tokens in URL, should be deprecated in favor of Authorization Code flow + 22| const token = searchParams.get('token') + 23| const refreshToken = searchParams.get('refresh_token') + 24| + 25| if (token && refreshToken) { + 26| processedRef.current = true + 27| + 28| const handleDirectTokens = async () => { + 29| const result = await setSessionAction(token, refreshToken) + 30| if (result.success && result.user) { + 31| login(result.user) + 32| const returnTo = searchParams.get('returnTo') || '/' + 33| router.push(returnTo) + 34| } else { + 35| setError('Invalid token received') + 36| } + 37| } + 38| handleDirectTokens() + 39| return + 40| } + 41| + 42| const code = searchParams.get('code') + 43| const state = searchParams.get('state') + 44| + 45| // * Skip if params are missing (might be initial render before params are ready) + 46| if (!code || !state) return + 47| + 48| processedRef.current = true + 49| + 50| const storedState = localStorage.getItem('oauth_state') + 51| const codeVerifier = localStorage.getItem('oauth_code_verifier') + 52| + 53| if (!code || !state) { + 54| setError('Missing code or state') + 55| return + 56| } + 57| + 58| if (state !== storedState) { + 59| console.error('State mismatch', { received: state, stored: storedState }) + 60| setError('Invalid state') + 61| return + 62| } + 63| + 64| if (!codeVerifier) { + 65| setError('Missing code verifier') + 66| return + 67| } + 68| + 69| const exchangeCode = async () => { + 70| try { + 71| const redirectUri = window.location.origin + '/auth/callback' + 72| const result = await exchangeAuthCode(code, codeVerifier, redirectUri) + 73| + 74| if (!result.success || !result.user) { + 75| throw new Error(result.error || 'Failed to exchange token') + 76| } + 77| + 78| login(result.user) + 79| + 80| // * Cleanup + 81| localStorage.removeItem('oauth_state') + 82| localStorage.removeItem('oauth_code_verifier') + 83| + 84| router.push('/') + 85| } catch (err: any) { + 86| setError(err.message) + 87| } + 88| } + 89| + 90| exchangeCode() + 91| }, [searchParams, login, router]) + 92| + 93| if (error) { + 94| return ( + 95|
+ 96|
+ 97| Error: {error} + 98|
+ 99| + 105|
+ 106|
+ 107|
+ 108| ) + 109| } + 110| + 111| return ( + 112|
+ 113|
+ 114|
+ 115|

Completing sign in...

+ 116|
+ 117|
+ 118| ) + 119|} + 120| + 121|export default function AuthCallback() { + 122| return ( + 123| + 125|
+ 126|
+ 127|

Loading...

+ 128|
+ 129| + 130| }> + 131| + 132|
+ 133| ) + 134|} + 135| \ No newline at end of file diff --git a/lib/api/client.ts b/lib/api/client.ts index 9b725d9..6105940 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -1,146 +1,147 @@ -/** - * HTTP client wrapper for API calls - */ - -export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082' -export const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3000' -export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003' -export const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || 'https://auth-api.ciphera.net' - -export function getLoginUrl(redirectPath = '/auth/callback') { - const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) - return `${AUTH_URL}/login?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code` -} - -export function getSignupUrl(redirectPath = '/auth/callback') { - const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) - return `${AUTH_URL}/signup?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code` -} - -export class ApiError extends Error { - status: number - constructor(message: string, status: number) { - super(message) - this.status = status - } -} - -// * Mutex for token refresh -let isRefreshing = false -let refreshSubscribers: (() => void)[] = [] - -function subscribeToTokenRefresh(cb: () => void) { - refreshSubscribers.push(cb) -} - -function onRefreshed() { - refreshSubscribers.map((cb) => cb()) - refreshSubscribers = [] -} - -/** - * Base API client with error handling - */ -async function apiRequest( - endpoint: string, - options: RequestInit = {} -): Promise { - // * Determine base URL - const isAuthRequest = endpoint.startsWith('/auth') - const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL - const url = `${baseUrl}/api/v1${endpoint}` - - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...options.headers, - } - - // * We rely on HttpOnly cookies, so no manual Authorization header injection. - // * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site). - - const response = await fetch(url, { - ...options, - headers, - credentials: 'include', // * IMPORTANT: Send cookies - }) - - if (!response.ok) { - if (response.status === 401) { - // * Attempt Token Refresh if 401 - if (typeof window !== 'undefined') { - // * Prevent infinite loop: Don't refresh if the failed request WAS a refresh request (unlikely via apiRequest but safe to check) - if (!endpoint.includes('/auth/refresh')) { - if (isRefreshing) { - // * If refresh is already in progress, wait for it to complete - return new Promise((resolve, reject) => { - subscribeToTokenRefresh(async () => { - // Retry original request (browser uses new cookie) - try { - const retryResponse = await fetch(url, { - ...options, - headers, - credentials: 'include', - }) - if (retryResponse.ok) { - resolve(retryResponse.json()) - } else { - reject(new ApiError('Retry failed', retryResponse.status)) - } - } catch (e) { - reject(e) - } - }) - }) - } - - isRefreshing = true - - try { - // * Call our internal Next.js API route to handle refresh securely - const refreshRes = await fetch('/api/auth/refresh', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }) - - if (refreshRes.ok) { - // * Refresh successful, cookies updated - onRefreshed() - - // * Retry original request - const retryResponse = await fetch(url, { - ...options, - headers, - credentials: 'include', - }) - - if (retryResponse.ok) { - return retryResponse.json() - } - } else { - // * Refresh failed, logout - localStorage.removeItem('user') - // * Redirect to login if needed, or let the app handle 401 - // window.location.href = '/' - } - } catch (e) { - // * Network error during refresh - throw e - } finally { - isRefreshing = false - } - } - } - } - - const errorBody = await response.json().catch(() => ({ - error: 'Unknown error', - message: `HTTP ${response.status}: ${response.statusText}`, - })) - throw new ApiError(errorBody.message || errorBody.error || 'Request failed', response.status) - } - - return response.json() -} - -export const authFetch = apiRequest -export default apiRequest + 1|/** + 2| * HTTP client wrapper for API calls + 3| */ + 4| + 5|export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082' + 6|export const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3000' + 7|export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003' + 8|export const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || 'https://auth-api.ciphera.net' + 9| + 10|export function getLoginUrl(redirectPath = '/auth/callback') { + 11| const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) + 12| return `${AUTH_URL}/login?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code` + 13|} + 14| + 15|export function getSignupUrl(redirectPath = '/auth/callback') { + 16| const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) + 17| return `${AUTH_URL}/signup?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code` + 18|} + 19| + 20|export class ApiError extends Error { + 21| status: number + 22| constructor(message: string, status: number) { + 23| super(message) + 24| this.status = status + 25| } + 26|} + 27| + 28|// * Mutex for token refresh + 29|let isRefreshing = false + 30|let refreshSubscribers: (() => void)[] = [] + 31| + 32|function subscribeToTokenRefresh(cb: () => void) { + 33| refreshSubscribers.push(cb) + 34|} + 35| + 36|function onRefreshed() { + 37| refreshSubscribers.map((cb) => cb()) + 38| refreshSubscribers = [] + 39|} + 40| + 41|/** + 42| * Base API client with error handling + 43| */ + 44|async function apiRequest( + 45| endpoint: string, + 46| options: RequestInit = {} + 47|): Promise { + 48| // * Determine base URL + 49| const isAuthRequest = endpoint.startsWith('/auth') + 50| const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL + 51| const url = `${baseUrl}/api/v1${endpoint}` + 52| + 53| const headers: HeadersInit = { + 54| 'Content-Type': 'application/json', + 55| ...options.headers, + 56| } + 57| + 58| // * We rely on HttpOnly cookies, so no manual Authorization header injection. + 59| // * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site). + 60| + 61| const response = await fetch(url, { + 62| ...options, + 63| headers, + 64| credentials: 'include', // * IMPORTANT: Send cookies + 65| }) + 66| + 67| if (!response.ok) { + 68| if (response.status === 401) { + 69| // * Attempt Token Refresh if 401 + 70| if (typeof window !== 'undefined') { + 71| // * Prevent infinite loop: Don't refresh if the failed request WAS a refresh request (unlikely via apiRequest but safe to check) + 72| if (!endpoint.includes('/auth/refresh')) { + 73| if (isRefreshing) { + 74| // * If refresh is already in progress, wait for it to complete + 75| return new Promise((resolve, reject) => { + 76| subscribeToTokenRefresh(async () => { + 77| // Retry original request (browser uses new cookie) + 78| try { + 79| const retryResponse = await fetch(url, { + 80| ...options, + 81| headers, + 82| credentials: 'include', + 83| }) + 84| if (retryResponse.ok) { + 85| resolve(retryResponse.json()) + 86| } else { + 87| reject(new ApiError('Retry failed', retryResponse.status)) + 88| } + 89| } catch (e) { + 90| reject(e) + 91| } + 92| }) + 93| }) + 94| } + 95| + 96| isRefreshing = true + 97| + 98| try { + 99| // * Call our internal Next.js API route to handle refresh securely + 100| const refreshRes = await fetch('/api/auth/refresh', { + 101| method: 'POST', + 102| headers: { 'Content-Type': 'application/json' }, + 103| }) + 104| + 105| if (refreshRes.ok) { + 106| // * Refresh successful, cookies updated + 107| onRefreshed() + 108| + 109| // * Retry original request + 110| const retryResponse = await fetch(url, { + 111| ...options, + 112| headers, + 113| credentials: 'include', + 114| }) + 115| + 116| if (retryResponse.ok) { + 117| return retryResponse.json() + 118| } + 119| } else { + 120| // * Refresh failed, logout + 121| localStorage.removeItem('user') + 122| // * Redirect to login if needed, or let the app handle 401 + 123| // window.location.href = '/' + 124| } + 125| } catch (e) { + 126| // * Network error during refresh + 127| throw e + 128| } finally { + 129| isRefreshing = false + 130| } + 131| } + 132| } + 133| } + 134| + 135| const errorBody = await response.json().catch(() => ({ + 136| error: 'Unknown error', + 137| message: `HTTP ${response.status}: ${response.statusText}`, + 138| })) + 139| throw new ApiError(errorBody.message || errorBody.error || 'Request failed', response.status) + 140| } + 141| + 142| return response.json() + 143|} + 144| + 145|export const authFetch = apiRequest + 146|export default apiRequest + 147| \ No newline at end of file diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index 0cbb96e..14ce63c 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -1,108 +1,109 @@ -'use client' - -import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' -import { useRouter } from 'next/navigation' -import apiRequest from '@/lib/api/client' -import LoadingOverlay from '@/components/LoadingOverlay' -import { logoutAction, getSessionAction } from '@/app/actions/auth' - -interface User { - id: string - email: string - totp_enabled: boolean -} - -interface AuthContextType { - user: User | null - loading: boolean - login: (user: User) => void - logout: () => void - refresh: () => Promise - refreshSession: () => Promise -} - -const AuthContext = createContext({ - user: null, - loading: true, - login: () => {}, - logout: () => {}, - refresh: async () => {}, - refreshSession: async () => {}, -}) - -export function AuthProvider({ children }: { children: React.ReactNode }) { - const [user, setUser] = useState(null) - const [loading, setLoading] = useState(true) - const [isLoggingOut, setIsLoggingOut] = useState(false) - const router = useRouter() - - const login = (userData: User) => { - // * We still store user profile in localStorage for optimistic UI, but NOT the token - localStorage.setItem('user', JSON.stringify(userData)) - setUser(userData) - router.refresh() - } - - const logout = useCallback(async () => { - setIsLoggingOut(true) - await logoutAction() - localStorage.removeItem('user') - // * Clear legacy tokens if they exist - localStorage.removeItem('token') - localStorage.removeItem('refreshToken') - - setTimeout(() => { - window.location.href = '/' - }, 500) - }, []) - - const refresh = useCallback(async () => { - try { - const userData = await apiRequest('/auth/user/me') - setUser(userData) - localStorage.setItem('user', JSON.stringify(userData)) - } catch (e) { - console.error('Failed to refresh user data', e) - } - router.refresh() - }, [router]) - - const refreshSession = useCallback(async () => { - await refresh() - }, [refresh]) - - // Initial load - useEffect(() => { - const init = async () => { - // * 1. Check server-side session (cookies) - const session = await getSessionAction() - - if (session) { - setUser(session) - localStorage.setItem('user', JSON.stringify(session)) - } else { - // * Session invalid/expired - localStorage.removeItem('user') - setUser(null) - } - - // * Clear legacy tokens if they exist (migration) - if (localStorage.getItem('token')) { - localStorage.removeItem('token') - localStorage.removeItem('refreshToken') - } - - setLoading(false) - } - init() - }, []) - - return ( - - {isLoggingOut && } - {children} - - ) -} - -export const useAuth = () => useContext(AuthContext) + 1|'use client' + 2| + 3|import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' + 4|import { useRouter } from 'next/navigation' + 5|import apiRequest from '@/lib/api/client' + 6|import LoadingOverlay from '@/components/LoadingOverlay' + 7|import { logoutAction, getSessionAction } from '@/app/actions/auth' + 8| + 9|interface User { + 10| id: string + 11| email: string + 12| totp_enabled: boolean + 13|} + 14| + 15|interface AuthContextType { + 16| user: User | null + 17| loading: boolean + 18| login: (user: User) => void + 19| logout: () => void + 20| refresh: () => Promise + 21| refreshSession: () => Promise + 22|} + 23| + 24|const AuthContext = createContext({ + 25| user: null, + 26| loading: true, + 27| login: () => {}, + 28| logout: () => {}, + 29| refresh: async () => {}, + 30| refreshSession: async () => {}, + 31|}) + 32| + 33|export function AuthProvider({ children }: { children: React.ReactNode }) { + 34| const [user, setUser] = useState(null) + 35| const [loading, setLoading] = useState(true) + 36| const [isLoggingOut, setIsLoggingOut] = useState(false) + 37| const router = useRouter() + 38| + 39| const login = (userData: User) => { + 40| // * We still store user profile in localStorage for optimistic UI, but NOT the token + 41| localStorage.setItem('user', JSON.stringify(userData)) + 42| setUser(userData) + 43| router.refresh() + 44| } + 45| + 46| const logout = useCallback(async () => { + 47| setIsLoggingOut(true) + 48| await logoutAction() + 49| localStorage.removeItem('user') + 50| // * Clear legacy tokens if they exist + 51| localStorage.removeItem('token') + 52| localStorage.removeItem('refreshToken') + 53| + 54| setTimeout(() => { + 55| window.location.href = '/' + 56| }, 500) + 57| }, []) + 58| + 59| const refresh = useCallback(async () => { + 60| try { + 61| const userData = await apiRequest('/auth/user/me') + 62| setUser(userData) + 63| localStorage.setItem('user', JSON.stringify(userData)) + 64| } catch (e) { + 65| console.error('Failed to refresh user data', e) + 66| } + 67| router.refresh() + 68| }, [router]) + 69| + 70| const refreshSession = useCallback(async () => { + 71| await refresh() + 72| }, [refresh]) + 73| + 74| // Initial load + 75| useEffect(() => { + 76| const init = async () => { + 77| // * 1. Check server-side session (cookies) + 78| const session = await getSessionAction() + 79| + 80| if (session) { + 81| setUser(session) + 82| localStorage.setItem('user', JSON.stringify(session)) + 83| } else { + 84| // * Session invalid/expired + 85| localStorage.removeItem('user') + 86| setUser(null) + 87| } + 88| + 89| // * Clear legacy tokens if they exist (migration) + 90| if (localStorage.getItem('token')) { + 91| localStorage.removeItem('token') + 92| localStorage.removeItem('refreshToken') + 93| } + 94| + 95| setLoading(false) + 96| } + 97| init() + 98| }, []) + 99| + 100| return ( + 101| + 102| {isLoggingOut && } + 103| {children} + 104| + 105| ) + 106|} + 107| + 108|export const useAuth = () => useContext(AuthContext) + 109| \ No newline at end of file