fix: resolve frontend build errors and enable credentials for options requests in backend

This commit is contained in:
Usman Baig
2026-01-18 21:32:47 +01:00
parent 2fda4667ed
commit 3e7273363b
3 changed files with 388 additions and 391 deletions

View File

@@ -1,135 +1,134 @@
1|'use client' 'use client'
2|
3|import { useEffect, useState, Suspense, useRef } from 'react' import { useEffect, useState, Suspense, useRef } from 'react'
4|import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
5|import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
6|import { AUTH_URL } from '@/lib/api/client' import { AUTH_URL } from '@/lib/api/client'
7|import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth' import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth'
8|
9|function AuthCallbackContent() { function AuthCallbackContent() {
10| const router = useRouter() const router = useRouter()
11| const searchParams = useSearchParams() const searchParams = useSearchParams()
12| const { login } = useAuth() const { login } = useAuth()
13| const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
14| const processedRef = useRef(false) const processedRef = useRef(false)
15|
16| useEffect(() => { useEffect(() => {
17| // * Prevent double execution (React Strict Mode or fast re-renders) // * Prevent double execution (React Strict Mode or fast re-renders)
18| if (processedRef.current) return if (processedRef.current) return
19|
20| // * Check for direct token passing (from auth-frontend direct login) // * 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 // * TODO: This flow exposes tokens in URL, should be deprecated in favor of Authorization Code flow
22| const token = searchParams.get('token') const token = searchParams.get('token')
23| const refreshToken = searchParams.get('refresh_token') const refreshToken = searchParams.get('refresh_token')
24|
25| if (token && refreshToken) { if (token && refreshToken) {
26| processedRef.current = true processedRef.current = true
27|
28| const handleDirectTokens = async () => { const handleDirectTokens = async () => {
29| const result = await setSessionAction(token, refreshToken) const result = await setSessionAction(token, refreshToken)
30| if (result.success && result.user) { if (result.success && result.user) {
31| login(result.user) login(result.user)
32| const returnTo = searchParams.get('returnTo') || '/' const returnTo = searchParams.get('returnTo') || '/'
33| router.push(returnTo) router.push(returnTo)
34| } else { } else {
35| setError('Invalid token received') setError('Invalid token received')
36| } }
37| } }
38| handleDirectTokens() handleDirectTokens()
39| return return
40| } }
41|
42| const code = searchParams.get('code') const code = searchParams.get('code')
43| const state = searchParams.get('state') const state = searchParams.get('state')
44|
45| // * Skip if params are missing (might be initial render before params are ready) // * Skip if params are missing (might be initial render before params are ready)
46| if (!code || !state) return if (!code || !state) return
47|
48| processedRef.current = true processedRef.current = true
49|
50| const storedState = localStorage.getItem('oauth_state') const storedState = localStorage.getItem('oauth_state')
51| const codeVerifier = localStorage.getItem('oauth_code_verifier') const codeVerifier = localStorage.getItem('oauth_code_verifier')
52|
53| if (!code || !state) { if (!code || !state) {
54| setError('Missing code or state') setError('Missing code or state')
55| return return
56| } }
57|
58| if (state !== storedState) { if (state !== storedState) {
59| console.error('State mismatch', { received: state, stored: storedState }) console.error('State mismatch', { received: state, stored: storedState })
60| setError('Invalid state') setError('Invalid state')
61| return return
62| } }
63|
64| if (!codeVerifier) { if (!codeVerifier) {
65| setError('Missing code verifier') setError('Missing code verifier')
66| return return
67| } }
68|
69| const exchangeCode = async () => { const exchangeCode = async () => {
70| try { try {
71| const redirectUri = window.location.origin + '/auth/callback' const redirectUri = window.location.origin + '/auth/callback'
72| const result = await exchangeAuthCode(code, codeVerifier, redirectUri) const result = await exchangeAuthCode(code, codeVerifier, redirectUri)
73|
74| if (!result.success || !result.user) { if (!result.success || !result.user) {
75| throw new Error(result.error || 'Failed to exchange token') throw new Error(result.error || 'Failed to exchange token')
76| } }
77|
78| login(result.user) login(result.user)
79|
80| // * Cleanup // * Cleanup
81| localStorage.removeItem('oauth_state') localStorage.removeItem('oauth_state')
82| localStorage.removeItem('oauth_code_verifier') localStorage.removeItem('oauth_code_verifier')
83|
84| router.push('/') router.push('/')
85| } catch (err: any) { } catch (err: any) {
86| setError(err.message) setError(err.message)
87| } }
88| } }
89|
90| exchangeCode() exchangeCode()
91| }, [searchParams, login, router]) }, [searchParams, login, router])
92|
93| if (error) { if (error) {
94| return ( return (
95| <div className="flex min-h-screen items-center justify-center p-4"> <div className="flex min-h-screen items-center justify-center p-4">
96| <div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4 text-red-500"> <div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4 text-red-500">
97| Error: {error} Error: {error}
98| <div className="mt-4"> <div className="mt-4">
99| <button <button
100| onClick={() => window.location.href = `${AUTH_URL}/login`} onClick={() => window.location.href = `${AUTH_URL}/login`}
101| className="text-sm underline" className="text-sm underline"
102| > >
103| Back to Login Back to Login
104| </button> </button>
105| </div> </div>
106| </div> </div>
107| </div> </div>
108| ) )
109| } }
110|
111| return ( return (
112| <div className="flex min-h-screen items-center justify-center p-4"> <div className="flex min-h-screen items-center justify-center p-4">
113| <div className="text-center"> <div className="text-center">
114| <div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-neutral-800 mx-auto mb-4"></div> <div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-neutral-800 mx-auto mb-4"></div>
115| <p className="text-neutral-600 dark:text-neutral-400">Completing sign in...</p> <p className="text-neutral-600 dark:text-neutral-400">Completing sign in...</p>
116| </div> </div>
117| </div> </div>
118| ) )
119|} }
120|
121|export default function AuthCallback() { export default function AuthCallback() {
122| return ( return (
123| <Suspense fallback={ <Suspense fallback={
124| <div className="flex min-h-screen items-center justify-center p-4"> <div className="flex min-h-screen items-center justify-center p-4">
125| <div className="text-center"> <div className="text-center">
126| <div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-neutral-800 mx-auto mb-4"></div> <div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-neutral-800 mx-auto mb-4"></div>
127| <p className="text-neutral-600 dark:text-neutral-400">Loading...</p> <p className="text-neutral-600 dark:text-neutral-400">Loading...</p>
128| </div> </div>
129| </div> </div>
130| }> }>
131| <AuthCallbackContent /> <AuthCallbackContent />
132| </Suspense> </Suspense>
133| ) )
134|} }
135|

View File

@@ -1,147 +1,146 @@
1|/** /**
2| * HTTP client wrapper for API calls * HTTP client wrapper for API calls
3| */ */
4|
5|export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082' 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' 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' 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' 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') { export function getLoginUrl(redirectPath = '/auth/callback') {
11| const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`)
12| return `${AUTH_URL}/login?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code` return `${AUTH_URL}/login?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code`
13|} }
14|
15|export function getSignupUrl(redirectPath = '/auth/callback') { export function getSignupUrl(redirectPath = '/auth/callback') {
16| const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`)
17| return `${AUTH_URL}/signup?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code` return `${AUTH_URL}/signup?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code`
18|} }
19|
20|export class ApiError extends Error { export class ApiError extends Error {
21| status: number status: number
22| constructor(message: string, status: number) { constructor(message: string, status: number) {
23| super(message) super(message)
24| this.status = status this.status = status
25| } }
26|} }
27|
28|// * Mutex for token refresh // * Mutex for token refresh
29|let isRefreshing = false let isRefreshing = false
30|let refreshSubscribers: (() => void)[] = [] let refreshSubscribers: (() => void)[] = []
31|
32|function subscribeToTokenRefresh(cb: () => void) { function subscribeToTokenRefresh(cb: () => void) {
33| refreshSubscribers.push(cb) refreshSubscribers.push(cb)
34|} }
35|
36|function onRefreshed() { function onRefreshed() {
37| refreshSubscribers.map((cb) => cb()) refreshSubscribers.map((cb) => cb())
38| refreshSubscribers = [] refreshSubscribers = []
39|} }
40|
41|/** /**
42| * Base API client with error handling * Base API client with error handling
43| */ */
44|async function apiRequest<T>( async function apiRequest<T>(
45| endpoint: string, endpoint: string,
46| options: RequestInit = {} options: RequestInit = {}
47|): Promise<T> { ): Promise<T> {
48| // * Determine base URL // * Determine base URL
49| const isAuthRequest = endpoint.startsWith('/auth') const isAuthRequest = endpoint.startsWith('/auth')
50| const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL
51| const url = `${baseUrl}/api/v1${endpoint}` const url = `${baseUrl}/api/v1${endpoint}`
52|
53| const headers: HeadersInit = { const headers: HeadersInit = {
54| 'Content-Type': 'application/json', 'Content-Type': 'application/json',
55| ...options.headers, ...options.headers,
56| } }
57|
58| // * We rely on HttpOnly cookies, so no manual Authorization header injection. // * 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). // * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site).
60|
61| const response = await fetch(url, { const response = await fetch(url, {
62| ...options, ...options,
63| headers, headers,
64| credentials: 'include', // * IMPORTANT: Send cookies credentials: 'include', // * IMPORTANT: Send cookies
65| }) })
66|
67| if (!response.ok) { if (!response.ok) {
68| if (response.status === 401) { if (response.status === 401) {
69| // * Attempt Token Refresh if 401 // * Attempt Token Refresh if 401
70| if (typeof window !== 'undefined') { 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) // * 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')) { if (!endpoint.includes('/auth/refresh')) {
73| if (isRefreshing) { if (isRefreshing) {
74| // * If refresh is already in progress, wait for it to complete // * If refresh is already in progress, wait for it to complete
75| return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
76| subscribeToTokenRefresh(async () => { subscribeToTokenRefresh(async () => {
77| // Retry original request (browser uses new cookie) // Retry original request (browser uses new cookie)
78| try { try {
79| const retryResponse = await fetch(url, { const retryResponse = await fetch(url, {
80| ...options, ...options,
81| headers, headers,
82| credentials: 'include', credentials: 'include',
83| }) })
84| if (retryResponse.ok) { if (retryResponse.ok) {
85| resolve(retryResponse.json()) resolve(retryResponse.json())
86| } else { } else {
87| reject(new ApiError('Retry failed', retryResponse.status)) reject(new ApiError('Retry failed', retryResponse.status))
88| } }
89| } catch (e) { } catch (e) {
90| reject(e) reject(e)
91| } }
92| }) })
93| }) })
94| } }
95|
96| isRefreshing = true isRefreshing = true
97|
98| try { try {
99| // * Call our internal Next.js API route to handle refresh securely // * Call our internal Next.js API route to handle refresh securely
100| const refreshRes = await fetch('/api/auth/refresh', { const refreshRes = await fetch('/api/auth/refresh', {
101| method: 'POST', method: 'POST',
102| headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
103| }) })
104|
105| if (refreshRes.ok) { if (refreshRes.ok) {
106| // * Refresh successful, cookies updated // * Refresh successful, cookies updated
107| onRefreshed() onRefreshed()
108|
109| // * Retry original request // * Retry original request
110| const retryResponse = await fetch(url, { const retryResponse = await fetch(url, {
111| ...options, ...options,
112| headers, headers,
113| credentials: 'include', credentials: 'include',
114| }) })
115|
116| if (retryResponse.ok) { if (retryResponse.ok) {
117| return retryResponse.json() return retryResponse.json()
118| } }
119| } else { } else {
120| // * Refresh failed, logout // * Refresh failed, logout
121| localStorage.removeItem('user') localStorage.removeItem('user')
122| // * Redirect to login if needed, or let the app handle 401 // * Redirect to login if needed, or let the app handle 401
123| // window.location.href = '/' // window.location.href = '/'
124| } }
125| } catch (e) { } catch (e) {
126| // * Network error during refresh // * Network error during refresh
127| throw e throw e
128| } finally { } finally {
129| isRefreshing = false isRefreshing = false
130| } }
131| } }
132| } }
133| } }
134|
135| const errorBody = await response.json().catch(() => ({ const errorBody = await response.json().catch(() => ({
136| error: 'Unknown error', error: 'Unknown error',
137| message: `HTTP ${response.status}: ${response.statusText}`, message: `HTTP ${response.status}: ${response.statusText}`,
138| })) }))
139| throw new ApiError(errorBody.message || errorBody.error || 'Request failed', response.status) throw new ApiError(errorBody.message || errorBody.error || 'Request failed', response.status)
140| } }
141|
142| return response.json() return response.json()
143|} }
144|
145|export const authFetch = apiRequest export const authFetch = apiRequest
146|export default apiRequest export default apiRequest
147|

View File

@@ -1,109 +1,108 @@
1|'use client' 'use client'
2|
3|import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
4|import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
5|import apiRequest from '@/lib/api/client' import apiRequest from '@/lib/api/client'
6|import LoadingOverlay from '@/components/LoadingOverlay' import LoadingOverlay from '@/components/LoadingOverlay'
7|import { logoutAction, getSessionAction } from '@/app/actions/auth' import { logoutAction, getSessionAction } from '@/app/actions/auth'
8|
9|interface User { interface User {
10| id: string id: string
11| email: string email: string
12| totp_enabled: boolean totp_enabled: boolean
13|} }
14|
15|interface AuthContextType { interface AuthContextType {
16| user: User | null user: User | null
17| loading: boolean loading: boolean
18| login: (user: User) => void login: (user: User) => void
19| logout: () => void logout: () => void
20| refresh: () => Promise<void> refresh: () => Promise<void>
21| refreshSession: () => Promise<void> refreshSession: () => Promise<void>
22|} }
23|
24|const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType>({
25| user: null, user: null,
26| loading: true, loading: true,
27| login: () => {}, login: () => {},
28| logout: () => {}, logout: () => {},
29| refresh: async () => {}, refresh: async () => {},
30| refreshSession: async () => {}, refreshSession: async () => {},
31|}) })
32|
33|export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
34| const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
35| const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
36| const [isLoggingOut, setIsLoggingOut] = useState(false) const [isLoggingOut, setIsLoggingOut] = useState(false)
37| const router = useRouter() const router = useRouter()
38|
39| const login = (userData: User) => { const login = (userData: User) => {
40| // * We still store user profile in localStorage for optimistic UI, but NOT the token // * We still store user profile in localStorage for optimistic UI, but NOT the token
41| localStorage.setItem('user', JSON.stringify(userData)) localStorage.setItem('user', JSON.stringify(userData))
42| setUser(userData) setUser(userData)
43| router.refresh() router.refresh()
44| } }
45|
46| const logout = useCallback(async () => { const logout = useCallback(async () => {
47| setIsLoggingOut(true) setIsLoggingOut(true)
48| await logoutAction() await logoutAction()
49| localStorage.removeItem('user') localStorage.removeItem('user')
50| // * Clear legacy tokens if they exist // * Clear legacy tokens if they exist
51| localStorage.removeItem('token') localStorage.removeItem('token')
52| localStorage.removeItem('refreshToken') localStorage.removeItem('refreshToken')
53|
54| setTimeout(() => { setTimeout(() => {
55| window.location.href = '/' window.location.href = '/'
56| }, 500) }, 500)
57| }, []) }, [])
58|
59| const refresh = useCallback(async () => { const refresh = useCallback(async () => {
60| try { try {
61| const userData = await apiRequest<User>('/auth/user/me') const userData = await apiRequest<User>('/auth/user/me')
62| setUser(userData) setUser(userData)
63| localStorage.setItem('user', JSON.stringify(userData)) localStorage.setItem('user', JSON.stringify(userData))
64| } catch (e) { } catch (e) {
65| console.error('Failed to refresh user data', e) console.error('Failed to refresh user data', e)
66| } }
67| router.refresh() router.refresh()
68| }, [router]) }, [router])
69|
70| const refreshSession = useCallback(async () => { const refreshSession = useCallback(async () => {
71| await refresh() await refresh()
72| }, [refresh]) }, [refresh])
73|
74| // Initial load // Initial load
75| useEffect(() => { useEffect(() => {
76| const init = async () => { const init = async () => {
77| // * 1. Check server-side session (cookies) // * 1. Check server-side session (cookies)
78| const session = await getSessionAction() const session = await getSessionAction()
79|
80| if (session) { if (session) {
81| setUser(session) setUser(session)
82| localStorage.setItem('user', JSON.stringify(session)) localStorage.setItem('user', JSON.stringify(session))
83| } else { } else {
84| // * Session invalid/expired // * Session invalid/expired
85| localStorage.removeItem('user') localStorage.removeItem('user')
86| setUser(null) setUser(null)
87| } }
88|
89| // * Clear legacy tokens if they exist (migration) // * Clear legacy tokens if they exist (migration)
90| if (localStorage.getItem('token')) { if (localStorage.getItem('token')) {
91| localStorage.removeItem('token') localStorage.removeItem('token')
92| localStorage.removeItem('refreshToken') localStorage.removeItem('refreshToken')
93| } }
94|
95| setLoading(false) setLoading(false)
96| } }
97| init() init()
98| }, []) }, [])
99|
100| return ( return (
101| <AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}> <AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}>
102| {isLoggingOut && <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Analytics" />} {isLoggingOut && <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Analytics" />}
103| {children} {children}
104| </AuthContext.Provider> </AuthContext.Provider>
105| ) )
106|} }
107|
108|export const useAuth = () => useContext(AuthContext) export const useAuth = () => useContext(AuthContext)
109|