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:
241
frontend/src/pages/onboarding/OnboardingPage.tsx
Normal file
241
frontend/src/pages/onboarding/OnboardingPage.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container, Title, Text, Stack, Card, Group, Button, TextInput,
|
||||
Select, Stepper, ThemeIcon, Progress, Alert, Loader, Center, Anchor,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import {
|
||||
IconUser, IconBuilding, IconUserPlus, IconListDetails,
|
||||
IconCheck, IconPlayerPlay, IconConfetti,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
const STEPS = [
|
||||
{ slug: 'profile', label: 'Complete Your Profile', icon: IconUser, description: 'Set up your name and contact' },
|
||||
{ slug: 'workspace', label: 'Configure Your HOA', icon: IconBuilding, description: 'Organization name and settings' },
|
||||
{ slug: 'invite_member', label: 'Invite a Team Member', icon: IconUserPlus, description: 'Add a board member or manager' },
|
||||
{ slug: 'first_workflow', label: 'Set Up First Account', icon: IconListDetails, description: 'Create your chart of accounts' },
|
||||
];
|
||||
|
||||
export function OnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
|
||||
const { data: progress, isLoading } = useQuery({
|
||||
queryKey: ['onboarding-progress'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/onboarding/progress');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const markStep = useMutation({
|
||||
mutationFn: (step: string) => api.patch('/onboarding/progress', { step }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['onboarding-progress'] }),
|
||||
});
|
||||
|
||||
const completedSteps = progress?.completedSteps || [];
|
||||
const completedCount = completedSteps.length;
|
||||
const allDone = progress?.completedAt != null;
|
||||
|
||||
// Profile form
|
||||
const profileForm = useForm({
|
||||
initialValues: {
|
||||
firstName: user?.firstName || '',
|
||||
lastName: user?.lastName || '',
|
||||
phone: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Workspace form
|
||||
const workspaceForm = useForm({
|
||||
initialValues: { orgName: '', address: '', fiscalYearStart: '1' },
|
||||
});
|
||||
|
||||
// Invite form
|
||||
const inviteForm = useForm({
|
||||
initialValues: { email: '', role: 'treasurer' },
|
||||
validate: { email: (v) => (/\S+@\S+/.test(v) ? null : 'Valid email required') },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-advance to first incomplete step
|
||||
const firstIncomplete = STEPS.findIndex((s) => !completedSteps.includes(s.slug));
|
||||
if (firstIncomplete >= 0) setActiveStep(firstIncomplete);
|
||||
}, [completedSteps]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Center h={400}><Loader size="lg" /></Center>;
|
||||
}
|
||||
|
||||
if (allDone) {
|
||||
return (
|
||||
<Container size="sm" py={60}>
|
||||
<Center>
|
||||
<Stack align="center" gap="lg">
|
||||
<ThemeIcon size={60} radius="xl" color="green" variant="light">
|
||||
<IconConfetti size={30} />
|
||||
</ThemeIcon>
|
||||
<Title order={2}>You're all set!</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
Your workspace is ready. Let's get to work.
|
||||
</Text>
|
||||
<Button size="lg" onClick={() => navigate('/dashboard')}>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="md" py={40}>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2}>Welcome to HOA LedgerIQ</Title>
|
||||
<Text c="dimmed" size="sm">Complete these steps to set up your workspace</Text>
|
||||
</div>
|
||||
|
||||
<Progress value={(completedCount / STEPS.length) * 100} size="lg" color="teal" />
|
||||
<Text size="sm" c="dimmed" ta="center">{completedCount} of {STEPS.length} steps complete</Text>
|
||||
|
||||
<Stepper
|
||||
active={activeStep}
|
||||
onStepClick={setActiveStep}
|
||||
orientation="vertical"
|
||||
size="sm"
|
||||
>
|
||||
{/* Step 1: Profile */}
|
||||
<Stepper.Step
|
||||
label={STEPS[0].label}
|
||||
description={STEPS[0].description}
|
||||
icon={completedSteps.includes('profile') ? <IconCheck size={16} /> : <IconUser size={16} />}
|
||||
completedIcon={<IconCheck size={16} />}
|
||||
color={completedSteps.includes('profile') ? 'green' : undefined}
|
||||
>
|
||||
<Card withBorder p="lg" mt="sm">
|
||||
<form onSubmit={profileForm.onSubmit(() => markStep.mutate('profile'))}>
|
||||
<Stack>
|
||||
<Group grow>
|
||||
<TextInput label="First Name" {...profileForm.getInputProps('firstName')} />
|
||||
<TextInput label="Last Name" {...profileForm.getInputProps('lastName')} />
|
||||
</Group>
|
||||
<TextInput label="Phone (optional)" {...profileForm.getInputProps('phone')} />
|
||||
<Button type="submit" loading={markStep.isPending}>Save & Continue</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
|
||||
{/* Step 2: Workspace */}
|
||||
<Stepper.Step
|
||||
label={STEPS[1].label}
|
||||
description={STEPS[1].description}
|
||||
icon={completedSteps.includes('workspace') ? <IconCheck size={16} /> : <IconBuilding size={16} />}
|
||||
completedIcon={<IconCheck size={16} />}
|
||||
color={completedSteps.includes('workspace') ? 'green' : undefined}
|
||||
>
|
||||
<Card withBorder p="lg" mt="sm">
|
||||
<form onSubmit={workspaceForm.onSubmit(() => markStep.mutate('workspace'))}>
|
||||
<Stack>
|
||||
<TextInput label="Organization Name" placeholder="Sunset Village HOA" {...workspaceForm.getInputProps('orgName')} />
|
||||
<TextInput label="Address" placeholder="123 Main St" {...workspaceForm.getInputProps('address')} />
|
||||
<Select
|
||||
label="Fiscal Year Start Month"
|
||||
data={[
|
||||
{ value: '1', label: 'January' },
|
||||
{ value: '4', label: 'April' },
|
||||
{ value: '7', label: 'July' },
|
||||
{ value: '10', label: 'October' },
|
||||
]}
|
||||
{...workspaceForm.getInputProps('fiscalYearStart')}
|
||||
/>
|
||||
<Button type="submit" loading={markStep.isPending}>Save & Continue</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
|
||||
{/* Step 3: Invite */}
|
||||
<Stepper.Step
|
||||
label={STEPS[2].label}
|
||||
description={STEPS[2].description}
|
||||
icon={completedSteps.includes('invite_member') ? <IconCheck size={16} /> : <IconUserPlus size={16} />}
|
||||
completedIcon={<IconCheck size={16} />}
|
||||
color={completedSteps.includes('invite_member') ? 'green' : undefined}
|
||||
>
|
||||
<Card withBorder p="lg" mt="sm">
|
||||
<form onSubmit={inviteForm.onSubmit(() => markStep.mutate('invite_member'))}>
|
||||
<Stack>
|
||||
<TextInput label="Email Address" placeholder="teammate@example.com" {...inviteForm.getInputProps('email')} />
|
||||
<Select
|
||||
label="Role"
|
||||
data={[
|
||||
{ value: 'president', label: 'President' },
|
||||
{ value: 'treasurer', label: 'Treasurer' },
|
||||
{ value: 'secretary', label: 'Secretary' },
|
||||
{ value: 'member_at_large', label: 'Member at Large' },
|
||||
{ value: 'manager', label: 'Manager' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
]}
|
||||
{...inviteForm.getInputProps('role')}
|
||||
/>
|
||||
<Group>
|
||||
<Button type="submit" loading={markStep.isPending}>Send Invite & Continue</Button>
|
||||
<Button variant="subtle" onClick={() => markStep.mutate('invite_member')}>
|
||||
Skip for now
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
|
||||
{/* Step 4: First Account */}
|
||||
<Stepper.Step
|
||||
label={STEPS[3].label}
|
||||
description={STEPS[3].description}
|
||||
icon={completedSteps.includes('first_workflow') ? <IconCheck size={16} /> : <IconListDetails size={16} />}
|
||||
completedIcon={<IconCheck size={16} />}
|
||||
color={completedSteps.includes('first_workflow') ? 'green' : undefined}
|
||||
>
|
||||
<Card withBorder p="lg" mt="sm">
|
||||
<Stack>
|
||||
<Text size="sm">
|
||||
Your chart of accounts has been pre-configured with standard HOA accounts.
|
||||
You can review and customize them now, or do it later.
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<IconListDetails size={16} />}
|
||||
onClick={() => {
|
||||
markStep.mutate('first_workflow');
|
||||
navigate('/accounts');
|
||||
}}
|
||||
>
|
||||
Review Accounts
|
||||
</Button>
|
||||
<Button variant="subtle" onClick={() => markStep.mutate('first_workflow')}>
|
||||
Use defaults & Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
|
||||
<Group justify="center" mt="md">
|
||||
<Button variant="subtle" color="gray" onClick={() => navigate('/dashboard')}>
|
||||
Finish Later
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
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