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.
This commit is contained in:
Mo Tarbin
2025-12-25 22:54:39 -05:00
parent 1123fb3ca6
commit 8edd3774d2
20 changed files with 493 additions and 428 deletions

View File

@@ -6,10 +6,10 @@ import { Outlet, useNavigate } from 'react-router-dom'
import { useRegisterSW } from 'virtual:pwa-register/react' import { useRegisterSW } from 'virtual:pwa-register/react'
import { registerCapacitorListeners } from './CapacitorListener' import { registerCapacitorListeners } from './CapacitorListener'
import PageTransition from './components/animations/PageTransition' import PageTransition from './components/animations/PageTransition'
import { AuthProvider } from './hooks/useAuth.jsx'
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext' import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
import { AuthenticationProvider } from './service/AuthenticationService' import SSEProvider from './contexts/SSEContext'
import { useNotification } from './service/NotificationProvider' import { useNotification } from './service/NotificationProvider'
import { apiManager } from './utils/TokenManager'
import NetworkBanner from './views/components/NetworkBanner' import NetworkBanner from './views/components/NetworkBanner'
@@ -32,12 +32,6 @@ const startOpenReplay = () => {
tracker.start() tracker.start()
} }
const startApiManager = async navigate => {
await apiManager.init()
apiManager.setNavigateToLogin(() => {
navigate('/login')
})
}
const AppContent = () => { const AppContent = () => {
const { showNotification } = useNotification() const { showNotification } = useNotification()
@@ -100,8 +94,6 @@ const AppContent = () => {
} }
function App() { function App() {
const navigate = useNavigate()
startApiManager(navigate)
// startOpenReplay() // startOpenReplay()
const { mode, systemMode } = useColorScheme() const { mode, systemMode } = useColorScheme()
@@ -135,9 +127,11 @@ function App() {
<> <>
<NetworkBanner /> <NetworkBanner />
<AuthenticationProvider> <AuthProvider>
<AppContent /> <SSEProvider>
</AuthenticationProvider> <AppContent />
</SSEProvider>
</AuthProvider>
</> </>
) )
} }

View File

@@ -76,7 +76,7 @@ const UserProfileAvatar = () => {
} }
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('ca_token') localStorage.removeItem('access_token')
localStorage.removeItem('ca_expiration') localStorage.removeItem('ca_expiration')
window.location.href = '/login' window.location.href = '/login'
} }

View File

@@ -2,7 +2,6 @@ import { AlertsProvider } from '../service/AlertsProvider'
import { NotificationProvider } from '../service/NotificationProvider' import { NotificationProvider } from '../service/NotificationProvider'
import QueryContext from './QueryContext' import QueryContext from './QueryContext'
import RouterContext from './RouterContext' import RouterContext from './RouterContext'
import SSEProvider from './SSEContext'
import ThemeContext from './ThemeContext' import ThemeContext from './ThemeContext'
const Contexts = ({ children }) => { const Contexts = ({ children }) => {
@@ -11,7 +10,6 @@ const Contexts = ({ children }) => {
ThemeContext, ThemeContext,
QueryContext, QueryContext,
NotificationProvider, NotificationProvider,
SSEProvider,
RouterContext, RouterContext,
] ]

144
src/hooks/useAuth.jsx Normal file
View File

@@ -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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

View File

@@ -4,7 +4,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useUserProfile } from '../queries/UserQueries' import { useUserProfile } from '../queries/UserQueries'
import { useAlerts } from '../service/AlertsProvider' import { useAlerts } from '../service/AlertsProvider'
import { useNotification } from '../service/NotificationProvider' import { useNotification } from '../service/NotificationProvider'
import { apiManager, isTokenValid } from '../utils/TokenManager' import { apiClient } from '../utils/apiClient'
import { useAuth } from './useAuth.jsx'
const SSE_STATES = { const SSE_STATES = {
CONNECTING: 0, CONNECTING: 0,
OPEN: 1, OPEN: 1,
@@ -17,6 +18,7 @@ const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes
export const useSSE = () => { export const useSSE = () => {
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const { isAuthenticated, token } = useAuth()
const [connectionState, setConnectionState] = useState(SSE_STATES.CLOSED) const [connectionState, setConnectionState] = useState(SSE_STATES.CLOSED)
const [lastEvent, setLastEvent] = useState(null) const [lastEvent, setLastEvent] = useState(null)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@@ -34,14 +36,14 @@ export const useSSE = () => {
const { showAlert } = useAlerts() const { showAlert } = useAlerts()
const getSSEUrl = useCallback(() => { const getSSEUrl = useCallback(() => {
const token = localStorage.getItem('ca_token') const authToken = token
if (!token || !isTokenValid()) { if (!authToken || !isAuthenticated) {
console.log('SSE: No valid authentication token') console.log('SSE: No valid authentication token')
return null return null
} }
// Get the API URL from apiManager // 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 // Build SSE URL - let backend determine circle from authenticated user
const sseUrl = `${apiUrl}/realtime/sse` const sseUrl = `${apiUrl}/realtime/sse`
@@ -302,6 +304,7 @@ export const useSSE = () => {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
Accept: 'text/event-stream', Accept: 'text/event-stream',
}, },
withCredentials: true,
// Increase timeout to prevent premature disconnections // Increase timeout to prevent premature disconnections
// Default is 45000ms (45s), increasing to 2 minutes // Default is 45000ms (45s), increasing to 2 minutes
// TODO: send this in the resource object so it can be configured per instance // TODO: send this in the resource object so it can be configured per instance
@@ -447,10 +450,10 @@ export const useSSE = () => {
enabled => { enabled => {
console.log('SSE toggleSSEEnabled called:', { console.log('SSE toggleSSEEnabled called:', {
enabled, enabled,
isTokenValid: isTokenValid(), isTokenValid: isAuthenticated,
}) })
localStorage.setItem('sse_enabled', enabled.toString()) localStorage.setItem('sse_enabled', enabled.toString())
if (enabled && isTokenValid()) { if (enabled && isAuthenticated) {
console.log('SSE toggleSSEEnabled: Calling connect()') console.log('SSE toggleSSEEnabled: Calling connect()')
connect() connect()
} else { } else {
@@ -468,13 +471,13 @@ export const useSSE = () => {
// Auto-connect when SSE is enabled and token is valid // Auto-connect when SSE is enabled and token is valid
useEffect(() => { useEffect(() => {
console.log('SSE auto-connect effect triggered') console.log('SSE auto-connect effect triggered')
console.log('Token valid:', isTokenValid()) console.log('Token valid:', isAuthenticated)
// Check if SSE is enabled in settings // Check if SSE is enabled in settings
const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true' const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true'
console.log('SSE enabled in settings:', isSSEEnabledSetting) console.log('SSE enabled in settings:', isSSEEnabledSetting)
if (isTokenValid() && isSSEEnabledSetting) { if (isAuthenticated && isSSEEnabledSetting) {
console.log('SSE: Conditions met, attempting to connect') console.log('SSE: Conditions met, attempting to connect')
connect() connect()
} else { } else {
@@ -514,7 +517,7 @@ export const useSSE = () => {
const isSSEEnabledSetting = const isSSEEnabledSetting =
localStorage.getItem('sse_enabled') === 'true' localStorage.getItem('sse_enabled') === 'true'
if ( if (
isTokenValid() && isAuthenticated &&
isSSEEnabledSetting && isSSEEnabledSetting &&
connectionState !== SSE_STATES.OPEN connectionState !== SSE_STATES.OPEN
) { ) {

View File

@@ -6,7 +6,17 @@ import {
GetDeviceTokens, GetDeviceTokens,
GetUserProfile, GetUserProfile,
} from '../utils/Fetcher' } 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 = () => { export const useAllUsers = () => {
return useQuery({ return useQuery({
@@ -37,16 +47,16 @@ export const useUserProfile = () => {
queryKey: ['userProfile'], queryKey: ['userProfile'],
queryFn: async () => { queryFn: async () => {
if (!isTokenValid()) { 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 resp = await GetUserProfile()
const result = await resp.json() const result = await resp.json()
// if we got 403 then user probably deleted their account and token is still valid. navigate to login // if we got 403 then user probably deleted their account and token is still valid. navigate to login
if (resp.status === 403) { if (resp.status === 403) {
localStorage.removeItem('ca_token') localStorage.removeItem('access_token')
localStorage.removeItem('ca_expiration') localStorage.removeItem('ca_expiration')
window.location.href = '/login' window.location.href = '/login'
return null throw new Error('User account deleted or access forbidden')
} }
return result.res // Return the actual user profile data return result.res // Return the actual user profile data

View File

@@ -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 (
<AuthenticationContext.Provider
value={{ isLoggedIn, setIsLoggedIn, userProfile, setUserProfile }}
>
{children}
</AuthenticationContext.Provider>
)
}
export { AuthenticationContext, AuthenticationProvider }
// export default AuthenticationProvider;

180
src/utils/ApiClient.js Normal file
View File

@@ -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()

View File

@@ -100,8 +100,8 @@ export const isOfficialDonetickInstance = async () => {
export const isOfficialDonetickInstanceSync = () => { export const isOfficialDonetickInstanceSync = () => {
try { try {
// Dynamic import to avoid circular dependencies // Dynamic import to avoid circular dependencies
return import('../utils/TokenManager').then(({ apiManager }) => { return import('./apiClient').then(({ apiClient }) => {
const currentApiUrl = apiManager.getApiURL() const currentApiUrl = apiClient.baseURL
// Check if the API URL contains donetick.com // Check if the API URL contains donetick.com
return currentApiUrl.toLowerCase().includes('donetick.com') return currentApiUrl.toLowerCase().includes('donetick.com')
}).catch(error => { }).catch(error => {

View File

@@ -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 => { const createChore = userID => {
return Fetch(`/chores/`, { return Fetch(`/chores/`, {
@@ -547,12 +561,36 @@ const RedeemPoints = (userId, points, circleID) => {
body: JSON.stringify({ points, userId }), body: JSON.stringify({ points, userId }),
}) })
} }
const RefreshToken = () => { const RefreshToken = async () => {
const basedURL = apiManager.getApiURL() const basedURL = apiManager.getApiURL()
return fetch(`${basedURL}/auth/refresh`, {
method: 'GET', // Check if running on native platform
headers: HEADERS(), 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) => { const GetChoresHistory = async (limit, includeMembers) => {
var url = `/chores/history` var url = `/chores/history`
@@ -757,6 +795,7 @@ export {
CreateBackup, CreateBackup,
CreateChildUser, CreateChildUser,
CreateChore, CreateChore,
createChore,
CreateLabel, CreateLabel,
CreateLongLiveToken, CreateLongLiveToken,
CreateThing, CreateThing,
@@ -777,10 +816,10 @@ export {
GetChoreByID, GetChoreByID,
GetChoreDetailById, GetChoreDetailById,
GetChoreHistory, GetChoreHistory,
GetChoreTimer,
GetChores, GetChores,
GetChoresHistory, GetChoresHistory,
GetChoresNew, GetChoresNew,
GetChoreTimer,
GetCircleMemberRequests, GetCircleMemberRequests,
GetDeviceTokens, GetDeviceTokens,
GetLabels, GetLabels,
@@ -795,6 +834,7 @@ export {
GetUserProfile, GetUserProfile,
JoinCircle, JoinCircle,
LeaveCircle, LeaveCircle,
login,
MarkChoreComplete, MarkChoreComplete,
NudgeChore, NudgeChore,
PauseChore, PauseChore,
@@ -811,6 +851,7 @@ export {
SaveChore, SaveChore,
SaveThing, SaveThing,
SetupMFA, SetupMFA,
signUp,
SkipChore, SkipChore,
StartChore, StartChore,
UnArchiveChore, UnArchiveChore,
@@ -828,7 +869,4 @@ export {
UpdateTimeSession, UpdateTimeSession,
UpdateUserDetails, UpdateUserDetails,
VerifyMFA, VerifyMFA,
createChore,
login,
signUp,
} }

View File

@@ -1,5 +1,5 @@
import moment from 'moment' import moment from 'moment'
import { getAssetURL } from './TokenManager' import { apiClient } from './apiClient'
const isPlusAccount = userProfile => { const isPlusAccount = userProfile => {
return userProfile?.expiration && moment(userProfile?.expiration).isAfter() return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
@@ -11,7 +11,7 @@ const resolvePhotoURL = url => {
return url return url
} }
if (url.startsWith('assets')) { if (url.startsWith('assets')) {
return getAssetURL(url) return apiClient.getAssetURL(url)
} }
return url return url
} }

View File

@@ -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,
),
)
}
}

View File

@@ -1,7 +1,7 @@
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy' import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Logo from '../../Logo' import Logo from '../../Logo'
import { apiManager } from '../../utils/TokenManager' import { apiClient } from '../../utils/apiClient'
import Cookies from 'js-cookie' import Cookies from 'js-cookie'
import { useRef } from 'react' import { useRef } from 'react'
@@ -58,7 +58,7 @@ const AuthenticationLoading = () => {
} }
if (code) { if (code) {
const baseURL = apiManager.getApiURL() const baseURL = apiClient.baseURL
fetch(`${baseURL}/auth/${provider}/callback`, { fetch(`${baseURL}/auth/${provider}/callback`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -71,7 +71,7 @@ const AuthenticationLoading = () => {
}).then(response => { }).then(response => {
if (response.status === 200) { if (response.status === 200) {
return response.json().then(data => { return response.json().then(data => {
localStorage.setItem('ca_token', data.token) localStorage.setItem('token', data.token)
localStorage.setItem('ca_expiration', data.expire) localStorage.setItem('ca_expiration', data.expire)
const redirectUrl = Cookies.get('ca_redirect') const redirectUrl = Cookies.get('ca_redirect')

View File

@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom'
import { API_URL } from '../../Config' import { API_URL } from '../../Config'
import Logo from '../../Logo' import Logo from '../../Logo'
import { useNotification } from '../../service/NotificationProvider' import { useNotification } from '../../service/NotificationProvider'
import { apiManager } from '../../utils/TokenManager' import { apiClient } from '../../utils/apiClient'
const LoginSettings = () => { const LoginSettings = () => {
const Navigate = useNavigate() const Navigate = useNavigate()
const [serverURL, setServerURL] = React.useState('') const [serverURL, setServerURL] = React.useState('')
@@ -115,7 +115,7 @@ const LoginSettings = () => {
key: 'customServerUrl', key: 'customServerUrl',
value: serverURL, value: serverURL,
}).then(() => { }).then(() => {
apiManager.updateApiURL(serverURL + '/api/v1') apiClient.baseURL = serverURL + '/api/v1'
Navigate('/login') Navigate('/login')
}) })
}} }}

View File

@@ -30,8 +30,8 @@ import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
import Logo from '../../Logo' import Logo from '../../Logo'
import { useResource } from '../../queries/ResourceQueries' import { useResource } from '../../queries/ResourceQueries'
import { useNotification } from '../../service/NotificationProvider' import { useNotification } from '../../service/NotificationProvider'
import { GetUserProfile, login } from '../../utils/Fetcher' import { useAuth } from '../../hooks/useAuth.jsx'
import { apiManager, isTokenValid } from '../../utils/TokenManager' import { apiClient } from '../../utils/apiClient'
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers' import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
import MFAVerificationModal from './MFAVerificationModal' import MFAVerificationModal from './MFAVerificationModal'
@@ -60,6 +60,7 @@ const LoginView = () => {
} }
const { data: resource } = useResource() const { data: resource } = useResource()
const { showError } = useNotification() const { showError } = useNotification()
const { isAuthenticated, login: authLogin, user } = useAuth()
const Navigate = useNavigate() const Navigate = useNavigate()
useEffect(() => { useEffect(() => {
const initializeSocialLogin = async () => { const initializeSocialLogin = async () => {
@@ -89,18 +90,11 @@ const LoginView = () => {
initializeSocialLogin() initializeSocialLogin()
}, []) }, [])
useEffect(() => { useEffect(() => {
if (isTokenValid()) { if (isAuthenticated && user) {
GetUserProfile().then(response => { setUserProfile(user)
if (response.status === 200) { Navigate('/chores')
return response.json().then(data => {
setUserProfile(data.res)
})
} else {
console.log('Failed to fetch user profile')
}
})
} }
}, []) }, [isAuthenticated, user, Navigate])
const handleSubmit = async e => { const handleSubmit = async e => {
e.preventDefault() e.preventDefault()
@@ -144,98 +138,72 @@ const LoginView = () => {
? buildChildUsername(parentUsername, childName) ? buildChildUsername(parentUsername, childName)
: username : username
login(actualUsername, password) const result = await authLogin({ username: 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
}
// Normal login without MFA if (result.success) {
localStorage.setItem('ca_token', data.token) if (result.data?.mfaRequired) {
localStorage.setItem('ca_expiration', data.expire) setMfaSessionToken(result.data.sessionToken)
setMfaModalOpen(true)
return
}
// Refetch user profile after successful login // Refetch user profile after successful login
queryClient.refetchQueries(['userProfile']) queryClient.refetchQueries(['userProfile'])
const redirectUrl = Cookies.get('ca_redirect') const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl && redirectUrl !== '/') {
if (redirectUrl && redirectUrl !== '/') { Cookies.remove('ca_redirect')
console.log('Redirecting to', redirectUrl) Navigate(redirectUrl)
} else {
Cookies.remove('ca_redirect') Navigate('/chores')
Navigate(redirectUrl) }
} else { } else {
Cookies.remove('ca_redirect') showError({
Navigate('/chores') title: 'Login Failed',
} message: result.error || 'An error occurred, please try again',
})
} 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 loggedWithProvider = function (provider, data) { const loggedWithProvider = async function (provider, data) {
const baseURL = apiManager.getApiURL()
const getAccessToken = data => { const getAccessToken = data => {
if (data['access_token']) { if (data['access_token']) {
// data["access_token"] is for Google
return data['access_token'] return data['access_token']
} else if (data['accessToken']) { } else if (data['accessToken']) {
// data["accessToken"] is for Google Capacitor
return data['accessToken']['token'] return data['accessToken']['token']
} else if (data['response'] && data['response']['id_token']) { } else if (data['response'] && data['response']['id_token']) {
// Apple Sign In returns id_token in response
return data['response']['id_token'] return data['response']['id_token']
} else if (data['id_token']) { } else if (data['id_token']) {
// Direct id_token for Apple (fallback)
return data['id_token'] return data['id_token']
} }
} }
return fetch(`${baseURL}/auth/${provider}/callback`, { try {
method: 'POST', const response = await apiClient.post(`/auth/${provider}/callback`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: provider, provider: provider,
token: getAccessToken(data), token: getAccessToken(data),
data: 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 if (response.ok) {
localStorage.setItem('ca_token', data.token) const responseData = await response.json()
localStorage.setItem('ca_expiration', data.expire)
// 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 // Refetch user profile after successful OAuth login
queryClient.invalidateQueries(['userProfile']) queryClient.invalidateQueries(['userProfile'])
@@ -247,16 +215,21 @@ const LoginView = () => {
} else { } else {
getUserProfileAndNavigateToHome() getUserProfileAndNavigateToHome()
} }
}) }
} } else {
return response.json().then(() => {
const providerName = provider === 'apple' ? 'Apple' : 'Google' const providerName = provider === 'apple' ? 'Apple' : 'Google'
showError({ showError({
title: `${providerName} Login Failed`, title: `${providerName} Login Failed`,
message: `Couldn't log in with ${providerName}, please try again`, 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 = () => { const getUserProfileAndNavigateToHome = () => {
// Refetch user profile after login using React Query // Refetch user profile after login using React Query
@@ -273,8 +246,8 @@ const LoginView = () => {
} }
const handleMFASuccess = data => { const handleMFASuccess = data => {
localStorage.setItem('ca_token', data.token) localStorage.setItem('token', data.token)
localStorage.setItem('ca_expiration', data.expire) localStorage.setItem('token_expiry', data.expire)
setMfaModalOpen(false) setMfaModalOpen(false)
setMfaSessionToken('') setMfaSessionToken('')

View File

@@ -242,7 +242,7 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
// Refresh function to refetch all data // Refresh function to refetch all data
const handleRefresh = async () => { const handleRefresh = async () => {
await Promise.all([refetchChores(), refetchHistory(), refetchMembers()]) await Promise.all([refetchChores(), refetchHistory, refetchMembers])
} }
// Show loading state // Show loading state

View File

@@ -125,7 +125,7 @@ const MyChores = () => {
localStorage.getItem('selectedChoreFilter') || 'anyone', localStorage.getItem('selectedChoreFilter') || 'anyone',
) )
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [performers, setPerformers] = useState([])
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)
const [viewMode, setViewMode] = useState( const [viewMode, setViewMode] = useState(
localStorage.getItem('choreCardViewMode') || 'default', localStorage.getItem('choreCardViewMode') || 'default',
@@ -202,7 +202,6 @@ const MyChores = () => {
choresData?.res choresData?.res
) { ) {
const processEffectAsync = async () => { const processEffectAsync = async () => {
setPerformers(membersData.res)
setChores(processedChores) setChores(processedChores)
setFilteredChores(processedChores) setFilteredChores(processedChores)
@@ -1022,7 +1021,7 @@ const MyChores = () => {
<CardComponent <CardComponent
key={key || chore.id} key={key || chore.id}
chore={chore} chore={chore}
performers={performers} performers={membersData?.res}
userLabels={userLabels} userLabels={userLabels}
onChipClick={handleLabelFiltering} onChipClick={handleLabelFiltering}
onAction={handleChoreAction} onAction={handleChoreAction}
@@ -1508,7 +1507,7 @@ const MyChores = () => {
if ( if (
isUserProfileLoading || isUserProfileLoading ||
userLabelsLoading || userLabelsLoading ||
performers.length === 0 || membersLoading ||
choresLoading choresLoading
) { ) {
return ( return (
@@ -1745,7 +1744,7 @@ const MyChores = () => {
const filterFunction = FILTERS[filter] const filterFunction = FILTERS[filter]
const filteredChores = const filteredChores =
filterFunction.length === 2 filterFunction.length === 2
? filterFunction(chores, userProfile.id) ? filterFunction(chores, userProfile?.id)
: filterFunction(chores) : filterFunction(chores)
setFilteredChores(filteredChores) setFilteredChores(filteredChores)
setSearchFilter(filter) setSearchFilter(filter)
@@ -1757,7 +1756,7 @@ const MyChores = () => {
color={searchFilter === filter ? 'primary' : 'neutral'} color={searchFilter === filter ? 'primary' : 'neutral'}
> >
{FILTERS[filter].length === 2 {FILTERS[filter].length === 2
? FILTERS[filter](chores, userProfile.id).length ? FILTERS[filter](chores, userProfile?.id).length
: FILTERS[filter](chores).length} : FILTERS[filter](chores).length}
</Chip> </Chip>
</MenuItem> </MenuItem>
@@ -2323,7 +2322,7 @@ const MyChores = () => {
<CompactChoreCard <CompactChoreCard
key={`calendar-${chore.id}`} key={`calendar-${chore.id}`}
chore={chore} chore={chore}
performers={performers} performers={membersData?.res || []}
userLabels={userLabels} userLabels={userLabels}
onChipClick={handleLabelFiltering} onChipClick={handleLabelFiltering}
onAction={handleChoreAction} onAction={handleChoreAction}
@@ -2501,7 +2500,7 @@ const MyChores = () => {
)} )}
</Container> </Container>
<Sidepanel chores={chores} performers={performers} /> <Sidepanel chores={chores} performers={membersData?.res || []} />
{/* Multi-select Help - only show when in multi-select mode */} {/* Multi-select Help - only show when in multi-select mode */}
{/* <MultiSelectHelp isVisible={isMultiSelectMode} /> */} {/* <MultiSelectHelp isVisible={isMultiSelectMode} /> */}
@@ -2537,7 +2536,7 @@ const MyChores = () => {
{activeModal === 'changeAssignee' && modalChore && ( {activeModal === 'changeAssignee' && modalChore && (
<SelectModal <SelectModal
isOpen={true} isOpen={true}
options={performers} options={membersData?.res || []}
displayKey='displayName' displayKey='displayName'
title={`Delegate to someone else`} title={`Delegate to someone else`}
placeholder={'Select a performer'} placeholder={'Select a performer'}

View File

@@ -18,7 +18,7 @@ import { useNotification } from '../../service/NotificationProvider'
import { UpdateUserDetails } from '../../utils/Fetcher' import { UpdateUserDetails } from '../../utils/Fetcher'
import { resolvePhotoURL } from '../../utils/Helpers' import { resolvePhotoURL } from '../../utils/Helpers'
import { getCroppedImg } from '../../utils/imageCropUtils' import { getCroppedImg } from '../../utils/imageCropUtils'
import { UploadFile } from '../../utils/TokenManager' import { apiClient } from '../../utils/apiClient'
import SettingsLayout from './SettingsLayout' import SettingsLayout from './SettingsLayout'
const ProfileSettings = () => { const ProfileSettings = () => {
@@ -85,10 +85,7 @@ const ProfileSettings = () => {
const formData = new FormData() const formData = new FormData()
formData.append('file', compressedFile, 'profile.jpg') formData.append('file', compressedFile, 'profile.jpg')
const response = await UploadFile('/users/profile_photo', { const response = await apiClient.upload('/users/profile_photo', formData)
method: 'POST',
body: formData,
})
if (!response.ok) throw new Error('Upload failed') if (!response.ok) throw new Error('Upload failed')
const data = await response.json() const data = await response.json()
const url = resolvePhotoURL(data.url || data.sign) const url = resolvePhotoURL(data.url || data.sign)

View File

@@ -87,12 +87,14 @@ const links = [
] ]
import { SafeArea } from 'capacitor-plugin-safe-area' import { SafeArea } from 'capacitor-plugin-safe-area'
import { useAuth } from '../../hooks/useAuth.jsx'
import Z_INDEX from '../../constants/zIndex' import Z_INDEX from '../../constants/zIndex'
import { useResource } from '../../queries/ResourceQueries' import { useResource } from '../../queries/ResourceQueries'
const publicPages = ['/landing', '/privacy', '/terms'] const publicPages = ['/landing', '/privacy', '/terms']
const NavBar = () => { const NavBar = () => {
const { data: resource } = useResource() const { data: resource } = useResource()
const { logout } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
@@ -264,12 +266,7 @@ const NavBar = () => {
<ListItemContent>Upgrade to Plus</ListItemContent> <ListItemContent>Upgrade to Plus</ListItemContent>
</ListItemButton> */} </ListItemButton> */}
<ListItemButton <ListItemButton
onClick={() => { onClick={logout}
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
// go to login page:
window.location.href = '/login'
}}
sx={{ sx={{
py: 1.2, py: 1.2,
}} }}

View File

@@ -12,7 +12,7 @@ import {
import { useUserProfile } from '../../queries/UserQueries' import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider' import { useNotification } from '../../service/NotificationProvider'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers' import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import { UploadFile } from '../../utils/TokenManager' import { apiClient } from '../../utils/apiClient'
import './RichTextEditor.css' import './RichTextEditor.css'
const RichTextEditor = forwardRef( const RichTextEditor = forwardRef(
@@ -106,10 +106,7 @@ const RichTextEditor = forwardRef(
formData.append('entityId', entityId) formData.append('entityId', entityId)
formData.append('entityType', entityType) formData.append('entityType', entityType)
const response = await UploadFile('/assets/chore', { const response = await apiClient.upload('/assets/chore', formData)
method: 'POST',
body: formData,
})
if (response.status === 507) { if (response.status === 507) {
showError({ showError({