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:
20
src/App.jsx
20
src/App.jsx
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
144
src/hooks/useAuth.jsx
Normal 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>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
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 = () => {
|
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 => {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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('')
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user