- C1: Disable Swagger UI in production (env gate) - M1+M2: Add Helmet.js for security headers (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) and remove X-Powered-By - H2: Add @nestjs/throttler rate limiting (5 req/min on login/register) - M4: Remove orgSchema from JWT payload and client-side storage; tenant middleware now resolves schema from orgId via cached DB lookup - L1: Fix Chatwoot user identification (read from auth store on ready) - Remove schemaName from frontend Organization type and UI displays Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
174 lines
5.4 KiB
TypeScript
174 lines
5.4 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Container,
|
|
Paper,
|
|
Title,
|
|
Text,
|
|
Stack,
|
|
Button,
|
|
Card,
|
|
Group,
|
|
Badge,
|
|
TextInput,
|
|
Modal,
|
|
Alert,
|
|
} from '@mantine/core';
|
|
import { useForm } from '@mantine/form';
|
|
import { useDisclosure } from '@mantine/hooks';
|
|
import { IconBuilding, IconPlus, IconAlertCircle } from '@tabler/icons-react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import api from '../../services/api';
|
|
import { useAuthStore } from '../../stores/authStore';
|
|
|
|
export function SelectOrgPage() {
|
|
const { organizations, setCurrentOrg, logout } = useAuthStore();
|
|
const navigate = useNavigate();
|
|
const [opened, { open, close }] = useDisclosure(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
// If no organizations in store (stale session), redirect to login
|
|
if (!organizations || organizations.length === 0) {
|
|
return (
|
|
<Container size={500} my={80}>
|
|
<Title ta="center" order={2}>No Organizations</Title>
|
|
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
|
Please log in again to refresh your session.
|
|
</Text>
|
|
<Button fullWidth mt="lg" onClick={() => { logout(); navigate('/login'); }}>
|
|
Go to Login
|
|
</Button>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
const form = useForm({
|
|
initialValues: { name: '', addressLine1: '', city: '', state: '', zipCode: '' },
|
|
validate: {
|
|
name: (v) => (v.length >= 2 ? null : 'Name required'),
|
|
},
|
|
});
|
|
|
|
// Filter out suspended/archived organizations (defense in depth)
|
|
const activeOrganizations = (organizations || []).filter(
|
|
(org: any) => !org.status || !['suspended', 'archived'].includes(org.status),
|
|
);
|
|
|
|
const handleSelect = async (org: any) => {
|
|
try {
|
|
const { data } = await api.post('/auth/switch-org', {
|
|
organizationId: org.id,
|
|
});
|
|
setCurrentOrg(data.organization, data.accessToken);
|
|
navigate('/dashboard');
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.message || 'Failed to switch organization. Please try logging in again.');
|
|
}
|
|
};
|
|
|
|
const handleCreateOrg = async (values: typeof form.values) => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const { data } = await api.post('/organizations', values);
|
|
// Switch to the new org
|
|
const switchRes = await api.post('/auth/switch-org', {
|
|
organizationId: data.id,
|
|
});
|
|
setCurrentOrg(
|
|
{ id: data.id, name: data.name, role: 'president' },
|
|
switchRes.data.accessToken,
|
|
);
|
|
close();
|
|
navigate('/');
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.message || 'Failed to create organization');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Container size={500} my={80}>
|
|
<Title ta="center" order={2}>Select Organization</Title>
|
|
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
|
Choose an HOA to manage or create a new one
|
|
</Text>
|
|
|
|
{/* Filter out suspended/archived orgs (defense in depth — backend also filters) */}
|
|
{organizations.length > activeOrganizations.length && (
|
|
<Alert icon={<IconAlertCircle size={16} />} color="yellow" variant="light" mt="md">
|
|
Some organizations are currently suspended or archived and are not shown.
|
|
</Alert>
|
|
)}
|
|
|
|
<Stack mt={organizations.length > activeOrganizations.length ? 'sm' : 30}>
|
|
{activeOrganizations.map((org) => (
|
|
<Card
|
|
key={org.id}
|
|
shadow="sm"
|
|
padding="lg"
|
|
radius="md"
|
|
withBorder
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={() => handleSelect(org)}
|
|
>
|
|
<Group justify="space-between">
|
|
<Group>
|
|
<IconBuilding size={24} />
|
|
<div>
|
|
<Text fw={500}>{org.name}</Text>
|
|
<Group gap={4}>
|
|
<Badge size="sm" variant="light">{org.role}</Badge>
|
|
</Group>
|
|
</div>
|
|
</Group>
|
|
<Button variant="light" size="xs">Select</Button>
|
|
</Group>
|
|
</Card>
|
|
))}
|
|
|
|
<Button
|
|
variant="outline"
|
|
leftSection={<IconPlus size={16} />}
|
|
onClick={open}
|
|
fullWidth
|
|
>
|
|
Create New HOA
|
|
</Button>
|
|
</Stack>
|
|
|
|
<Modal opened={opened} onClose={close} title="Create New HOA">
|
|
<form onSubmit={form.onSubmit(handleCreateOrg)}>
|
|
<Stack>
|
|
{error && (
|
|
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
<TextInput
|
|
label="HOA Name"
|
|
placeholder="Sunrise Valley HOA"
|
|
required
|
|
{...form.getInputProps('name')}
|
|
/>
|
|
<TextInput
|
|
label="Address"
|
|
placeholder="123 Main St"
|
|
{...form.getInputProps('addressLine1')}
|
|
/>
|
|
<Group grow>
|
|
<TextInput label="City" placeholder="Springfield" {...form.getInputProps('city')} />
|
|
<TextInput label="State" placeholder="IL" {...form.getInputProps('state')} />
|
|
<TextInput label="ZIP" placeholder="62701" {...form.getInputProps('zipCode')} />
|
|
</Group>
|
|
<Button type="submit" fullWidth loading={loading}>
|
|
Create Organization
|
|
</Button>
|
|
</Stack>
|
|
</form>
|
|
</Modal>
|
|
</Container>
|
|
);
|
|
}
|