From 8edd3774d23373eb278b5b338ca2eaee63bb6ed4 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Thu, 25 Dec 2025 22:54:39 -0500 Subject: [PATCH] Support Refresh token and improve authentication and security. Refactor authentication handling and API client integration - Updated UserProfileAvatar to use 'access_token' instead of 'ca_token' for logout. - Removed SSEProvider from Contexts and adjusted related imports. - Introduced useAuth hook for centralized authentication logic, including login, logout, and token management. - Refactored useSSE to utilize the new useAuth hook for token validation. - Updated UserQueries to check for token validity using the new method. - Deleted AuthenticationService as its functionality is now handled by useAuth. - Created a new ApiClient utility for handling API requests and token management. - Updated various components and views to use the new ApiClient for API interactions. - Removed TokenManager and migrated its functionality to the new ApiClient. - Adjusted LoginView and related components to utilize the new authentication flow. - Cleaned up unused variables and improved code consistency across components. --- src/App.jsx | 20 +- src/components/UserProfileAvatar.jsx | 2 +- src/contexts/Contexts.jsx | 2 - src/hooks/useAuth.jsx | 144 ++++++++++++ src/hooks/useSSE.js | 21 +- src/queries/UserQueries.jsx | 18 +- src/service/AuthenticationService.jsx | 18 -- src/utils/ApiClient.js | 180 +++++++++++++++ src/utils/FeatureToggle.js | 4 +- src/utils/Fetcher.jsx | 58 ++++- src/utils/Helpers.jsx | 4 +- src/utils/TokenManager.jsx | 247 --------------------- src/views/Authorization/Authenticating.jsx | 6 +- src/views/Authorization/LoginSettings.jsx | 4 +- src/views/Authorization/LoginView.jsx | 151 ++++++------- src/views/Chores/ActivitesCard.jsx | 2 +- src/views/Chores/MyChores.jsx | 17 +- src/views/Settings/ProfileSettings.jsx | 7 +- src/views/components/NavBar.jsx | 9 +- src/views/components/RichTextEditor.jsx | 7 +- 20 files changed, 493 insertions(+), 428 deletions(-) create mode 100644 src/hooks/useAuth.jsx delete mode 100644 src/service/AuthenticationService.jsx create mode 100644 src/utils/ApiClient.js delete mode 100644 src/utils/TokenManager.jsx diff --git a/src/App.jsx b/src/App.jsx index 9ea6d6e..32f888b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,10 +6,10 @@ import { Outlet, useNavigate } from 'react-router-dom' import { useRegisterSW } from 'virtual:pwa-register/react' import { registerCapacitorListeners } from './CapacitorListener' import PageTransition from './components/animations/PageTransition' +import { AuthProvider } from './hooks/useAuth.jsx' import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext' -import { AuthenticationProvider } from './service/AuthenticationService' +import SSEProvider from './contexts/SSEContext' import { useNotification } from './service/NotificationProvider' -import { apiManager } from './utils/TokenManager' import NetworkBanner from './views/components/NetworkBanner' @@ -32,12 +32,6 @@ const startOpenReplay = () => { tracker.start() } -const startApiManager = async navigate => { - await apiManager.init() - apiManager.setNavigateToLogin(() => { - navigate('/login') - }) -} const AppContent = () => { const { showNotification } = useNotification() @@ -100,8 +94,6 @@ const AppContent = () => { } function App() { - const navigate = useNavigate() - startApiManager(navigate) // startOpenReplay() const { mode, systemMode } = useColorScheme() @@ -135,9 +127,11 @@ function App() { <> - - - + + + + + ) } diff --git a/src/components/UserProfileAvatar.jsx b/src/components/UserProfileAvatar.jsx index 2b9dd53..0b31008 100644 --- a/src/components/UserProfileAvatar.jsx +++ b/src/components/UserProfileAvatar.jsx @@ -76,7 +76,7 @@ const UserProfileAvatar = () => { } const handleLogout = () => { - localStorage.removeItem('ca_token') + localStorage.removeItem('access_token') localStorage.removeItem('ca_expiration') window.location.href = '/login' } diff --git a/src/contexts/Contexts.jsx b/src/contexts/Contexts.jsx index 1b42ef1..5573dc4 100644 --- a/src/contexts/Contexts.jsx +++ b/src/contexts/Contexts.jsx @@ -2,7 +2,6 @@ import { AlertsProvider } from '../service/AlertsProvider' import { NotificationProvider } from '../service/NotificationProvider' import QueryContext from './QueryContext' import RouterContext from './RouterContext' -import SSEProvider from './SSEContext' import ThemeContext from './ThemeContext' const Contexts = ({ children }) => { @@ -11,7 +10,6 @@ const Contexts = ({ children }) => { ThemeContext, QueryContext, NotificationProvider, - SSEProvider, RouterContext, ] diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.jsx new file mode 100644 index 0000000..4dc41b8 --- /dev/null +++ b/src/hooks/useAuth.jsx @@ -0,0 +1,144 @@ +import { createContext, useContext, useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { API_URL } from '../Config' +import { apiClient } from '../utils/ApiClient' + +const AuthContext = createContext(null) + +export const useAuth = () => { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} + +export const AuthProvider = ({ children }) => { + const [token, setToken] = useState(() => localStorage.getItem('token')) + const [user, setUser] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const navigate = useNavigate() + + const baseURL = `${API_URL}/api/v1` + const isAuthenticated = !!token + + const isTokenExpired = () => { + const expiry = localStorage.getItem('token_expiry') + if (!expiry) return false + return new Date() >= new Date(expiry) + } + + const clearAuth = () => { + setToken(null) + setUser(null) + localStorage.removeItem('token') + localStorage.removeItem('token_expiry') + localStorage.removeItem('ca_token') + localStorage.removeItem('ca_expiration') + localStorage.removeItem('access_token') + } + + const login = async credentials => { + setIsLoading(true) + try { + const response = await fetch(`${baseURL}/auth/login`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + }) + + if (!response.ok) { + const error = await response.json() + return { success: false, error: error.message || 'Login failed' } + } + + const data = await response.json() + const userToken = data.token || data.access_token + + if (userToken) { + setToken(userToken) + localStorage.setItem('token', userToken) + + if (data.expire || data.access_token_expiry) { + localStorage.setItem( + 'token_expiry', + data.expire || data.access_token_expiry, + ) + } + } + + setIsLoading(false) + return { success: true, data } + } catch (error) { + setIsLoading(false) + return { success: false, error: 'Network error' } + } + } + + const logout = async () => { + setIsLoading(true) + try { + await fetch(`${baseURL}/auth/logout`, { + method: 'POST', + credentials: 'include', + }) + } catch (error) { + console.warn('Logout API call failed:', error) + } finally { + clearAuth() + setIsLoading(false) + navigate('/login') + } + } + + const fetchUser = async () => { + if (!token) return null + + try { + const response = await apiClient.get('/users/profile') + + if (!response.ok) { + return null + } + + const userData = await response.json() + setUser(userData) + return userData + } catch (error) { + console.error('Fetch user error:', error) + return null + } + } + + useEffect(() => { + const initAuth = async () => { + if (token && !isTokenExpired()) { + await fetchUser() + } else if (token && isTokenExpired()) { + // Token is expired, but don't refresh here + // Let the first API call handle refresh via ApiClient + // Just try to fetch user - if it fails, ApiClient will handle refresh + await fetchUser() + } else { + clearAuth() + navigate('/login') + } + setIsLoading(false) + } + + initAuth() + }, [token, navigate]) + + const value = { + token, + user, + isLoading, + isAuthenticated, + login, + logout, + fetchUser, + } + + return {children} +} diff --git a/src/hooks/useSSE.js b/src/hooks/useSSE.js index 07d6d63..ecd7c22 100644 --- a/src/hooks/useSSE.js +++ b/src/hooks/useSSE.js @@ -4,7 +4,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useUserProfile } from '../queries/UserQueries' import { useAlerts } from '../service/AlertsProvider' import { useNotification } from '../service/NotificationProvider' -import { apiManager, isTokenValid } from '../utils/TokenManager' +import { apiClient } from '../utils/apiClient' +import { useAuth } from './useAuth.jsx' const SSE_STATES = { CONNECTING: 0, OPEN: 1, @@ -17,6 +18,7 @@ const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes export const useSSE = () => { const { data: userProfile } = useUserProfile() + const { isAuthenticated, token } = useAuth() const [connectionState, setConnectionState] = useState(SSE_STATES.CLOSED) const [lastEvent, setLastEvent] = useState(null) const [error, setError] = useState(null) @@ -34,14 +36,14 @@ export const useSSE = () => { const { showAlert } = useAlerts() const getSSEUrl = useCallback(() => { - const token = localStorage.getItem('ca_token') - if (!token || !isTokenValid()) { + const authToken = token + if (!authToken || !isAuthenticated) { console.log('SSE: No valid authentication token') return null } // Get the API URL from apiManager - const apiUrl = apiManager.getApiURL() // e.g., "http://localhost:8080/api/v1" + const apiUrl = apiClient.baseURL // e.g., "http://localhost:8080/api/v1" // Build SSE URL - let backend determine circle from authenticated user const sseUrl = `${apiUrl}/realtime/sse` @@ -302,6 +304,7 @@ export const useSSE = () => { 'Cache-Control': 'no-cache', Accept: 'text/event-stream', }, + withCredentials: true, // Increase timeout to prevent premature disconnections // Default is 45000ms (45s), increasing to 2 minutes // TODO: send this in the resource object so it can be configured per instance @@ -447,10 +450,10 @@ export const useSSE = () => { enabled => { console.log('SSE toggleSSEEnabled called:', { enabled, - isTokenValid: isTokenValid(), + isTokenValid: isAuthenticated, }) localStorage.setItem('sse_enabled', enabled.toString()) - if (enabled && isTokenValid()) { + if (enabled && isAuthenticated) { console.log('SSE toggleSSEEnabled: Calling connect()') connect() } else { @@ -468,13 +471,13 @@ export const useSSE = () => { // Auto-connect when SSE is enabled and token is valid useEffect(() => { console.log('SSE auto-connect effect triggered') - console.log('Token valid:', isTokenValid()) + console.log('Token valid:', isAuthenticated) // Check if SSE is enabled in settings const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true' console.log('SSE enabled in settings:', isSSEEnabledSetting) - if (isTokenValid() && isSSEEnabledSetting) { + if (isAuthenticated && isSSEEnabledSetting) { console.log('SSE: Conditions met, attempting to connect') connect() } else { @@ -514,7 +517,7 @@ export const useSSE = () => { const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true' if ( - isTokenValid() && + isAuthenticated && isSSEEnabledSetting && connectionState !== SSE_STATES.OPEN ) { diff --git a/src/queries/UserQueries.jsx b/src/queries/UserQueries.jsx index 2795b49..d414ec3 100644 --- a/src/queries/UserQueries.jsx +++ b/src/queries/UserQueries.jsx @@ -6,7 +6,17 @@ import { GetDeviceTokens, GetUserProfile, } from '../utils/Fetcher' -import { isTokenValid } from '../utils/TokenManager' + +// Helper to check if we have a valid token +const isTokenValid = () => { + const token = localStorage.getItem('token') + if (!token) return false + + const expiry = localStorage.getItem('token_expiry') + if (!expiry) return true // No expiry set, assume valid + + return new Date() < new Date(expiry) +} export const useAllUsers = () => { return useQuery({ @@ -37,16 +47,16 @@ export const useUserProfile = () => { queryKey: ['userProfile'], queryFn: async () => { if (!isTokenValid()) { - return null // Token is invalid, return null to indicate no profile + throw new Error('Invalid or expired token, cannot fetch user profile') } const resp = await GetUserProfile() const result = await resp.json() // if we got 403 then user probably deleted their account and token is still valid. navigate to login if (resp.status === 403) { - localStorage.removeItem('ca_token') + localStorage.removeItem('access_token') localStorage.removeItem('ca_expiration') window.location.href = '/login' - return null + throw new Error('User account deleted or access forbidden') } return result.res // Return the actual user profile data diff --git a/src/service/AuthenticationService.jsx b/src/service/AuthenticationService.jsx deleted file mode 100644 index 7b1c700..0000000 --- a/src/service/AuthenticationService.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { createContext, useState } from 'react' - -const AuthenticationContext = createContext({}) - -const AuthenticationProvider = ({ children }) => { - const [isLoggedIn, setIsLoggedIn] = useState(false) - const [userProfile, setUserProfile] = useState({}) - return ( - - {children} - - ) -} -export { AuthenticationContext, AuthenticationProvider } - -// export default AuthenticationProvider; diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js new file mode 100644 index 0000000..51d6bbc --- /dev/null +++ b/src/utils/ApiClient.js @@ -0,0 +1,180 @@ +import { API_URL } from '../Config' +import { RefreshToken } from './Fetcher' + +class ApiClient { + constructor() { + this.baseURL = `${API_URL}/api/v1` + this.isRefreshing = false + this.failedQueue = [] + } + + getToken() { + return localStorage.getItem('token') + } + + getHeaders(customHeaders = {}) { + const headers = { + 'Content-Type': 'application/json', + ...customHeaders, + } + + const token = this.getToken() + if (token) { + headers.Authorization = `Bearer ${token}` + } + + const impersonateUserId = localStorage.getItem('impersonatedUserId') + if (impersonateUserId) { + headers['X-Impersonate-User-ID'] = impersonateUserId + } + + return headers + } + + // Process queued requests after refresh attempt + processQueue(error, token = null) { + this.failedQueue.forEach(({ resolve, reject }) => { + if (error) { + reject(error) + } else { + resolve(token) + } + }) + + this.failedQueue = [] + } + + // Helper to avoid repeating cleanup code + handleLogout() { + localStorage.removeItem('token') + localStorage.removeItem('token_expiry') + window.location.href = '/login' + } + async request(endpoint, options = {}) { + const url = `${this.baseURL}${endpoint}` + const config = { + // credentials: 'include', + ...options, + headers: this.getHeaders(options.headers), + } + + try { + // 1. Initial Request + let response = await fetch(url, config) + + // 2. Check for 401 (Unauthorized) + if (response.status === 401) { + if (!this.isRefreshing) { + this.isRefreshing = true + + try { + // Attempt to refresh token + const refreshReq = await RefreshToken() + + if (refreshReq.ok) { + const data = await refreshReq.json() + const newToken = data.token || data.access_token + + // Update Local Storage + localStorage.setItem('token', newToken) + if (data.expire || data.access_token_expiry) { + localStorage.setItem( + 'token_expiry', + data.expire || data.access_token_expiry, + ) + } + + // Process queue with success + this.processQueue(null, newToken) + + // Retry the original request with new token + const newHeaders = this.getHeaders(options?.headers) + const retryConfig = { + ...config, + headers: newHeaders, + } + + response = await fetch(url, retryConfig) + + // If it fails again with 401, force logout + if (response.status === 401) { + this.handleLogout() + return null + } + } else { + // Refresh failed (e.g., refresh token expired) + this.processQueue(new Error('Token refresh failed'), null) + this.handleLogout() + return null + } + } finally { + this.isRefreshing = false + } + } else { + // Token is currently being refreshed, queue this request + return new Promise((resolve, reject) => { + this.failedQueue.push({ + resolve: (token) => { + // Retry the original request with new token + const newHeaders = this.getHeaders(options?.headers) + const retryConfig = { + ...config, + headers: newHeaders, + } + resolve(fetch(url, retryConfig)) + }, + reject + }) + }) + } + } + + return response + } catch (error) { + console.error('Request failed', error) + throw error + } + } + + async get(endpoint, options = {}) { + return this.request(endpoint, { ...options, method: 'GET' }) + } + + async post(endpoint, data, options = {}) { + return this.request(endpoint, { + ...options, + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }) + } + + async put(endpoint, data, options = {}) { + return this.request(endpoint, { + ...options, + method: 'PUT', + body: data ? JSON.stringify(data) : undefined, + }) + } + + async delete(endpoint, options = {}) { + return this.request(endpoint, { ...options, method: 'DELETE' }) + } + + async upload(endpoint, formData, options = {}) { + const headers = options.headers || {} + delete headers['Content-Type'] + + return this.request(endpoint, { + ...options, + method: 'POST', + body: formData, + headers, + }) + } + + getAssetURL(path) { + return `${this.baseURL}/assets/${path}` + } +} + +export const apiClient = new ApiClient() diff --git a/src/utils/FeatureToggle.js b/src/utils/FeatureToggle.js index bb2f331..24482ea 100644 --- a/src/utils/FeatureToggle.js +++ b/src/utils/FeatureToggle.js @@ -100,8 +100,8 @@ export const isOfficialDonetickInstance = async () => { export const isOfficialDonetickInstanceSync = () => { try { // Dynamic import to avoid circular dependencies - return import('../utils/TokenManager').then(({ apiManager }) => { - const currentApiUrl = apiManager.getApiURL() + return import('./apiClient').then(({ apiClient }) => { + const currentApiUrl = apiClient.baseURL // Check if the API URL contains donetick.com return currentApiUrl.toLowerCase().includes('donetick.com') }).catch(error => { diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index 8d6ba5c..ca1ffe9 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -1,4 +1,18 @@ -import { Fetch, HEADERS, apiManager } from './TokenManager' +import { apiClient } from './apiClient' + +// Migration helpers to maintain compatibility with existing code +const Fetch = async (endpoint, options = {}) => { + const response = await apiClient.request(endpoint, options) + return response +} + +const HEADERS = () => { + return apiClient.getHeaders() +} + +const apiManager = { + getApiURL: () => apiClient.baseURL, +} const createChore = userID => { return Fetch(`/chores/`, { @@ -547,12 +561,36 @@ const RedeemPoints = (userId, points, circleID) => { body: JSON.stringify({ points, userId }), }) } -const RefreshToken = () => { +const RefreshToken = async () => { const basedURL = apiManager.getApiURL() - return fetch(`${basedURL}/auth/refresh`, { - method: 'GET', - headers: HEADERS(), - }) + + // Check if running on native platform + const isNative = typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.() + + if (isNative) { + // For native platforms, send refresh token in request body + const { Preferences } = await import('@capacitor/preferences') + const { value: refreshToken } = await Preferences.get({ key: 'refresh_token' }) + + if (!refreshToken) { + throw new Error('No refresh token available') + } + + return fetch(`${basedURL}/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refresh_token: refreshToken }), + }) + } else { + // For web, continue using cookies + return fetch(`${basedURL}/auth/refresh`, { + method: 'POST', + credentials: 'include', + headers: HEADERS(), + }) + } } const GetChoresHistory = async (limit, includeMembers) => { var url = `/chores/history` @@ -757,6 +795,7 @@ export { CreateBackup, CreateChildUser, CreateChore, + createChore, CreateLabel, CreateLongLiveToken, CreateThing, @@ -777,10 +816,10 @@ export { GetChoreByID, GetChoreDetailById, GetChoreHistory, - GetChoreTimer, GetChores, GetChoresHistory, GetChoresNew, + GetChoreTimer, GetCircleMemberRequests, GetDeviceTokens, GetLabels, @@ -795,6 +834,7 @@ export { GetUserProfile, JoinCircle, LeaveCircle, + login, MarkChoreComplete, NudgeChore, PauseChore, @@ -811,6 +851,7 @@ export { SaveChore, SaveThing, SetupMFA, + signUp, SkipChore, StartChore, UnArchiveChore, @@ -828,7 +869,4 @@ export { UpdateTimeSession, UpdateUserDetails, VerifyMFA, - createChore, - login, - signUp, } diff --git a/src/utils/Helpers.jsx b/src/utils/Helpers.jsx index aeafe6c..ef14df5 100644 --- a/src/utils/Helpers.jsx +++ b/src/utils/Helpers.jsx @@ -1,5 +1,5 @@ import moment from 'moment' -import { getAssetURL } from './TokenManager' +import { apiClient } from './apiClient' const isPlusAccount = userProfile => { return userProfile?.expiration && moment(userProfile?.expiration).isAfter() @@ -11,7 +11,7 @@ const resolvePhotoURL = url => { return url } if (url.startsWith('assets')) { - return getAssetURL(url) + return apiClient.getAssetURL(url) } return url } diff --git a/src/utils/TokenManager.jsx b/src/utils/TokenManager.jsx deleted file mode 100644 index 6331198..0000000 --- a/src/utils/TokenManager.jsx +++ /dev/null @@ -1,247 +0,0 @@ -import { Preferences } from '@capacitor/preferences' -import Cookies from 'js-cookie' -import murmurhash from 'murmurhash' -import { API_URL } from '../Config' -import { FEATURES, isFeatureEnabled } from '../utils/FeatureToggle' -import { networkManager } from '../hooks/NetworkManager' -import { RefreshToken } from './Fetcher' -import { localStore } from './LocalStore' - -class ApiManager { - constructor() { - this.customServerURL = `${API_URL}/api/v1` - this.initialized = false - this.initPromise = null - this.navigateToLogin = () => {} - } - - async init() { - if (this.initPromise) { - return this.initPromise - } - - if (this.initialized) { - return Promise.resolve() - } - - this.initPromise = this._doInit() - return this.initPromise - } - - async _doInit() { - const { value: serverURL } = await Preferences.get({ - key: 'customServerUrl', - }) - - this.customServerURL = `${serverURL || API_URL}/api/v1` - this.initialized = true - await localStore.initDatabase() - } - - getApiURL() { - return this.customServerURL - } - - updateApiURL(url) { - this.customServerURL = url - this.init() - } - setNavigateToLogin(callback) { - this.navigateToLogin = callback - } -} - -export const apiManager = new ApiManager() - -export const getAssetURL = path => { - const baseURL = apiManager.getApiURL() - return `${baseURL}/assets/${path}` -} -export async function UploadFile(url, options) { - await apiManager.init() - - if (!isTokenValid()) { - Cookies.set('ca_redirect', window.location.pathname) - window.location.href = '/login' - } - - if (!options) { - options = {} - } - const headers = HEADERS() - options.headers = { Authorization: headers['Authorization'] } - - const baseURL = apiManager.getApiURL() - const fullURL = `${baseURL}${url}` - - return fetch(fullURL, options) -} - -export async function Fetch(url, options) { - await apiManager.init() - if (!isTokenValid()) { - Cookies.set('ca_redirect', window.location.pathname) - if (window.location.pathname !== '/login') { - window.location.href = '/login' - } - } - - if (!options) { - options = {} - } - // clone options to avoid mutation - options.headers = { ...options.headers, ...HEADERS() } - - const baseURL = apiManager.getApiURL() - const fullURL = `${baseURL}${url}` - - // const networkStatus = await Network.getStatus() - - // if (!networkStatus.connected) { - // return handleOfflineRequest(fullURL, options) - // } - - // Online: Perform the fetch - try { - const response = await fetch(fullURL, options) - - if (response.ok) { - // Only cache data if offline mode is enabled - if (isFeatureEnabled(FEATURES.OFFLINE_MODE)) { - const data = await response.clone().json() - const optionWithoutToken = { ...options } - delete optionWithoutToken.headers.Authorization - const optionsHash = murmurhash.v3(JSON.stringify(optionWithoutToken)) - await localStore.saveToCache(fullURL + optionsHash, data) - } - networkManager.setOnline() - } else if (response.status === 401) { - // Handle 401 Unauthorized - const errorData = await response.json() - console.error('Unauthorized:', errorData) - localStorage.removeItem('ca_token') - localStorage.removeItem('ca_expiration') - apiManager.navigateToLogin() - } else if ( - response.status === 503 || - response.type === 'opaque' || - response.status === 0 - ) { - networkManager.setOffline() - // Only handle offline requests if offline mode is enabled - if (isFeatureEnabled(FEATURES.OFFLINE_MODE)) { - return handleOfflineRequest(fullURL, options) - } - // If offline mode is disabled, just throw the error - throw new Error(`Request failed with status ${response.status}`) - } - // return promise that resolves to response object: - return Promise.resolve(response) - } catch (error) { - networkManager.setOffline() - console.error('Fetch error:', error) - // Only handle offline requests if offline mode is enabled - if (isFeatureEnabled(FEATURES.OFFLINE_MODE)) { - return handleOfflineRequest(fullURL, options) - } - // If offline mode is disabled, just throw the error - throw error - } -} - -export const HEADERS = () => { - // Import here to avoid circular dependency issues - const impersonateUserId = localStorage.getItem('impersonatedUserId') - - return { - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + localStorage.getItem('ca_token'), - ...(impersonateUserId && { 'X-Impersonate-User-ID': impersonateUserId }), - } -} - -export const isTokenValid = () => { - const expiration = localStorage.getItem('ca_expiration') - const token = localStorage.getItem('ca_token') - - if (token) { - const now = new Date() - const expire = new Date(expiration) - if (now < expire) { - if (now.getTime() + 24 * 60 * 60 * 1000 > expire.getTime()) { - refreshAccessToken() - } - return true - } else { - localStorage.removeItem('ca_token') - localStorage.removeItem('ca_expiration') - } - return false - } - return false -} - -export const refreshAccessToken = () => { - RefreshToken().then(res => { - if (res.status === 200) { - res.json().then(data => { - localStorage.setItem('ca_token', data.token) - localStorage.setItem('ca_expiration', data.expire) - }) - } else { - return res.json().then(error => { - console.log(error) - }) - } - }) -} - -async function handleOfflineRequest(url, options) { - // Only handle offline requests if offline mode is enabled - if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) { - throw new Error('Network request failed and offline mode is disabled') - } - - // if get request then attempt to fetch from cache otherewise queue it : - if (options.method === 'GET') { - return attemptFetchFromCache(url, options) - } else { - // Queue the request for later processing - const requestId = murmurhash.v3(JSON.stringify({ url, options })) - await localStore.queueRequest(requestId, { url, options }) - console.log('Request queued for later processing:', requestId) - return Promise.reject({ - error: 'Offline and request queued', - requestId, - queued: true, - }) - } -} -async function attemptFetchFromCache(url, options) { - // Only attempt cache fetch if offline mode is enabled - if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) { - throw new Error('Cache access disabled - offline mode is not enabled') - } - - const optionsHash = murmurhash.v3(JSON.stringify(options)) - const cachedData = await localStore.getFromCache(url + optionsHash) - networkManager.setOffline() - - if (cachedData) { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => cachedData, - }) - } else { - // TODO: change this to throw error instead of returning promise - return Promise.reject( - new Error( - 'No cached data found for URL: ' + - url + - ' with options hash: ' + - optionsHash, - ), - ) - } -} diff --git a/src/views/Authorization/Authenticating.jsx b/src/views/Authorization/Authenticating.jsx index 3ecf99e..5e58bc5 100644 --- a/src/views/Authorization/Authenticating.jsx +++ b/src/views/Authorization/Authenticating.jsx @@ -1,7 +1,7 @@ import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy' import { useEffect, useState } from 'react' import Logo from '../../Logo' -import { apiManager } from '../../utils/TokenManager' +import { apiClient } from '../../utils/apiClient' import Cookies from 'js-cookie' import { useRef } from 'react' @@ -58,7 +58,7 @@ const AuthenticationLoading = () => { } if (code) { - const baseURL = apiManager.getApiURL() + const baseURL = apiClient.baseURL fetch(`${baseURL}/auth/${provider}/callback`, { method: 'POST', headers: { @@ -71,7 +71,7 @@ const AuthenticationLoading = () => { }).then(response => { if (response.status === 200) { return response.json().then(data => { - localStorage.setItem('ca_token', data.token) + localStorage.setItem('token', data.token) localStorage.setItem('ca_expiration', data.expire) const redirectUrl = Cookies.get('ca_redirect') diff --git a/src/views/Authorization/LoginSettings.jsx b/src/views/Authorization/LoginSettings.jsx index 8daee05..66f4266 100644 --- a/src/views/Authorization/LoginSettings.jsx +++ b/src/views/Authorization/LoginSettings.jsx @@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom' import { API_URL } from '../../Config' import Logo from '../../Logo' import { useNotification } from '../../service/NotificationProvider' -import { apiManager } from '../../utils/TokenManager' +import { apiClient } from '../../utils/apiClient' const LoginSettings = () => { const Navigate = useNavigate() const [serverURL, setServerURL] = React.useState('') @@ -115,7 +115,7 @@ const LoginSettings = () => { key: 'customServerUrl', value: serverURL, }).then(() => { - apiManager.updateApiURL(serverURL + '/api/v1') + apiClient.baseURL = serverURL + '/api/v1' Navigate('/login') }) }} diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx index 46d8705..ec1221a 100644 --- a/src/views/Authorization/LoginView.jsx +++ b/src/views/Authorization/LoginView.jsx @@ -30,8 +30,8 @@ import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config' import Logo from '../../Logo' import { useResource } from '../../queries/ResourceQueries' import { useNotification } from '../../service/NotificationProvider' -import { GetUserProfile, login } from '../../utils/Fetcher' -import { apiManager, isTokenValid } from '../../utils/TokenManager' +import { useAuth } from '../../hooks/useAuth.jsx' +import { apiClient } from '../../utils/apiClient' import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers' import MFAVerificationModal from './MFAVerificationModal' @@ -60,6 +60,7 @@ const LoginView = () => { } const { data: resource } = useResource() const { showError } = useNotification() + const { isAuthenticated, login: authLogin, user } = useAuth() const Navigate = useNavigate() useEffect(() => { const initializeSocialLogin = async () => { @@ -89,18 +90,11 @@ const LoginView = () => { initializeSocialLogin() }, []) useEffect(() => { - if (isTokenValid()) { - GetUserProfile().then(response => { - if (response.status === 200) { - return response.json().then(data => { - setUserProfile(data.res) - }) - } else { - console.log('Failed to fetch user profile') - } - }) + if (isAuthenticated && user) { + setUserProfile(user) + Navigate('/chores') } - }, []) + }, [isAuthenticated, user, Navigate]) const handleSubmit = async e => { e.preventDefault() @@ -144,98 +138,72 @@ const LoginView = () => { ? buildChildUsername(parentUsername, childName) : username - login(actualUsername, password) - .then(response => { - if (response.status === 200) { - return response.json().then(data => { - // Check if MFA is required - if (data.mfaRequired) { - setMfaSessionToken(data.sessionToken) - setMfaModalOpen(true) - return - } + const result = await authLogin({ username: actualUsername, password }) - // Normal login without MFA - localStorage.setItem('ca_token', data.token) - localStorage.setItem('ca_expiration', data.expire) + if (result.success) { + if (result.data?.mfaRequired) { + setMfaSessionToken(result.data.sessionToken) + setMfaModalOpen(true) + return + } - // Refetch user profile after successful login - queryClient.refetchQueries(['userProfile']) + // Refetch user profile after successful login + queryClient.refetchQueries(['userProfile']) - const redirectUrl = Cookies.get('ca_redirect') - - if (redirectUrl && redirectUrl !== '/') { - console.log('Redirecting to', redirectUrl) - - Cookies.remove('ca_redirect') - Navigate(redirectUrl) - } else { - Cookies.remove('ca_redirect') - Navigate('/chores') - } - }) - } else if (response.status === 401) { - showError({ - title: 'Login Failed', - message: 'Wrong username or password', - }) - } else { - showError({ - title: 'Login Failed', - message: 'An error occurred, please try again', - }) - console.log('Login failed') - } - }) - .catch(err => { - showError({ - title: 'Connection Error', - message: 'Unable to communicate with server, please try again', - }) - console.log('Login failed', err) + const redirectUrl = Cookies.get('ca_redirect') + if (redirectUrl && redirectUrl !== '/') { + Cookies.remove('ca_redirect') + Navigate(redirectUrl) + } else { + Navigate('/chores') + } + } else { + showError({ + title: 'Login Failed', + message: result.error || 'An error occurred, please try again', }) + } } - const loggedWithProvider = function (provider, data) { - const baseURL = apiManager.getApiURL() - + const loggedWithProvider = async function (provider, data) { const getAccessToken = data => { if (data['access_token']) { - // data["access_token"] is for Google return data['access_token'] } else if (data['accessToken']) { - // data["accessToken"] is for Google Capacitor return data['accessToken']['token'] } else if (data['response'] && data['response']['id_token']) { - // Apple Sign In returns id_token in response return data['response']['id_token'] } else if (data['id_token']) { - // Direct id_token for Apple (fallback) return data['id_token'] } } - return fetch(`${baseURL}/auth/${provider}/callback`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + try { + const response = await apiClient.post(`/auth/${provider}/callback`, { provider: provider, token: getAccessToken(data), data: data, - }), - }).then(response => { - if (response.status === 200) { - return response.json().then(data => { - // Check if MFA is required for OAuth login - if (data.mfaRequired) { - setMfaSessionToken(data.sessionToken) - setMfaModalOpen(true) - return - } + }) - // Normal OAuth login without MFA - localStorage.setItem('ca_token', data.token) - localStorage.setItem('ca_expiration', data.expire) + if (response.ok) { + const responseData = await response.json() + + // Check if MFA is required for OAuth login + if (responseData.mfaRequired) { + setMfaSessionToken(responseData.sessionToken) + setMfaModalOpen(true) + return + } + + // Use new auth system to handle token storage + if (responseData.token || responseData.access_token) { + const token = responseData.token || responseData.access_token + const expiry = responseData.expire || responseData.access_token_expiry + + localStorage.setItem('token', token) + if (expiry) { + localStorage.setItem('token_expiry', expiry) + } // Refetch user profile after successful OAuth login queryClient.invalidateQueries(['userProfile']) @@ -247,16 +215,21 @@ const LoginView = () => { } else { getUserProfileAndNavigateToHome() } - }) - } - return response.json().then(() => { + } + } else { const providerName = provider === 'apple' ? 'Apple' : 'Google' showError({ title: `${providerName} Login Failed`, message: `Couldn't log in with ${providerName}, please try again`, }) + } + } catch (error) { + const providerName = provider === 'apple' ? 'Apple' : 'Google' + showError({ + title: `${providerName} Login Error`, + message: 'Network error occurred, please try again', }) - }) + } } const getUserProfileAndNavigateToHome = () => { // Refetch user profile after login using React Query @@ -273,8 +246,8 @@ const LoginView = () => { } const handleMFASuccess = data => { - localStorage.setItem('ca_token', data.token) - localStorage.setItem('ca_expiration', data.expire) + localStorage.setItem('token', data.token) + localStorage.setItem('token_expiry', data.expire) setMfaModalOpen(false) setMfaSessionToken('') diff --git a/src/views/Chores/ActivitesCard.jsx b/src/views/Chores/ActivitesCard.jsx index 92d6f19..55c7fcd 100644 --- a/src/views/Chores/ActivitesCard.jsx +++ b/src/views/Chores/ActivitesCard.jsx @@ -242,7 +242,7 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => { // Refresh function to refetch all data const handleRefresh = async () => { - await Promise.all([refetchChores(), refetchHistory(), refetchMembers()]) + await Promise.all([refetchChores(), refetchHistory, refetchMembers]) } // Show loading state diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index 7c3900e..f341c65 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -125,7 +125,7 @@ const MyChores = () => { localStorage.getItem('selectedChoreFilter') || 'anyone', ) const [searchTerm, setSearchTerm] = useState('') - const [performers, setPerformers] = useState([]) + const [anchorEl, setAnchorEl] = useState(null) const [viewMode, setViewMode] = useState( localStorage.getItem('choreCardViewMode') || 'default', @@ -202,7 +202,6 @@ const MyChores = () => { choresData?.res ) { const processEffectAsync = async () => { - setPerformers(membersData.res) setChores(processedChores) setFilteredChores(processedChores) @@ -1022,7 +1021,7 @@ const MyChores = () => { { if ( isUserProfileLoading || userLabelsLoading || - performers.length === 0 || + membersLoading || choresLoading ) { return ( @@ -1745,7 +1744,7 @@ const MyChores = () => { const filterFunction = FILTERS[filter] const filteredChores = filterFunction.length === 2 - ? filterFunction(chores, userProfile.id) + ? filterFunction(chores, userProfile?.id) : filterFunction(chores) setFilteredChores(filteredChores) setSearchFilter(filter) @@ -1757,7 +1756,7 @@ const MyChores = () => { color={searchFilter === filter ? 'primary' : 'neutral'} > {FILTERS[filter].length === 2 - ? FILTERS[filter](chores, userProfile.id).length + ? FILTERS[filter](chores, userProfile?.id).length : FILTERS[filter](chores).length} @@ -2323,7 +2322,7 @@ const MyChores = () => { { )} - + {/* Multi-select Help - only show when in multi-select mode */} {/* */} @@ -2537,7 +2536,7 @@ const MyChores = () => { {activeModal === 'changeAssignee' && modalChore && ( { @@ -85,10 +85,7 @@ const ProfileSettings = () => { const formData = new FormData() formData.append('file', compressedFile, 'profile.jpg') - const response = await UploadFile('/users/profile_photo', { - method: 'POST', - body: formData, - }) + const response = await apiClient.upload('/users/profile_photo', formData) if (!response.ok) throw new Error('Upload failed') const data = await response.json() const url = resolvePhotoURL(data.url || data.sign) diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx index 92690bf..076c077 100644 --- a/src/views/components/NavBar.jsx +++ b/src/views/components/NavBar.jsx @@ -87,12 +87,14 @@ const links = [ ] import { SafeArea } from 'capacitor-plugin-safe-area' +import { useAuth } from '../../hooks/useAuth.jsx' import Z_INDEX from '../../constants/zIndex' import { useResource } from '../../queries/ResourceQueries' const publicPages = ['/landing', '/privacy', '/terms'] const NavBar = () => { const { data: resource } = useResource() + const { logout } = useAuth() const navigate = useNavigate() const [drawerOpen, setDrawerOpen] = useState(false) @@ -264,12 +266,7 @@ const NavBar = () => { Upgrade to Plus */} { - localStorage.removeItem('ca_token') - localStorage.removeItem('ca_expiration') - // go to login page: - window.location.href = '/login' - }} + onClick={logout} sx={{ py: 1.2, }} diff --git a/src/views/components/RichTextEditor.jsx b/src/views/components/RichTextEditor.jsx index 680ab6a..8f70a04 100644 --- a/src/views/components/RichTextEditor.jsx +++ b/src/views/components/RichTextEditor.jsx @@ -12,7 +12,7 @@ import { import { useUserProfile } from '../../queries/UserQueries' import { useNotification } from '../../service/NotificationProvider' import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers' -import { UploadFile } from '../../utils/TokenManager' +import { apiClient } from '../../utils/apiClient' import './RichTextEditor.css' const RichTextEditor = forwardRef( @@ -106,10 +106,7 @@ const RichTextEditor = forwardRef( formData.append('entityId', entityId) formData.append('entityType', entityType) - const response = await UploadFile('/assets/chore', { - method: 'POST', - body: formData, - }) + const response = await apiClient.upload('/assets/chore', formData) if (response.status === 507) { showError({