feat: SaaS onboarding, Stripe billing, MFA, SSO, passkeys, refresh tokens
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>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import axios from 'axios';
|
||||
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) => {
|
||||
@@ -14,23 +15,89 @@ api.interceptors.request.use((config) => {
|
||||
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,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
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 error.response?.data?.message === 'string' &&
|
||||
error.response.data.message.includes('has been')
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user