Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
313 lines
9.8 KiB
TypeScript
313 lines
9.8 KiB
TypeScript
import { logoutUser } from '@/stores/auth'
|
|
|
|
const hasLocalStorage =
|
|
typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function'
|
|
const CROSS_TAB_REFRESH_KEY = 'lastRefreshAt'
|
|
const CROSS_TAB_REFRESH_MIN_INTERVAL = 8_000 // 8s — prevents concurrent multi-tab refresh
|
|
|
|
let unauthorizedInterceptorInstalled = false
|
|
let unauthorizedRedirectHandler: (() => void) | null = null
|
|
let unauthorizedHandlingInProgress = false
|
|
|
|
// Mutex for refresh token requests — prevents concurrent refresh calls
|
|
let refreshPromise: Promise<boolean> | null = null
|
|
|
|
export function setUnauthorizedRedirectHandlerForTests(handler: (() => void) | null): void {
|
|
unauthorizedRedirectHandler = handler
|
|
}
|
|
|
|
/** Reset interceptor state (for tests). */
|
|
export function resetInterceptorStateForTests(): void {
|
|
unauthorizedHandlingInProgress = false
|
|
refreshPromise = null
|
|
if (hasLocalStorage && typeof localStorage.removeItem === 'function') {
|
|
localStorage.removeItem(CROSS_TAB_REFRESH_KEY)
|
|
}
|
|
}
|
|
|
|
function handleUnauthorizedResponse(): void {
|
|
if (unauthorizedHandlingInProgress) return
|
|
unauthorizedHandlingInProgress = true
|
|
logoutUser()
|
|
if (typeof window === 'undefined') return
|
|
if (window.location.pathname.startsWith('/auth')) return
|
|
if (window.location.pathname === '/') return
|
|
if (unauthorizedRedirectHandler) {
|
|
unauthorizedRedirectHandler()
|
|
return
|
|
}
|
|
window.location.assign('/')
|
|
}
|
|
|
|
/**
|
|
* Attempt to refresh the access token by calling the refresh endpoint.
|
|
* Returns true if refresh succeeded, false otherwise.
|
|
* Uses a mutex so concurrent 401s only trigger one refresh call.
|
|
* Also uses a localStorage timestamp to coordinate across tabs, preventing
|
|
* concurrent refreshes that would trigger false-positive theft detection.
|
|
*/
|
|
async function attemptTokenRefresh(originalFetch: typeof fetch): Promise<boolean> {
|
|
// Cross-tab coordination: if another tab refreshed recently, the new cookie
|
|
// is already set. Skip the refresh call and let the original request retry.
|
|
if (hasLocalStorage && typeof localStorage.getItem === 'function') {
|
|
const lastRefresh = localStorage.getItem(CROSS_TAB_REFRESH_KEY)
|
|
if (lastRefresh && Date.now() - Number(lastRefresh) < CROSS_TAB_REFRESH_MIN_INTERVAL) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
if (refreshPromise) return refreshPromise
|
|
|
|
refreshPromise = (async () => {
|
|
try {
|
|
const res = await originalFetch('/api/auth/refresh', { method: 'POST' })
|
|
if (res.ok && hasLocalStorage && typeof localStorage.setItem === 'function') {
|
|
localStorage.setItem(CROSS_TAB_REFRESH_KEY, String(Date.now()))
|
|
}
|
|
return res.ok
|
|
} catch {
|
|
return false
|
|
} finally {
|
|
refreshPromise = null
|
|
}
|
|
})()
|
|
|
|
return refreshPromise
|
|
}
|
|
|
|
/** URLs that should NOT trigger a refresh attempt on 401. */
|
|
function isAuthUrl(url: string): boolean {
|
|
return url.includes('/api/auth/refresh') || url.includes('/api/auth/login')
|
|
}
|
|
|
|
export function installUnauthorizedFetchInterceptor(): void {
|
|
if (unauthorizedInterceptorInstalled || typeof window === 'undefined') return
|
|
unauthorizedInterceptorInstalled = true
|
|
|
|
const originalFetch = globalThis.fetch.bind(globalThis)
|
|
const wrappedFetch = (async (...args: Parameters<typeof fetch>) => {
|
|
const response = await originalFetch(...args)
|
|
|
|
if (response.status === 401) {
|
|
// Determine the request URL
|
|
const requestUrl = typeof args[0] === 'string' ? args[0] : (args[0] as Request).url
|
|
|
|
// Don't attempt refresh for auth endpoints themselves
|
|
if (isAuthUrl(requestUrl)) {
|
|
handleUnauthorizedResponse()
|
|
return response
|
|
}
|
|
|
|
// Attempt silent refresh
|
|
const refreshed = await attemptTokenRefresh(originalFetch)
|
|
if (refreshed) {
|
|
// Retry the original request with the new access token cookie
|
|
return originalFetch(...args)
|
|
}
|
|
|
|
// Refresh failed — log out
|
|
handleUnauthorizedResponse()
|
|
}
|
|
|
|
return response
|
|
}) as typeof fetch
|
|
|
|
window.fetch = wrappedFetch as typeof window.fetch
|
|
globalThis.fetch = wrappedFetch as typeof globalThis.fetch
|
|
}
|
|
|
|
export async function parseErrorResponse(res: Response): Promise<{ msg: string; code?: string }> {
|
|
try {
|
|
const data = await res.json()
|
|
return { msg: data.error || data.message || 'Error', code: data.code }
|
|
} catch {
|
|
const text = await res.text()
|
|
return { msg: text || 'Error' }
|
|
}
|
|
}
|
|
|
|
export function isEmailValid(email: string): boolean {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
|
}
|
|
|
|
export function isPasswordStrong(password: string): boolean {
|
|
return /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{8,}$/.test(password)
|
|
}
|
|
|
|
/**
|
|
* Fetch tracking events for a child with optional filters.
|
|
*/
|
|
export async function getTrackingEventsForChild(params: {
|
|
childId: string
|
|
entityType?: 'task' | 'reward' | 'penalty' | 'chore' | 'kindness'
|
|
action?:
|
|
| 'activated'
|
|
| 'requested'
|
|
| 'redeemed'
|
|
| 'cancelled'
|
|
| 'confirmed'
|
|
| 'approved'
|
|
| 'rejected'
|
|
| 'reset'
|
|
limit?: number
|
|
offset?: number
|
|
}): Promise<Response> {
|
|
const query = new URLSearchParams()
|
|
query.set('child_id', params.childId)
|
|
if (params.entityType) query.set('entity_type', params.entityType)
|
|
if (params.action) query.set('action', params.action)
|
|
if (params.limit) query.set('limit', params.limit.toString())
|
|
if (params.offset) query.set('offset', params.offset.toString())
|
|
|
|
return fetch(`/api/admin/tracking?${query.toString()}`)
|
|
}
|
|
|
|
/**
|
|
* Set or update a custom value for a task/reward for a specific child.
|
|
*/
|
|
export async function setChildOverride(
|
|
childId: string,
|
|
entityId: string,
|
|
entityType: 'task' | 'reward',
|
|
customValue: number,
|
|
): Promise<Response> {
|
|
return fetch(`/api/child/${childId}/override`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
entity_id: entityId,
|
|
entity_type: entityType,
|
|
custom_value: customValue,
|
|
}),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get all overrides for a specific child.
|
|
*/
|
|
export async function getChildOverrides(childId: string): Promise<Response> {
|
|
return fetch(`/api/child/${childId}/overrides`)
|
|
}
|
|
|
|
/**
|
|
* Delete an override (reset to default).
|
|
*/
|
|
export async function deleteChildOverride(childId: string, entityId: string): Promise<Response> {
|
|
return fetch(`/api/child/${childId}/override/${entityId}`, {
|
|
method: 'DELETE',
|
|
})
|
|
}
|
|
|
|
// ── Chore Schedule API ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get the schedule for a specific child + task pair.
|
|
*/
|
|
export async function getChoreSchedule(childId: string, taskId: string): Promise<Response> {
|
|
return fetch(`/api/child/${childId}/task/${taskId}/schedule`)
|
|
}
|
|
|
|
/**
|
|
* Create or replace the schedule for a specific child + task pair.
|
|
*/
|
|
export async function setChoreSchedule(
|
|
childId: string,
|
|
taskId: string,
|
|
schedule: object,
|
|
): Promise<Response> {
|
|
return fetch(`/api/child/${childId}/task/${taskId}/schedule`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(schedule),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Delete the schedule for a specific child + task pair.
|
|
*/
|
|
export async function deleteChoreSchedule(childId: string, taskId: string): Promise<Response> {
|
|
return fetch(`/api/child/${childId}/task/${taskId}/schedule`, {
|
|
method: 'DELETE',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Extend a timed-out chore for the remainder of today only.
|
|
* `localDate` is the client's local ISO date (e.g. '2026-02-22').
|
|
*/
|
|
export async function extendChoreTime(
|
|
childId: string,
|
|
taskId: string,
|
|
localDate: string,
|
|
): Promise<Response> {
|
|
return fetch(`/api/child/${childId}/task/${taskId}/extend`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ date: localDate }),
|
|
})
|
|
}
|
|
|
|
// ── Chore Confirmation API ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Child confirms they completed a chore.
|
|
*/
|
|
export async function confirmChore(childId: string, taskId: string): Promise<Response> {
|
|
return fetch(`/api/child/${childId}/confirm-chore`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ task_id: taskId }),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Child cancels a pending chore confirmation.
|
|
*/
|
|
export async function cancelConfirmChore(childId: string, taskId: string): Promise<Response> {
|
|
return fetch(`/api/child/${childId}/cancel-confirm-chore`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ task_id: taskId }),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Parent approves a pending chore confirmation (awards points).
|
|
*/
|
|
export async function approveChore(childId: string, taskId: string): Promise<Response> {
|
|
return fetch(`/api/child/${childId}/approve-chore`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ task_id: taskId }),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Parent rejects a pending chore confirmation (no points, resets to available).
|
|
*/
|
|
export async function rejectChore(childId: string, taskId: string): Promise<Response> {
|
|
return fetch(`/api/child/${childId}/reject-chore`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ task_id: taskId }),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Parent resets a completed chore (points kept, chore can be confirmed again).
|
|
*/
|
|
export async function resetChore(childId: string, taskId: string): Promise<Response> {
|
|
return fetch(`/api/child/${childId}/reset-chore`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ task_id: taskId }),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Fetch all pending confirmations (both chores and rewards) for the current user.
|
|
*/
|
|
export async function fetchPendingConfirmations(): Promise<Response> {
|
|
return fetch('/api/pending-confirmations')
|
|
}
|