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>
180 lines
5.3 KiB
TypeScript
180 lines
5.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
Container, Paper, Title, Text, TextInput, PasswordInput,
|
|
Button, Stack, Alert, Center, Loader, Progress, Anchor,
|
|
} from '@mantine/core';
|
|
import { useForm } from '@mantine/form';
|
|
import { IconAlertCircle, IconCheck, IconShieldCheck } from '@tabler/icons-react';
|
|
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
|
|
import api from '../../services/api';
|
|
import { useAuthStore } from '../../stores/authStore';
|
|
import logoSrc from '../../assets/logo.png';
|
|
|
|
function getPasswordStrength(pw: string): number {
|
|
let score = 0;
|
|
if (pw.length >= 8) score += 25;
|
|
if (pw.length >= 12) score += 15;
|
|
if (/[A-Z]/.test(pw)) score += 20;
|
|
if (/[a-z]/.test(pw)) score += 10;
|
|
if (/[0-9]/.test(pw)) score += 15;
|
|
if (/[^A-Za-z0-9]/.test(pw)) score += 15;
|
|
return Math.min(score, 100);
|
|
}
|
|
|
|
function strengthColor(s: number): string {
|
|
if (s < 40) return 'red';
|
|
if (s < 70) return 'orange';
|
|
return 'green';
|
|
}
|
|
|
|
export function ActivatePage() {
|
|
const [searchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
const setAuth = useAuthStore((s) => s.setAuth);
|
|
const token = searchParams.get('token');
|
|
|
|
const [validating, setValidating] = useState(true);
|
|
const [tokenInfo, setTokenInfo] = useState<any>(null);
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const form = useForm({
|
|
initialValues: { fullName: '', password: '', confirmPassword: '' },
|
|
validate: {
|
|
fullName: (v) => (v.trim().length >= 2 ? null : 'Name is required'),
|
|
password: (v) => (v.length >= 8 ? null : 'Password must be at least 8 characters'),
|
|
confirmPassword: (v, values) => (v === values.password ? null : 'Passwords do not match'),
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
setError('No activation token provided');
|
|
setValidating(false);
|
|
return;
|
|
}
|
|
|
|
api.get(`/auth/activate?token=${token}`)
|
|
.then(({ data }) => {
|
|
setTokenInfo(data);
|
|
setValidating(false);
|
|
})
|
|
.catch((err) => {
|
|
setError(err.response?.data?.message || 'Invalid or expired activation link');
|
|
setValidating(false);
|
|
});
|
|
}, [token]);
|
|
|
|
const handleSubmit = async (values: typeof form.values) => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const { data } = await api.post('/auth/activate', {
|
|
token,
|
|
password: values.password,
|
|
fullName: values.fullName,
|
|
});
|
|
setAuth(data.accessToken, data.user, data.organizations);
|
|
navigate('/onboarding');
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.message || 'Activation failed');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const passwordStrength = getPasswordStrength(form.values.password);
|
|
|
|
if (validating) {
|
|
return (
|
|
<Container size={420} my={80}>
|
|
<Center><Loader size="lg" /></Center>
|
|
<Text ta="center" mt="md" c="dimmed">Validating activation link...</Text>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
if (error && !tokenInfo) {
|
|
return (
|
|
<Container size={420} my={80}>
|
|
<Center>
|
|
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
|
|
</Center>
|
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
|
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light" mb="md">
|
|
{error}
|
|
</Alert>
|
|
<Stack>
|
|
<Anchor component={Link} to="/login" size="sm" ta="center">
|
|
Go to Login
|
|
</Anchor>
|
|
</Stack>
|
|
</Paper>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Container size={420} my={80}>
|
|
<Center>
|
|
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
|
|
</Center>
|
|
<Text ta="center" mt={5} c="dimmed" size="sm">
|
|
Activate your account for <strong>{tokenInfo?.orgName || 'your organization'}</strong>
|
|
</Text>
|
|
|
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
<Stack>
|
|
{error && (
|
|
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
<TextInput
|
|
label="Full Name"
|
|
placeholder="John Doe"
|
|
required
|
|
{...form.getInputProps('fullName')}
|
|
/>
|
|
|
|
<div>
|
|
<PasswordInput
|
|
label="Password"
|
|
placeholder="Create a strong password"
|
|
required
|
|
{...form.getInputProps('password')}
|
|
/>
|
|
{form.values.password && (
|
|
<Progress
|
|
value={passwordStrength}
|
|
color={strengthColor(passwordStrength)}
|
|
size="xs"
|
|
mt={4}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<PasswordInput
|
|
label="Confirm Password"
|
|
placeholder="Confirm your password"
|
|
required
|
|
{...form.getInputProps('confirmPassword')}
|
|
/>
|
|
|
|
<Button
|
|
type="submit"
|
|
fullWidth
|
|
loading={loading}
|
|
leftSection={<IconShieldCheck size={16} />}
|
|
>
|
|
Activate Account
|
|
</Button>
|
|
</Stack>
|
|
</form>
|
|
</Paper>
|
|
</Container>
|
|
);
|
|
}
|