Complete SaaS self-service onboarding sprint: - Stripe-powered signup flow: pricing page → checkout → provisioning → activation - Refresh token infrastructure: 1h access tokens + 30-day httpOnly cookie refresh - TOTP MFA with QR setup, recovery codes, and login challenge flow - Google + Azure AD SSO (conditional on env vars) with account linking - WebAuthn passkey registration and passwordless login - Guided onboarding checklist with server-side progress tracking - Stubbed email service (console + DB logging, ready for real provider) - Settings page with tabbed security settings (MFA, passkeys, linked accounts) - Login page enhanced with MFA verification, SSO buttons, passkey login - Database migration 015 with all new tables and columns - Version bump to 2026.03.17 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
106 lines
3.3 KiB
TypeScript
106 lines
3.3 KiB
TypeScript
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
|
import { useAuthStore } from '../stores/authStore';
|
|
|
|
const api = axios.create({
|
|
baseURL: '/api',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
withCredentials: true, // Send httpOnly cookies for refresh token
|
|
});
|
|
|
|
api.interceptors.request.use((config) => {
|
|
const token = useAuthStore.getState().token;
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// ─── Silent Refresh Logic ─────────────────────────────────────────
|
|
let isRefreshing = false;
|
|
let pendingQueue: Array<{
|
|
resolve: (token: string) => void;
|
|
reject: (err: any) => void;
|
|
}> = [];
|
|
|
|
function processPendingQueue(error: any, token: string | null) {
|
|
pendingQueue.forEach((p) => {
|
|
if (error) {
|
|
p.reject(error);
|
|
} else {
|
|
p.resolve(token!);
|
|
}
|
|
});
|
|
pendingQueue = [];
|
|
}
|
|
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
async (error: AxiosError) => {
|
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
|
|
|
// If 401 and we haven't retried yet, try refreshing the token
|
|
if (
|
|
error.response?.status === 401 &&
|
|
originalRequest &&
|
|
!originalRequest._retry &&
|
|
!originalRequest.url?.includes('/auth/refresh') &&
|
|
!originalRequest.url?.includes('/auth/login')
|
|
) {
|
|
originalRequest._retry = true;
|
|
|
|
if (isRefreshing) {
|
|
// Another request is already refreshing — queue this one
|
|
return new Promise((resolve, reject) => {
|
|
pendingQueue.push({
|
|
resolve: (token: string) => {
|
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
resolve(api(originalRequest));
|
|
},
|
|
reject: (err: any) => reject(err),
|
|
});
|
|
});
|
|
}
|
|
|
|
isRefreshing = true;
|
|
|
|
try {
|
|
const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true });
|
|
const newToken = data.accessToken;
|
|
useAuthStore.getState().setToken(newToken);
|
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
|
processPendingQueue(null, newToken);
|
|
return api(originalRequest);
|
|
} catch (refreshError) {
|
|
processPendingQueue(refreshError, null);
|
|
useAuthStore.getState().logout();
|
|
window.location.href = '/login';
|
|
return Promise.reject(refreshError);
|
|
} finally {
|
|
isRefreshing = false;
|
|
}
|
|
}
|
|
|
|
// Non-retryable 401 (e.g. refresh failed, login failed)
|
|
if (error.response?.status === 401 && originalRequest?.url?.includes('/auth/refresh')) {
|
|
useAuthStore.getState().logout();
|
|
window.location.href = '/login';
|
|
}
|
|
|
|
// Handle org suspended/archived — redirect to org selection
|
|
const responseData = error.response?.data as any;
|
|
if (
|
|
error.response?.status === 403 &&
|
|
typeof responseData?.message === 'string' &&
|
|
responseData.message.includes('has been')
|
|
) {
|
|
const store = useAuthStore.getState();
|
|
store.setCurrentOrg({ id: '', name: '', role: '' }); // Clear current org
|
|
window.location.href = '/select-org';
|
|
}
|
|
|
|
return Promise.reject(error);
|
|
},
|
|
);
|
|
|
|
export default api;
|