diff --git a/backend/package.json b/backend/package.json index a48036d..ac475d7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-backend", - "version": "0.1.0", + "version": "0.2.0", "description": "HOA LedgerIQ - Backend API", "private": true, "scripts": { diff --git a/backend/src/modules/organizations/organizations.controller.ts b/backend/src/modules/organizations/organizations.controller.ts index 16e5f68..30d7916 100644 --- a/backend/src/modules/organizations/organizations.controller.ts +++ b/backend/src/modules/organizations/organizations.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Get, Body, UseGuards, Request } from '@nestjs/common'; +import { Controller, Post, Get, Put, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { OrganizationsService } from './organizations.service'; import { CreateOrganizationDto } from './dto/create-organization.dto'; @@ -22,4 +22,48 @@ export class OrganizationsController { async findMine(@Request() req: any) { return this.orgService.findByUser(req.user.sub); } + + // ── Org Member Management ── + + private requireTenantAdmin(req: any) { + const role = req.user.role; + if (!['president', 'admin', 'treasurer'].includes(role) && !req.user.isSuperadmin) { + throw new ForbiddenException('Only organization administrators can manage members'); + } + } + + @Get('members') + @ApiOperation({ summary: 'List members of current organization' }) + async getMembers(@Request() req: any) { + this.requireTenantAdmin(req); + return this.orgService.getMembers(req.user.orgId); + } + + @Post('members') + @ApiOperation({ summary: 'Add a member to the current organization' }) + async addMember( + @Request() req: any, + @Body() body: { email: string; firstName: string; lastName: string; password: string; role: string }, + ) { + this.requireTenantAdmin(req); + return this.orgService.addMember(req.user.orgId, body); + } + + @Put('members/:id/role') + @ApiOperation({ summary: 'Update a member role' }) + async updateMemberRole( + @Request() req: any, + @Param('id') id: string, + @Body() body: { role: string }, + ) { + this.requireTenantAdmin(req); + return this.orgService.updateMemberRole(req.user.orgId, id, body.role); + } + + @Delete('members/:id') + @ApiOperation({ summary: 'Remove a member from the organization' }) + async removeMember(@Request() req: any, @Param('id') id: string) { + this.requireTenantAdmin(req); + return this.orgService.removeMember(req.user.orgId, id, req.user.sub); + } } diff --git a/backend/src/modules/organizations/organizations.service.ts b/backend/src/modules/organizations/organizations.service.ts index 116b643..7756492 100644 --- a/backend/src/modules/organizations/organizations.service.ts +++ b/backend/src/modules/organizations/organizations.service.ts @@ -1,10 +1,11 @@ -import { Injectable, ConflictException } from '@nestjs/common'; +import { Injectable, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Organization } from './entities/organization.entity'; import { UserOrganization } from './entities/user-organization.entity'; import { TenantSchemaService } from '../../database/tenant-schema.service'; import { CreateOrganizationDto } from './dto/create-organization.dto'; +import * as bcrypt from 'bcrypt'; @Injectable() export class OrganizationsService { @@ -76,6 +77,105 @@ export class OrganizationsService { return this.orgRepository.findOne({ where: { id } }); } + // ── Org Member Management ── + + async getMembers(orgId: string) { + const dataSource = this.orgRepository.manager.connection; + const rows = await dataSource.query( + `SELECT + uo.id, + uo.user_id as "userId", + u.email, + u.first_name as "firstName", + u.last_name as "lastName", + uo.role, + uo.is_active as "isActive", + uo.joined_at as "joinedAt", + u.last_login_at as "lastLoginAt" + FROM shared.user_organizations uo + JOIN shared.users u ON u.id = uo.user_id + WHERE uo.organization_id = $1 + ORDER BY uo.joined_at ASC`, + [orgId], + ); + return rows; + } + + async addMember( + orgId: string, + data: { email: string; firstName: string; lastName: string; password: string; role: string }, + ) { + const dataSource = this.orgRepository.manager.connection; + + // Check if user already exists + let userRows = await dataSource.query( + `SELECT id FROM shared.users WHERE email = $1`, + [data.email.toLowerCase()], + ); + + let userId: string; + + if (userRows.length > 0) { + userId = userRows[0].id; + // Check if already a member of this org + const existing = await this.userOrgRepository.findOne({ + where: { userId, organizationId: orgId }, + }); + if (existing) { + if (existing.isActive) { + throw new ConflictException('User is already a member of this organization'); + } + // Re-activate an existing inactive membership + existing.isActive = true; + existing.role = data.role; + return this.userOrgRepository.save(existing); + } + } else { + // Create new user + const passwordHash = await bcrypt.hash(data.password, 12); + const result = await dataSource.query( + `INSERT INTO shared.users (email, password_hash, first_name, last_name) + VALUES ($1, $2, $3, $4) + RETURNING id`, + [data.email.toLowerCase(), passwordHash, data.firstName, data.lastName], + ); + userId = result[0].id; + } + + // Create membership + const membership = this.userOrgRepository.create({ + userId, + organizationId: orgId, + role: data.role, + }); + return this.userOrgRepository.save(membership); + } + + async updateMemberRole(orgId: string, membershipId: string, role: string) { + const membership = await this.userOrgRepository.findOne({ + where: { id: membershipId, organizationId: orgId }, + }); + if (!membership) { + throw new NotFoundException('Membership not found'); + } + membership.role = role; + return this.userOrgRepository.save(membership); + } + + async removeMember(orgId: string, membershipId: string, requestingUserId: string) { + const membership = await this.userOrgRepository.findOne({ + where: { id: membershipId, organizationId: orgId }, + }); + if (!membership) { + throw new NotFoundException('Membership not found'); + } + if (membership.userId === requestingUserId) { + throw new BadRequestException('You cannot remove yourself from the organization'); + } + membership.isActive = false; + return this.userOrgRepository.save(membership); + } + private generateSchemaName(name: string): string { const clean = name .toLowerCase() diff --git a/frontend/package.json b/frontend/package.json index dc44097..6e51efc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-frontend", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb30982..9dc7802 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,8 @@ import { CashFlowPage } from './pages/reports/CashFlowPage'; import { AgingReportPage } from './pages/reports/AgingReportPage'; import { YearEndPage } from './pages/reports/YearEndPage'; import { SettingsPage } from './pages/settings/SettingsPage'; +import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage'; +import { OrgMembersPage } from './pages/org-members/OrgMembersPage'; import { AdminPage } from './pages/admin/AdminPage'; import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage'; import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage'; @@ -124,6 +126,8 @@ export function App() { } /> } /> } /> + } /> + } /> ); diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 6971c92..4c3ff14 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -4,6 +4,9 @@ import { IconLogout, IconSwitchHorizontal, IconChevronDown, + IconSettings, + IconUserCog, + IconUsersGroup, } from '@tabler/icons-react'; import { Outlet, useNavigate } from 'react-router-dom'; import { useAuthStore } from '../../stores/authStore'; @@ -20,6 +23,9 @@ export function AppLayout() { navigate('/login'); }; + // Tenant admins (president role) can manage org members + const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin'; + return ( {currentOrg.name} )} - + @@ -49,6 +55,28 @@ export function AppLayout() { + Account + } + onClick={() => navigate('/preferences')} + > + User Preferences + + } + onClick={() => navigate('/settings')} + > + Settings + + {isTenantAdmin && ( + } + onClick={() => navigate('/org-members')} + > + Manage Members + + )} + } onClick={() => navigate('/select-org')} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 439c67d..bd5eec0 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -12,7 +12,6 @@ import { IconShieldCheck, IconBuildingBank, IconUsers, - IconSettings, IconCrown, IconCategory, IconChartAreaLine, @@ -74,12 +73,6 @@ const navSections = [ }, ], }, - { - label: 'Admin', - items: [ - { label: 'Settings', icon: IconSettings, path: '/settings' }, - ], - }, ]; export function Sidebar() { diff --git a/frontend/src/pages/org-members/OrgMembersPage.tsx b/frontend/src/pages/org-members/OrgMembersPage.tsx new file mode 100644 index 0000000..7738dcb --- /dev/null +++ b/frontend/src/pages/org-members/OrgMembersPage.tsx @@ -0,0 +1,376 @@ +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 } 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 = { + 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(null); + const queryClient = useQueryClient(); + const { user, currentOrg } = useAuthStore(); + + const { data: members = [], isLoading } = useQuery({ + 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
; + } + + return ( + + +
+ Organization Members + Manage who has access to {currentOrg?.name} +
+ +
+ + + + + + + +
+ Total Members + {activeMembers.length} +
+
+
+ + + + + +
+ Board Members + + {activeMembers.filter((m) => + ['president', 'treasurer', 'secretary', 'board_member'].includes(m.role), + ).length} + +
+
+
+ + + + + +
+ Your Role + {currentOrg?.role || 'N/A'} +
+
+
+
+ + } 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. + + + + + + Name + Email + Role + Status + Joined + Last Login + + + + + {activeMembers.length === 0 && ( + + + + No members yet. Add your first board member above. + + + + )} + {activeMembers.map((member) => ( + + + {member.firstName} {member.lastName} + {member.userId === user?.id && ( + You + )} + + {member.email} + + + {member.role.replace(/_/g, ' ')} + + + + Active + + + {member.joinedAt ? new Date(member.joinedAt).toLocaleDateString() : '-'} + + + {member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'} + + + + + handleEditRole(member)}> + + + + {member.userId !== user?.id && ( + + handleRemove(member)}> + + + + )} + + + + ))} + {inactiveMembers.map((member) => ( + + {member.firstName} {member.lastName} + {member.email} + + + {member.role.replace(/_/g, ' ')} + + + + Inactive + + + {member.joinedAt ? new Date(member.joinedAt).toLocaleDateString() : '-'} + + - + + + ))} + +
+ + {/* Add Member Modal */} + +
addMemberMutation.mutate(values))}> + + } 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. + + + + + + + + + + +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/preferences/UserPreferencesPage.tsx b/frontend/src/pages/preferences/UserPreferencesPage.tsx new file mode 100644 index 0000000..6acd193 --- /dev/null +++ b/frontend/src/pages/preferences/UserPreferencesPage.tsx @@ -0,0 +1,198 @@ +import { + Title, Text, Card, Stack, Group, SimpleGrid, ThemeIcon, Switch, + Select, Badge, Divider, +} from '@mantine/core'; +import { + IconUser, IconPalette, IconClock, IconBell, IconEye, +} from '@tabler/icons-react'; +import { useAuthStore } from '../../stores/authStore'; + +export function UserPreferencesPage() { + const { user, currentOrg } = useAuthStore(); + + return ( + +
+ User Preferences + Customize your experience +
+ + + {/* Profile */} + + + + + +
+ Profile + Your account information +
+
+ + + Name + {user?.firstName} {user?.lastName} + + + Email + {user?.email} + + + Organization + {currentOrg?.name || 'N/A'} + + + Role + {currentOrg?.role || 'N/A'} + + +
+ + {/* Display Preferences */} + + + + + +
+ Display + Appearance and layout +
+
+ + +
+ Dark Mode + Switch to dark color theme +
+ +
+ +
+ Compact View + Reduce spacing in tables and lists +
+ +
+ + Display preferences coming in a future release +
+
+ + {/* Regional */} + + + + + +
+ Regional + Locale and time settings +
+
+ + + + Regional preferences coming in a future release + +
+ + {/* Notifications */} + + + + + +
+ Notifications + Email and in-app alerts +
+
+ + +
+ Email Notifications + Receive alerts via email +
+ +
+ +
+ Payment Alerts + Notify when payments are received +
+ +
+ +
+ Budget Alerts + Warn when budget thresholds exceeded +
+ +
+ + Notification preferences coming in a future release +
+
+ + {/* Feature Visibility */} + + + + + +
+ Feature Visibility + Show or hide sidebar sections +
+
+ + + Assessments + + + + Planning + + + + Invoices & Payments + + + + Capital Projects + + + + Feature visibility preferences coming in a future release + +
+
+
+ ); +} diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 4f90763..85f5de0 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -117,7 +117,7 @@ export function SettingsPage() { Version - 0.1.0 MVP + 0.2.0 MVP_P2 API