- Add global WriteAccessGuard that blocks POST/PUT/PATCH/DELETE for viewer role - Add @AllowViewer() decorator for endpoints viewers need (switch-org, intro-seen, AI recommendations) - Add useIsReadOnly hook to auth store for frontend role checks - Hide write UI (add/edit/delete/import buttons, inline editors) in all 13 data pages for viewers - Disable inline NumberInputs on Budgets and Monthly Actuals pages for viewers - Skip onboarding wizard for viewer role users Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Title, Text, Card, Stack, Group, Table, Badge, Button, Modal,
|
|
TextInput, Select, ActionIcon, Tooltip, Alert, SimpleGrid,
|
|
ThemeIcon, Loader, Center,
|
|
} from '@mantine/core';
|
|
import { useForm } from '@mantine/form';
|
|
import { useDisclosure } from '@mantine/hooks';
|
|
import { notifications } from '@mantine/notifications';
|
|
import {
|
|
IconPlus, IconEdit, IconTrash, IconUserPlus, IconUsers,
|
|
IconShieldCheck, IconInfoCircle,
|
|
} from '@tabler/icons-react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import api from '../../services/api';
|
|
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
|
|
|
interface OrgMember {
|
|
id: string;
|
|
userId: string;
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
role: string;
|
|
isActive: boolean;
|
|
joinedAt: string;
|
|
lastLoginAt: string | null;
|
|
}
|
|
|
|
const ROLE_OPTIONS = [
|
|
{ value: 'president', label: 'President' },
|
|
{ value: 'treasurer', label: 'Treasurer' },
|
|
{ value: 'secretary', label: 'Secretary' },
|
|
{ value: 'board_member', label: 'Board Member' },
|
|
{ value: 'property_manager', label: 'Property Manager' },
|
|
{ value: 'viewer', label: 'Viewer (Read-Only)' },
|
|
];
|
|
|
|
const roleColors: Record<string, string> = {
|
|
president: 'red',
|
|
treasurer: 'blue',
|
|
secretary: 'green',
|
|
board_member: 'violet',
|
|
property_manager: 'orange',
|
|
viewer: 'gray',
|
|
admin: 'red',
|
|
};
|
|
|
|
export function OrgMembersPage() {
|
|
const [addOpened, { open: openAdd, close: closeAdd }] = useDisclosure(false);
|
|
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false);
|
|
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
|
|
const queryClient = useQueryClient();
|
|
const { user, currentOrg } = useAuthStore();
|
|
const isReadOnly = useIsReadOnly();
|
|
|
|
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
|
|
queryKey: ['org-members'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/organizations/members');
|
|
return data;
|
|
},
|
|
});
|
|
|
|
const addForm = useForm({
|
|
initialValues: {
|
|
email: '',
|
|
firstName: '',
|
|
lastName: '',
|
|
password: '',
|
|
role: 'board_member',
|
|
},
|
|
validate: {
|
|
email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Valid email required'),
|
|
firstName: (v) => (v.length > 0 ? null : 'Required'),
|
|
lastName: (v) => (v.length > 0 ? null : 'Required'),
|
|
password: (v) => (v.length >= 6 ? null : 'Min 6 characters'),
|
|
},
|
|
});
|
|
|
|
const editForm = useForm({
|
|
initialValues: {
|
|
role: 'board_member',
|
|
},
|
|
});
|
|
|
|
const addMemberMutation = useMutation({
|
|
mutationFn: (values: typeof addForm.values) =>
|
|
api.post('/organizations/members', values),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['org-members'] });
|
|
notifications.show({ message: 'Member added successfully', color: 'green' });
|
|
closeAdd();
|
|
addForm.reset();
|
|
},
|
|
onError: (err: any) => {
|
|
notifications.show({
|
|
message: err.response?.data?.message || 'Failed to add member',
|
|
color: 'red',
|
|
});
|
|
},
|
|
});
|
|
|
|
const updateRoleMutation = useMutation({
|
|
mutationFn: ({ membershipId, role }: { membershipId: string; role: string }) =>
|
|
api.put(`/organizations/members/${membershipId}/role`, { role }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['org-members'] });
|
|
notifications.show({ message: 'Role updated', color: 'green' });
|
|
closeEdit();
|
|
setEditingMember(null);
|
|
},
|
|
onError: (err: any) => {
|
|
notifications.show({
|
|
message: err.response?.data?.message || 'Failed to update role',
|
|
color: 'red',
|
|
});
|
|
},
|
|
});
|
|
|
|
const removeMemberMutation = useMutation({
|
|
mutationFn: (membershipId: string) =>
|
|
api.delete(`/organizations/members/${membershipId}`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['org-members'] });
|
|
notifications.show({ message: 'Member removed', color: 'green' });
|
|
},
|
|
onError: (err: any) => {
|
|
notifications.show({
|
|
message: err.response?.data?.message || 'Failed to remove member',
|
|
color: 'red',
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleEditRole = (member: OrgMember) => {
|
|
setEditingMember(member);
|
|
editForm.setValues({ role: member.role });
|
|
openEdit();
|
|
};
|
|
|
|
const handleRemove = (member: OrgMember) => {
|
|
if (member.userId === user?.id) {
|
|
notifications.show({ message: 'You cannot remove yourself', color: 'red' });
|
|
return;
|
|
}
|
|
if (window.confirm(`Remove ${member.firstName} ${member.lastName} (${member.email}) from this organization?`)) {
|
|
removeMemberMutation.mutate(member.id);
|
|
}
|
|
};
|
|
|
|
const activeMembers = members.filter((m) => m.isActive);
|
|
const inactiveMembers = members.filter((m) => !m.isActive);
|
|
|
|
if (isLoading) {
|
|
return <Center h={300}><Loader /></Center>;
|
|
}
|
|
|
|
return (
|
|
<Stack>
|
|
<Group justify="space-between">
|
|
<div>
|
|
<Title order={2}>Organization Members</Title>
|
|
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
|
|
</div>
|
|
{!isReadOnly && (
|
|
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
|
|
Add Member
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
|
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
|
<Card withBorder p="xs">
|
|
<Group>
|
|
<ThemeIcon color="blue" variant="light" size={36} radius="md">
|
|
<IconUsers size={20} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text size="xs" c="dimmed">Total Members</Text>
|
|
<Text fw={700}>{activeMembers.length}</Text>
|
|
</div>
|
|
</Group>
|
|
</Card>
|
|
<Card withBorder p="xs">
|
|
<Group>
|
|
<ThemeIcon color="green" variant="light" size={36} radius="md">
|
|
<IconShieldCheck size={20} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text size="xs" c="dimmed">Board Members</Text>
|
|
<Text fw={700}>
|
|
{activeMembers.filter((m) =>
|
|
['president', 'treasurer', 'secretary', 'board_member'].includes(m.role),
|
|
).length}
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
</Card>
|
|
<Card withBorder p="xs">
|
|
<Group>
|
|
<ThemeIcon color="violet" variant="light" size={36} radius="md">
|
|
<IconInfoCircle size={20} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text size="xs" c="dimmed">Your Role</Text>
|
|
<Text fw={700} tt="capitalize">{currentOrg?.role || 'N/A'}</Text>
|
|
</div>
|
|
</Group>
|
|
</Card>
|
|
</SimpleGrid>
|
|
|
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
|
As an organization administrator, you can add board members, property managers, and
|
|
viewers to give them access to this tenant. Each member can log in with their own
|
|
credentials and see the same financial data.
|
|
</Alert>
|
|
|
|
<Table striped highlightOnHover>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Name</Table.Th>
|
|
<Table.Th>Email</Table.Th>
|
|
<Table.Th>Role</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
<Table.Th>Joined</Table.Th>
|
|
<Table.Th>Last Login</Table.Th>
|
|
<Table.Th></Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{activeMembers.length === 0 && (
|
|
<Table.Tr>
|
|
<Table.Td colSpan={7}>
|
|
<Text ta="center" c="dimmed" py="lg">
|
|
No members yet. Add your first board member above.
|
|
</Text>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
)}
|
|
{activeMembers.map((member) => (
|
|
<Table.Tr key={member.id}>
|
|
<Table.Td fw={500}>
|
|
{member.firstName} {member.lastName}
|
|
{member.userId === user?.id && (
|
|
<Badge size="xs" variant="light" ml={6}>You</Badge>
|
|
)}
|
|
</Table.Td>
|
|
<Table.Td>{member.email}</Table.Td>
|
|
<Table.Td>
|
|
<Badge color={roleColors[member.role] || 'gray'} variant="light" size="sm" tt="capitalize">
|
|
{member.role.replace(/_/g, ' ')}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge color="green" variant="light" size="sm">Active</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{member.joinedAt ? new Date(member.joinedAt).toLocaleDateString() : '-'}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{!isReadOnly && (
|
|
<Group gap={4}>
|
|
<Tooltip label="Change role">
|
|
<ActionIcon variant="subtle" onClick={() => handleEditRole(member)}>
|
|
<IconEdit size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
{member.userId !== user?.id && (
|
|
<Tooltip label="Remove member">
|
|
<ActionIcon variant="subtle" color="red" onClick={() => handleRemove(member)}>
|
|
<IconTrash size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
</Group>
|
|
)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
{inactiveMembers.map((member) => (
|
|
<Table.Tr key={member.id} style={{ opacity: 0.5 }}>
|
|
<Table.Td fw={500}>{member.firstName} {member.lastName}</Table.Td>
|
|
<Table.Td>{member.email}</Table.Td>
|
|
<Table.Td>
|
|
<Badge color={roleColors[member.role] || 'gray'} variant="light" size="sm" tt="capitalize">
|
|
{member.role.replace(/_/g, ' ')}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge color="gray" variant="light" size="sm">Inactive</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{member.joinedAt ? new Date(member.joinedAt).toLocaleDateString() : '-'}
|
|
</Table.Td>
|
|
<Table.Td>-</Table.Td>
|
|
<Table.Td></Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
|
|
{/* Add Member Modal */}
|
|
<Modal opened={addOpened} onClose={closeAdd} title="Add Organization Member" size="md" closeOnClickOutside={false}>
|
|
<form onSubmit={addForm.onSubmit((values) => addMemberMutation.mutate(values))}>
|
|
<Stack>
|
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light" mb="xs">
|
|
This will create a new user account (or link an existing one) and add them to
|
|
your organization. They will be able to log in immediately.
|
|
</Alert>
|
|
<TextInput
|
|
label="Email Address"
|
|
placeholder="boardmember@example.com"
|
|
required
|
|
{...addForm.getInputProps('email')}
|
|
/>
|
|
<Group grow>
|
|
<TextInput
|
|
label="First Name"
|
|
placeholder="John"
|
|
required
|
|
{...addForm.getInputProps('firstName')}
|
|
/>
|
|
<TextInput
|
|
label="Last Name"
|
|
placeholder="Smith"
|
|
required
|
|
{...addForm.getInputProps('lastName')}
|
|
/>
|
|
</Group>
|
|
<TextInput
|
|
label="Temporary Password"
|
|
placeholder="Min 6 characters"
|
|
description="The user can change this after first login"
|
|
required
|
|
{...addForm.getInputProps('password')}
|
|
/>
|
|
<Select
|
|
label="Role"
|
|
required
|
|
data={ROLE_OPTIONS}
|
|
{...addForm.getInputProps('role')}
|
|
/>
|
|
<Button type="submit" leftSection={<IconPlus size={16} />} loading={addMemberMutation.isPending}>
|
|
Add Member
|
|
</Button>
|
|
</Stack>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Edit Role Modal */}
|
|
<Modal opened={editOpened} onClose={closeEdit} title="Change Member Role" size="sm" closeOnClickOutside={false}>
|
|
{editingMember && (
|
|
<form
|
|
onSubmit={editForm.onSubmit((values) =>
|
|
updateRoleMutation.mutate({ membershipId: editingMember.id, role: values.role }),
|
|
)}
|
|
>
|
|
<Stack>
|
|
<Text size="sm" c="dimmed">
|
|
Changing role for <strong>{editingMember.firstName} {editingMember.lastName}</strong>
|
|
</Text>
|
|
<Select
|
|
label="New Role"
|
|
required
|
|
data={ROLE_OPTIONS}
|
|
{...editForm.getInputProps('role')}
|
|
/>
|
|
<Button type="submit" loading={updateRoleMutation.isPending}>
|
|
Update Role
|
|
</Button>
|
|
</Stack>
|
|
</form>
|
|
)}
|
|
</Modal>
|
|
</Stack>
|
|
);
|
|
}
|