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:
2026-03-16 21:12:35 -04:00
parent 17bdebfb52
commit dfcd172ef3
39 changed files with 4673 additions and 82 deletions

View File

@@ -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);
},
);