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({