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>
242 lines
9.5 KiB
TypeScript
242 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|