Files
papercrate/frontend/src/app/appState.tsx
T
2025-11-14 02:35:17 +01:00

269 lines
7.1 KiB
TypeScript

import React, { useContext, useEffect, useMemo, useReducer } from 'react';
import api from '../lib/api';
type Tenant = Record<string, unknown> | null;
interface TenantSelection {
selectionToken: string;
tenants: Tenant[];
}
type AppStatus =
| 'logged-out'
| 'authenticating'
| 'authenticated'
| 'selecting-tenant'
| 'bootstrapping'
| 'ready';
interface AppState {
status: AppStatus;
token: string;
error: string | null;
isRefreshing: boolean;
tenantSelection: TenantSelection | null;
tenant: Tenant;
tenants: Tenant[];
}
type AppAction =
| { type: 'LOGIN_REQUEST' }
| { type: 'LOGIN_SUCCESS'; token: string; tenant?: Tenant }
| { type: 'LOGIN_FAILURE'; error?: string | null }
| { type: 'TENANT_SELECTION_REQUIRED'; selectionToken: string; tenants: Tenant[] }
| { type: 'CLEAR_TENANT_SELECTION' }
| { type: 'LOGOUT_SUCCESS' }
| { type: 'BOOTSTRAP_START' }
| { type: 'BOOTSTRAP_SUCCESS' }
| { type: 'BOOTSTRAP_FAILURE'; error?: string | null }
| { type: 'TOKEN_REFRESH_START' }
| { type: 'TOKEN_REFRESH_SUCCESS'; token: string; tenant?: Tenant }
| { type: 'TOKEN_REFRESH_FAILURE'; error?: string | null }
| { type: 'LOGOUT' }
| { type: 'RESET_ERROR' }
| { type: 'SET_TENANTS'; tenants: Tenant[] };
const storage = window.sessionStorage;
const STORED_TOKEN = storage?.getItem('papercrate_token') ?? '';
let STORED_TENANT: Tenant = null;
if (storage) {
try {
const rawTenant = storage.getItem('papercrate_tenant');
if (rawTenant) {
STORED_TENANT = JSON.parse(rawTenant);
}
} catch (error) {
console.warn('[app] Failed to parse stored tenant metadata', error);
}
}
if (STORED_TOKEN) {
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
}
const initialAppState: AppState = {
status: STORED_TOKEN ? 'authenticated' : 'logged-out',
token: STORED_TOKEN,
error: null,
isRefreshing: false,
tenantSelection: null,
tenant: STORED_TENANT,
tenants: [],
};
const AppStateContext = React.createContext<AppState | null>(null);
const AppDispatchContext = React.createContext<React.Dispatch<AppAction> | null>(null);
const appStateReducer = (state: AppState, action: AppAction): AppState => {
switch (action.type) {
case 'LOGIN_REQUEST':
return {
...state,
status: 'authenticating',
error: null,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'LOGIN_SUCCESS':
return {
...state,
status: 'authenticated',
token: action.token,
error: null,
tenantSelection: null,
tenant: action.tenant ?? null,
tenants: state.tenants,
};
case 'LOGIN_FAILURE':
return {
status: 'logged-out',
token: '',
error: action.error ?? null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'TENANT_SELECTION_REQUIRED':
return {
status: 'selecting-tenant',
token: '',
error: null,
isRefreshing: false,
tenantSelection: {
selectionToken: action.selectionToken,
tenants: action.tenants,
},
tenant: null,
tenants: [],
};
case 'CLEAR_TENANT_SELECTION':
return {
status: 'logged-out',
token: '',
error: null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'LOGOUT_SUCCESS':
case 'LOGOUT':
return {
status: 'logged-out',
token: '',
error: null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'BOOTSTRAP_START':
return { ...state, status: 'bootstrapping', error: null };
case 'BOOTSTRAP_SUCCESS':
return { ...state, status: 'ready', error: null };
case 'BOOTSTRAP_FAILURE':
return { ...state, status: 'authenticated', error: action.error ?? null };
case 'TOKEN_REFRESH_START':
return { ...state, isRefreshing: true, error: null };
case 'TOKEN_REFRESH_SUCCESS':
return {
...state,
token: action.token,
isRefreshing: false,
status: state.status === 'logged-out' ? 'authenticated' : state.status,
tenantSelection: null,
tenant: action.tenant ?? state.tenant ?? null,
tenants: state.tenants,
};
case 'TOKEN_REFRESH_FAILURE':
return {
status: 'logged-out',
token: '',
error: action.error ?? null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'RESET_ERROR':
return { ...state, error: null };
case 'SET_TENANTS':
return {
...state,
tenants: Array.isArray(action.tenants) ? action.tenants : [],
};
default:
return state;
}
};
const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(appStateReducer, initialAppState);
useEffect(() => {
const token = state.token ?? '';
if (token) {
api.defaults.headers.common.Authorization = `Bearer ${token}`;
storage?.setItem('papercrate_token', token);
} else {
delete api.defaults.headers.common.Authorization;
storage?.removeItem('papercrate_token');
}
}, [state.token]);
useEffect(() => {
if (state.tenant) {
try {
storage?.setItem('papercrate_tenant', JSON.stringify(state.tenant));
} catch (error) {
console.warn('[app] Failed to persist tenant info', error);
}
} else {
storage?.removeItem('papercrate_tenant');
}
}, [state.tenant]);
useEffect(() => {
let abort = false;
const loadTenants = async () => {
if (state.status !== 'authenticated' || !state.token) {
dispatch({ type: 'SET_TENANTS', tenants: [] });
return;
}
try {
const { data } = await api.get('/tenants');
if (!abort) {
dispatch({
type: 'SET_TENANTS',
tenants: Array.isArray(data?.tenants) ? data.tenants : [],
});
}
} catch (error) {
if (!abort) {
console.warn('Failed to load tenant list', error);
}
}
};
loadTenants();
return () => {
abort = true;
};
}, [state.status, state.token, dispatch]);
const stateValue = useMemo(() => state, [state]);
return (
<AppStateContext.Provider value={stateValue}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
);
};
const useAppState = (): AppState => {
const context = useContext(AppStateContext);
if (!context) {
throw new Error('useAppState must be used within an AppStateProvider.');
}
return context;
};
const useAppDispatch = (): React.Dispatch<AppAction> => {
const context = useContext(AppDispatchContext);
if (!context) {
throw new Error('useAppDispatch must be used within an AppStateProvider.');
}
return context;
};
export { api, AppStateProvider, useAppState, useAppDispatch };