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

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