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:
180
src/utils/ApiClient.js
Normal file
180
src/utils/ApiClient.js
Normal 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()
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user