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 { 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() {
<>
<NetworkBanner />
<AuthenticationProvider>
<AppContent />
</AuthenticationProvider>
<AuthProvider>
<SSEProvider>
<AppContent />
</SSEProvider>
</AuthProvider>
</>
)
}

View File

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

View File

@@ -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,
]

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

View File

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

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

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 => {
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,
}

View File

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

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 { 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')

View File

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

View File

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

View File

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

View File

@@ -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 = () => {
<CardComponent
key={key || chore.id}
chore={chore}
performers={performers}
performers={membersData?.res}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
onAction={handleChoreAction}
@@ -1508,7 +1507,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}
</Chip>
</MenuItem>
@@ -2323,7 +2322,7 @@ const MyChores = () => {
<CompactChoreCard
key={`calendar-${chore.id}`}
chore={chore}
performers={performers}
performers={membersData?.res || []}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
onAction={handleChoreAction}
@@ -2501,7 +2500,7 @@ const MyChores = () => {
)}
</Container>
<Sidepanel chores={chores} performers={performers} />
<Sidepanel chores={chores} performers={membersData?.res || []} />
{/* Multi-select Help - only show when in multi-select mode */}
{/* <MultiSelectHelp isVisible={isMultiSelectMode} /> */}
@@ -2537,7 +2536,7 @@ const MyChores = () => {
{activeModal === 'changeAssignee' && modalChore && (
<SelectModal
isOpen={true}
options={performers}
options={membersData?.res || []}
displayKey='displayName'
title={`Delegate to someone else`}
placeholder={'Select a performer'}

View File

@@ -18,7 +18,7 @@ import { useNotification } from '../../service/NotificationProvider'
import { UpdateUserDetails } from '../../utils/Fetcher'
import { resolvePhotoURL } from '../../utils/Helpers'
import { getCroppedImg } from '../../utils/imageCropUtils'
import { UploadFile } from '../../utils/TokenManager'
import { apiClient } from '../../utils/apiClient'
import SettingsLayout from './SettingsLayout'
const ProfileSettings = () => {
@@ -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)

View File

@@ -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 = () => {
<ListItemContent>Upgrade to Plus</ListItemContent>
</ListItemButton> */}
<ListItemButton
onClick={() => {
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
// go to login page:
window.location.href = '/login'
}}
onClick={logout}
sx={{
py: 1.2,
}}

View File

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