- Fix "Manage Billing" button error for trial orgs without Stripe customer; add fallback to retrieve customer from subscription, show helpful message for trial users, and surface real error messages in the UI - Add "Balance As-Of Date" field to onboarding wizard so opening balance journal entries use the correct statement date instead of today - Add "Total Unit Count" field to onboarding wizard assessment group step so cash flow projections work immediately - Remove broken budget upload step from onboarding wizard (was using legacy budgets endpoint); replace with guidance to use Budget Planning page - Replace bare "No budget plan lines" text with rich onboarding-style card featuring download template and upload CSV action buttons Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import {
|
|
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
|
|
Tabs, Button, Switch, Loader,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconBuilding, IconUser, IconSettings, IconShieldLock,
|
|
IconFingerprint, IconLink, IconLogout, IconCreditCard,
|
|
} from '@tabler/icons-react';
|
|
import { notifications } from '@mantine/notifications';
|
|
import { useAuthStore } from '../../stores/authStore';
|
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
import { MfaSettings } from './MfaSettings';
|
|
import { PasskeySettings } from './PasskeySettings';
|
|
import { LinkedAccounts } from './LinkedAccounts';
|
|
import api from '../../services/api';
|
|
|
|
interface SubscriptionInfo {
|
|
plan: string;
|
|
planName: string;
|
|
billingInterval: string;
|
|
status: string;
|
|
collectionMethod: string;
|
|
trialEndsAt: string | null;
|
|
currentPeriodEnd: string | null;
|
|
cancelAtPeriodEnd: boolean;
|
|
hasStripeCustomer: boolean;
|
|
}
|
|
|
|
const statusColors: Record<string, string> = {
|
|
active: 'green',
|
|
trial: 'blue',
|
|
past_due: 'orange',
|
|
archived: 'red',
|
|
suspended: 'red',
|
|
};
|
|
|
|
export function SettingsPage() {
|
|
const { user, currentOrg } = useAuthStore();
|
|
const { compactView, toggleCompactView } = usePreferencesStore();
|
|
const [loggingOutAll, setLoggingOutAll] = useState(false);
|
|
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
|
|
const [subLoading, setSubLoading] = useState(true);
|
|
const [portalLoading, setPortalLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
api.get('/billing/subscription')
|
|
.then(({ data }) => setSubscription(data))
|
|
.catch(() => { /* billing not configured or no subscription */ })
|
|
.finally(() => setSubLoading(false));
|
|
}, []);
|
|
|
|
const handleLogoutEverywhere = async () => {
|
|
setLoggingOutAll(true);
|
|
try {
|
|
await api.post('/auth/logout-everywhere');
|
|
notifications.show({ message: 'All other sessions have been logged out', color: 'green' });
|
|
} catch {
|
|
notifications.show({ message: 'Failed to log out other sessions', color: 'red' });
|
|
} finally {
|
|
setLoggingOutAll(false);
|
|
}
|
|
};
|
|
|
|
const handleManageBilling = async () => {
|
|
setPortalLoading(true);
|
|
try {
|
|
const { data } = await api.post('/billing/portal');
|
|
if (data.url) {
|
|
window.location.href = data.url;
|
|
}
|
|
} catch (err: any) {
|
|
const msg = err.response?.data?.message || 'Unable to open billing portal';
|
|
notifications.show({ message: typeof msg === 'string' ? msg : 'Unable to open billing portal', color: 'red' });
|
|
} finally {
|
|
setPortalLoading(false);
|
|
}
|
|
};
|
|
|
|
const formatInterval = (interval: string) => {
|
|
return interval === 'year' ? 'Annual' : 'Monthly';
|
|
};
|
|
|
|
const formatDate = (iso: string | null) => {
|
|
if (!iso) return null;
|
|
return new Date(iso).toLocaleDateString('en-US', {
|
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Stack>
|
|
<div>
|
|
<Title order={2}>Settings</Title>
|
|
<Text c="dimmed" size="sm">Organization and account settings</Text>
|
|
</div>
|
|
|
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
|
{/* Organization Info */}
|
|
<Card withBorder padding="lg">
|
|
<Group mb="md">
|
|
<ThemeIcon color="blue" variant="light" size={40} radius="md">
|
|
<IconBuilding size={24} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text fw={600} size="lg">Organization</Text>
|
|
<Text c="dimmed" size="sm">Current organization details</Text>
|
|
</div>
|
|
</Group>
|
|
<Stack gap="xs">
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Name</Text>
|
|
<Text size="sm" fw={500}>{currentOrg?.name || 'N/A'}</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Your Role</Text>
|
|
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
|
|
</Group>
|
|
</Stack>
|
|
</Card>
|
|
|
|
{/* Billing / Subscription */}
|
|
<Card withBorder padding="lg">
|
|
<Group mb="md">
|
|
<ThemeIcon color="teal" variant="light" size={40} radius="md">
|
|
<IconCreditCard size={24} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text fw={600} size="lg">Billing</Text>
|
|
<Text c="dimmed" size="sm">Subscription and payment</Text>
|
|
</div>
|
|
</Group>
|
|
{subLoading ? (
|
|
<Group justify="center" py="md"><Loader size="sm" /></Group>
|
|
) : subscription ? (
|
|
<Stack gap="xs">
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Plan</Text>
|
|
<Group gap={4}>
|
|
<Badge variant="light">{subscription.planName}</Badge>
|
|
<Badge variant="light" color="gray" size="sm">{formatInterval(subscription.billingInterval)}</Badge>
|
|
</Group>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Status</Text>
|
|
<Badge
|
|
color={statusColors[subscription.status] || 'gray'}
|
|
variant="light"
|
|
>
|
|
{subscription.status === 'past_due' ? 'Past Due' : subscription.status}
|
|
{subscription.cancelAtPeriodEnd ? ' (Canceling)' : ''}
|
|
</Badge>
|
|
</Group>
|
|
{subscription.trialEndsAt && subscription.status === 'trial' && (
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Trial Ends</Text>
|
|
<Text size="sm" fw={500}>{formatDate(subscription.trialEndsAt)}</Text>
|
|
</Group>
|
|
)}
|
|
{subscription.currentPeriodEnd && subscription.status !== 'trial' && (
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Current Period Ends</Text>
|
|
<Text size="sm" fw={500}>{formatDate(subscription.currentPeriodEnd)}</Text>
|
|
</Group>
|
|
)}
|
|
{subscription.collectionMethod === 'send_invoice' && (
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Payment</Text>
|
|
<Badge variant="light" color="cyan" size="sm">Invoice / ACH</Badge>
|
|
</Group>
|
|
)}
|
|
{subscription.hasStripeCustomer ? (
|
|
<Button
|
|
variant="light"
|
|
color="teal"
|
|
size="sm"
|
|
leftSection={<IconCreditCard size={16} />}
|
|
onClick={handleManageBilling}
|
|
loading={portalLoading}
|
|
mt="xs"
|
|
>
|
|
Manage Billing
|
|
</Button>
|
|
) : subscription.status === 'trial' ? (
|
|
<Text size="xs" c="dimmed" mt="xs">
|
|
Billing portal will be available once you add a payment method.
|
|
</Text>
|
|
) : null}
|
|
</Stack>
|
|
) : (
|
|
<Text size="sm" c="dimmed">No active subscription</Text>
|
|
)}
|
|
</Card>
|
|
|
|
{/* User Profile */}
|
|
<Card withBorder padding="lg">
|
|
<Group mb="md">
|
|
<ThemeIcon color="green" variant="light" size={40} radius="md">
|
|
<IconUser size={24} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text fw={600} size="lg">Your Profile</Text>
|
|
<Text c="dimmed" size="sm">Account information</Text>
|
|
</div>
|
|
</Group>
|
|
<Stack gap="xs">
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Name</Text>
|
|
<Text size="sm" fw={500}>{user?.firstName} {user?.lastName}</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Email</Text>
|
|
<Text size="sm" fw={500}>{user?.email}</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">User ID</Text>
|
|
<Text size="sm" ff="monospace" c="dimmed">{user?.id?.slice(0, 8)}...</Text>
|
|
</Group>
|
|
</Stack>
|
|
</Card>
|
|
|
|
{/* System Info */}
|
|
<Card withBorder padding="lg">
|
|
<Group mb="md">
|
|
<ThemeIcon color="violet" variant="light" size={40} radius="md">
|
|
<IconSettings size={24} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text fw={600} size="lg">System</Text>
|
|
<Text c="dimmed" size="sm">Platform information</Text>
|
|
</div>
|
|
</Group>
|
|
<Stack gap="xs">
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Platform</Text>
|
|
<Text size="sm" fw={500}>HOA LedgerIQ</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Version</Text>
|
|
<Badge variant="light">2026.03.18</Badge>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">API</Text>
|
|
<Text size="sm" ff="monospace" c="dimmed">/api/docs</Text>
|
|
</Group>
|
|
<Divider />
|
|
<Group justify="space-between">
|
|
<div>
|
|
<Text size="sm">Compact View</Text>
|
|
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
|
|
</div>
|
|
<Switch checked={compactView} onChange={toggleCompactView} />
|
|
</Group>
|
|
</Stack>
|
|
</Card>
|
|
|
|
{/* Sessions */}
|
|
<Card withBorder padding="lg">
|
|
<Group mb="md">
|
|
<ThemeIcon color="orange" variant="light" size={40} radius="md">
|
|
<IconLogout size={24} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text fw={600} size="lg">Sessions</Text>
|
|
<Text c="dimmed" size="sm">Manage active sessions</Text>
|
|
</div>
|
|
</Group>
|
|
<Stack gap="xs">
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Current Session</Text>
|
|
<Badge color="green" variant="light">Active</Badge>
|
|
</Group>
|
|
<Button
|
|
variant="light"
|
|
color="orange"
|
|
size="sm"
|
|
leftSection={<IconLogout size={16} />}
|
|
onClick={handleLogoutEverywhere}
|
|
loading={loggingOutAll}
|
|
mt="xs"
|
|
>
|
|
Log Out All Other Sessions
|
|
</Button>
|
|
</Stack>
|
|
</Card>
|
|
</SimpleGrid>
|
|
|
|
<Divider my="md" />
|
|
|
|
{/* Security Settings */}
|
|
<div>
|
|
<Title order={3} mb="sm">Security</Title>
|
|
<Text c="dimmed" size="sm" mb="md">Manage authentication methods and security settings</Text>
|
|
</div>
|
|
|
|
<Tabs defaultValue="mfa">
|
|
<Tabs.List>
|
|
<Tabs.Tab value="mfa" leftSection={<IconShieldLock size={16} />}>
|
|
Two-Factor Auth
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="passkeys" leftSection={<IconFingerprint size={16} />}>
|
|
Passkeys
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="linked" leftSection={<IconLink size={16} />}>
|
|
Linked Accounts
|
|
</Tabs.Tab>
|
|
</Tabs.List>
|
|
|
|
<Tabs.Panel value="mfa" pt="md">
|
|
<MfaSettings />
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="passkeys" pt="md">
|
|
<PasskeySettings />
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="linked" pt="md">
|
|
<LinkedAccounts />
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
</Stack>
|
|
);
|
|
}
|