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:
82
frontend/src/pages/onboarding/OnboardingPendingPage.tsx
Normal file
82
frontend/src/pages/onboarding/OnboardingPendingPage.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Container, Center, Stack, Loader, Text, Title, Alert, Button } from '@mantine/core';
|
||||
import { IconCheck, IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
|
||||
export function OnboardingPendingPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const sessionId = searchParams.get('session_id');
|
||||
const [status, setStatus] = useState<string>('polling');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setError('No session ID provided');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/billing/status?session_id=${sessionId}`);
|
||||
if (cancelled) return;
|
||||
|
||||
if (data.status === 'active') {
|
||||
setStatus('complete');
|
||||
// Redirect to login page — user will get activation email
|
||||
setTimeout(() => navigate('/login'), 3000);
|
||||
} else if (data.status === 'not_configured') {
|
||||
setError('Payment system is not configured. Please contact support.');
|
||||
} else {
|
||||
// Still provisioning — poll again
|
||||
setTimeout(poll, 3000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
setError(err.response?.data?.message || 'Failed to check status');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
return () => { cancelled = true; };
|
||||
}, [sessionId, navigate]);
|
||||
|
||||
return (
|
||||
<Container size="sm" py={80}>
|
||||
<Center>
|
||||
<Stack align="center" gap="lg">
|
||||
{error ? (
|
||||
<>
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
||||
{error}
|
||||
</Alert>
|
||||
<Button variant="light" onClick={() => navigate('/pricing')}>
|
||||
Back to Pricing
|
||||
</Button>
|
||||
</>
|
||||
) : status === 'complete' ? (
|
||||
<>
|
||||
<IconCheck size={48} color="var(--mantine-color-green-6)" />
|
||||
<Title order={2}>Your account is ready!</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
Check your email for an activation link to set your password and get started.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">Redirecting to login...</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader size="xl" />
|
||||
<Title order={2}>Setting up your account...</Title>
|
||||
<Text c="dimmed" ta="center" maw={400}>
|
||||
We're creating your HOA workspace. This usually takes just a few seconds.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user