From c39bd872bd3373a06687f15ef54c1aae47e3ca50 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 30 Apr 2026 00:52:05 +0330 Subject: [PATCH] feature: a minimal implementation of staff management is done. --- backend/prisma/seed.ts | 8 +- backend/src/app.module.ts | 2 + backend/src/common/permissions.spec.ts | 37 ++ backend/src/common/permissions.ts | 57 ++ backend/src/modules/auth/auth.service.ts | 2 +- .../src/modules/staff/dto/invite-staff.dto.ts | 15 + .../staff/dto/update-staff-member.dto.ts | 13 + backend/src/modules/staff/staff.controller.ts | 62 +++ backend/src/modules/staff/staff.module.ts | 10 + backend/src/modules/staff/staff.service.ts | 288 ++++++++++ frontend/src/access/dashboard-tab-access.ts | 56 ++ frontend/src/access/index.ts | 1 + frontend/src/app/(dashboard)/layout.tsx | 15 +- .../components}/CreatePatientModal.tsx | 0 .../components}/PatientSearchSelect.tsx | 0 .../components}/PatientSummaryCard.tsx | 0 .../components}/TreatmentHistoryPreview.tsx | 0 .../src/app/(dashboard)/patients/page.tsx | 8 +- .../staff/components/staffPermissions.ts | 43 ++ frontend/src/app/(dashboard)/staff/page.tsx | 499 +++++++++++++++++- .../staff/staff-permission-form.ts | 60 +++ frontend/src/components/ui/Checkbox.tsx | 70 +++ frontend/src/components/ui/Sidebar.tsx | 26 +- frontend/src/lib/api/staff.ts | 63 +++ frontend/src/lib/hooks/useAuth.tsx | 1 + frontend/src/shared/permissions.ts | 51 ++ 26 files changed, 1361 insertions(+), 26 deletions(-) create mode 100644 backend/src/common/permissions.spec.ts create mode 100644 backend/src/common/permissions.ts create mode 100644 backend/src/modules/staff/dto/invite-staff.dto.ts create mode 100644 backend/src/modules/staff/dto/update-staff-member.dto.ts create mode 100644 backend/src/modules/staff/staff.controller.ts create mode 100644 backend/src/modules/staff/staff.module.ts create mode 100644 backend/src/modules/staff/staff.service.ts create mode 100644 frontend/src/access/dashboard-tab-access.ts create mode 100644 frontend/src/access/index.ts rename frontend/src/{components/patients => app/(dashboard)/patients/components}/CreatePatientModal.tsx (100%) rename frontend/src/{components/patients => app/(dashboard)/patients/components}/PatientSearchSelect.tsx (100%) rename frontend/src/{components/patients => app/(dashboard)/patients/components}/PatientSummaryCard.tsx (100%) rename frontend/src/{components/patients => app/(dashboard)/patients/components}/TreatmentHistoryPreview.tsx (100%) create mode 100644 frontend/src/app/(dashboard)/staff/components/staffPermissions.ts create mode 100644 frontend/src/app/(dashboard)/staff/staff-permission-form.ts create mode 100644 frontend/src/components/ui/Checkbox.tsx create mode 100644 frontend/src/lib/api/staff.ts create mode 100644 frontend/src/shared/permissions.ts diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 532c14d..20e1131 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -45,10 +45,10 @@ async function main() { // Create plans const plans = [ { name: 'trial', maxUsers: 5, price: 0, features: {} }, - { name: 'Small', maxUsers: 5, price: 79, features: {} }, - { name: 'Medium', maxUsers: 10, price: 129, features: {} }, - { name: 'Large', maxUsers: 15, price: 179, features: {} }, - { name: 'Enterprise', maxUsers: 999999, price: 299, features: {} }, + { name: 'Small', maxUsers: 5, price: 150, features: {} }, + { name: 'Medium', maxUsers: 10, price: 250, features: {} }, + { name: 'Large', maxUsers: 15, price: 400, features: {} }, + { name: 'Enterprise', maxUsers: 999999, price: 1000, features: {} }, ]; for (const plan of plans) { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 468fd14..aaa7938 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { AppService } from './app.service'; import { AdminModule } from './admin/admin.module'; import { PrismaModule } from '../prisma/prisma.module'; // ✅ import { PatientsModule } from './modules/patients/patients.module'; +import { StaffModule } from './modules/staff/staff.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { PatientsModule } from './modules/patients/patients.module'; PrismaModule, // ✅ ADD THIS AuthModule, PatientsModule, + StaffModule, AdminModule.forRoot(), ], controllers: [AppController], diff --git a/backend/src/common/permissions.spec.ts b/backend/src/common/permissions.spec.ts new file mode 100644 index 0000000..6cad936 --- /dev/null +++ b/backend/src/common/permissions.spec.ts @@ -0,0 +1,37 @@ +import { isUnlimitedSeats, normalizeTabPermissions, SEAT_UNLIMITED_THRESHOLD } from './permissions'; + +describe('normalizeTabPermissions', () => { + it('adds READ when EDIT is present', () => { + expect(normalizeTabPermissions(['TAB_PATIENTS_EDIT'])).toEqual([ + 'TAB_PATIENTS_READ', + 'TAB_PATIENTS_EDIT', + ]); + }); + + it('dedupes and sorts', () => { + expect( + normalizeTabPermissions([ + 'TAB_TODAY_READ', + 'TAB_TODAY_EDIT', + 'TAB_TODAY_READ', + 'bogus', + ]), + ).toEqual(['TAB_TODAY_READ', 'TAB_TODAY_EDIT']); + }); + + it('accepts empty array', () => { + expect(normalizeTabPermissions([])).toEqual([]); + }); +}); + +describe('isUnlimitedSeats', () => { + it('treats sentinel as unlimited', () => { + expect(isUnlimitedSeats(SEAT_UNLIMITED_THRESHOLD)).toBe(true); + expect(isUnlimitedSeats(SEAT_UNLIMITED_THRESHOLD + 1)).toBe(true); + }); + + it('treats normal caps as limited', () => { + expect(isUnlimitedSeats(5)).toBe(false); + expect(isUnlimitedSeats(15)).toBe(false); + }); +}); diff --git a/backend/src/common/permissions.ts b/backend/src/common/permissions.ts new file mode 100644 index 0000000..2165d51 --- /dev/null +++ b/backend/src/common/permissions.ts @@ -0,0 +1,57 @@ +/** Tab permissions — keep in sync with prisma seed and AuthService ALL_PERMISSIONS */ +export const ALL_TAB_PERMISSIONS = [ + 'TAB_TODAY_READ', + 'TAB_TODAY_EDIT', + 'TAB_PATIENTS_READ', + 'TAB_PATIENTS_EDIT', + 'TAB_APPOINTMENTS_READ', + 'TAB_APPOINTMENTS_EDIT', + 'TAB_STAFF_READ', + 'TAB_STAFF_EDIT', + 'TAB_LAB_READ', + 'TAB_LAB_EDIT', + 'TAB_BILLING_READ', + 'TAB_BILLING_EDIT', + 'TAB_REPORTS_READ', + 'TAB_REPORTS_EDIT', +] as const; + +export type TabPermission = (typeof ALL_TAB_PERMISSIONS)[number]; + +const ALL_TAB_SET = new Set(ALL_TAB_PERMISSIONS); +const TAB_ORDER_INDEX = new Map( + ALL_TAB_PERMISSIONS.map((p, i) => [p, i]), +); + +/** Enterprise / unlimited seat plans use this sentinel in seed data */ +export const SEAT_UNLIMITED_THRESHOLD = 999999; + +export function isUnlimitedSeats(maxUsers: number): boolean { + return maxUsers >= SEAT_UNLIMITED_THRESHOLD; +} + +/** EDIT implies READ for the same feature tab */ +const EDIT_TO_READ: Record = { + TAB_TODAY_EDIT: 'TAB_TODAY_READ', + TAB_PATIENTS_EDIT: 'TAB_PATIENTS_READ', + TAB_APPOINTMENTS_EDIT: 'TAB_APPOINTMENTS_READ', + TAB_STAFF_EDIT: 'TAB_STAFF_READ', + TAB_LAB_EDIT: 'TAB_LAB_READ', + TAB_BILLING_EDIT: 'TAB_BILLING_READ', + TAB_REPORTS_EDIT: 'TAB_REPORTS_READ', +}; + +/** + * Dedupe, drop unknown strings, and add implied READ permissions for each EDIT. + */ +export function normalizeTabPermissions(names: string[]): string[] { + const out = new Set(); + for (const raw of names) { + const n = typeof raw === 'string' ? raw.trim() : ''; + if (!n || !ALL_TAB_SET.has(n)) continue; + out.add(n); + const read = EDIT_TO_READ[n]; + if (read) out.add(read); + } + return [...out].sort((a, b) => (TAB_ORDER_INDEX.get(a) ?? 0) - (TAB_ORDER_INDEX.get(b) ?? 0)); +} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index e041323..9e2814e 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -751,6 +751,7 @@ export class AuthService { name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, + permissions, plan: membership.organization.plan ? { name: membership.organization.plan.name, @@ -758,7 +759,6 @@ export class AuthService { } : undefined, }, - permissions, }, }; } diff --git a/backend/src/modules/staff/dto/invite-staff.dto.ts b/backend/src/modules/staff/dto/invite-staff.dto.ts new file mode 100644 index 0000000..cc26846 --- /dev/null +++ b/backend/src/modules/staff/dto/invite-staff.dto.ts @@ -0,0 +1,15 @@ +import { IsArray, IsEmail, IsString, MinLength } from 'class-validator'; + +export class InviteStaffDto { + @IsEmail() + email: string; + + @IsString() + @MinLength(1) + name: string; + + /** TAB_* permission names; EDIT implies READ after normalization. */ + @IsArray() + @IsString({ each: true }) + permissionNames: string[]; +} diff --git a/backend/src/modules/staff/dto/update-staff-member.dto.ts b/backend/src/modules/staff/dto/update-staff-member.dto.ts new file mode 100644 index 0000000..40854f3 --- /dev/null +++ b/backend/src/modules/staff/dto/update-staff-member.dto.ts @@ -0,0 +1,13 @@ +import { IsArray, IsOptional, IsString, MinLength } from 'class-validator'; + +export class UpdateStaffMemberDto { + @IsOptional() + @IsString() + @MinLength(1) + name?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissionNames?: string[]; +} diff --git a/backend/src/modules/staff/staff.controller.ts b/backend/src/modules/staff/staff.controller.ts new file mode 100644 index 0000000..a3641ad --- /dev/null +++ b/backend/src/modules/staff/staff.controller.ts @@ -0,0 +1,62 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { InviteStaffDto } from './dto/invite-staff.dto'; +import { UpdateStaffMemberDto } from './dto/update-staff-member.dto'; +import { StaffService } from './staff.service'; + +@ApiTags('staff') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('staff') +export class StaffController { + constructor(private readonly staffService: StaffService) {} + + @Get() + @ApiOperation({ summary: 'List organization members (requires TAB_STAFF_READ or owner)' }) + list(@Req() req: { user: { id: string; organizationId?: string } }) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffService.list(req.user.id, organizationId); + } + + @Post('invite') + @ApiOperation({ summary: 'Invite staff (requires TAB_STAFF_EDIT or owner)' }) + invite( + @Req() req: { user: { id: string; organizationId?: string } }, + @Body() dto: InviteStaffDto, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffService.invite(req.user.id, organizationId, dto); + } + + @Patch('members/:membershipId') + @ApiOperation({ summary: 'Update staff member name and/or permissions' }) + updateMember( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('membershipId') membershipId: string, + @Body() dto: UpdateStaffMemberDto, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto); + } + + @Delete('members/:membershipId') + @ApiOperation({ summary: 'Remove staff member from organization' }) + removeMember( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('membershipId') membershipId: string, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffService.removeMember(req.user.id, organizationId, membershipId); + } +} diff --git a/backend/src/modules/staff/staff.module.ts b/backend/src/modules/staff/staff.module.ts new file mode 100644 index 0000000..c297ea0 --- /dev/null +++ b/backend/src/modules/staff/staff.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { StaffController } from './staff.controller'; +import { StaffService } from './staff.service'; + +@Module({ + controllers: [StaffController], + providers: [StaffService, PrismaService], +}) +export class StaffModule {} diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts new file mode 100644 index 0000000..f8729af --- /dev/null +++ b/backend/src/modules/staff/staff.service.ts @@ -0,0 +1,288 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { randomBytes } from 'crypto'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions'; +import { InviteStaffDto } from './dto/invite-staff.dto'; +import { UpdateStaffMemberDto } from './dto/update-staff-member.dto'; + +@Injectable() +export class StaffService { + constructor(private readonly prisma: PrismaService) {} + + getOrganizationIdFromUser(user: { organizationId?: string }) { + if (!user?.organizationId) { + throw new BadRequestException('Organization is not selected'); + } + return user.organizationId; + } + + async list(userId: string, organizationId: string) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canViewStaff(actor)) { + throw new ForbiddenException('You do not have access to staff management'); + } + + const org = await this.prisma.organization.findUnique({ + where: { id: organizationId }, + include: { plan: true }, + }); + if (!org) { + throw new NotFoundException('Organization not found'); + } + + const [members, seatsUsed] = await Promise.all([ + this.prisma.membership.findMany({ + where: { organizationId }, + include: { + user: { select: { id: true, email: true, name: true } }, + permissions: { include: { permission: true } }, + }, + orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }], + }), + this.prisma.membership.count({ where: { organizationId } }), + ]); + + const maxUsers = org.plan.maxUsers; + const unlimited = isUnlimitedSeats(maxUsers); + + return { + success: true, + data: { + members: members.map((m) => ({ + id: m.id, + userId: m.user.id, + email: m.user.email, + name: m.user.name, + isOwner: m.isOwner, + permissions: m.isOwner + ? null + : m.permissions.map((p) => p.permission.name), + })), + seats: { + used: seatsUsed, + limit: unlimited ? null : maxUsers, + unlimited, + }, + }, + }; + } + + async invite(userId: string, organizationId: string, dto: InviteStaffDto) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canEditStaff(actor)) { + throw new ForbiddenException('You cannot invite or manage staff'); + } + + const email = dto.email.trim().toLowerCase(); + const normalizedPerms = normalizeTabPermissions(dto.permissionNames); + + const permissionRows = await this.prisma.permission.findMany({ + where: { name: { in: normalizedPerms } }, + select: { id: true, name: true }, + }); + if (permissionRows.length !== normalizedPerms.length) { + const ok = new Set(permissionRows.map((p) => p.name)); + const missing = normalizedPerms.filter((n) => !ok.has(n)); + throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`); + } + + let temporaryPassword: string | null = null; + + const result = await this.prisma.$transaction(async (tx) => { + const org = await tx.organization.findUnique({ + where: { id: organizationId }, + include: { plan: true }, + }); + if (!org) { + throw new NotFoundException('Organization not found'); + } + + const maxUsers = org.plan.maxUsers; + const seatsUsed = await tx.membership.count({ where: { organizationId } }); + if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) { + throw new BadRequestException( + `Your plan allows ${maxUsers} team members. Remove a member or upgrade to add more.`, + ); + } + + const existingUser = await tx.user.findUnique({ where: { email } }); + let targetUserId: string; + + if (existingUser) { + if (existingUser.id === org.ownerId) { + throw new BadRequestException('Organization owner is already a member'); + } + const dup = await tx.membership.findUnique({ + where: { + userId_organizationId: { + userId: existingUser.id, + organizationId, + }, + }, + }); + if (dup) { + throw new ConflictException('This user is already a member of this organization'); + } + targetUserId = existingUser.id; + } else { + temporaryPassword = randomBytes(18).toString('base64url').slice(0, 20); + const passwordHash = await bcrypt.hash(temporaryPassword, 10); + const created = await tx.user.create({ + data: { + email, + name: dto.name.trim(), + passwordHash, + }, + }); + targetUserId = created.id; + } + + const membership = await tx.membership.create({ + data: { + userId: targetUserId, + organizationId, + isOwner: false, + }, + }); + + if (permissionRows.length > 0) { + await tx.membershipPermission.createMany({ + data: permissionRows.map((p) => ({ + membershipId: membership.id, + permissionId: p.id, + })), + }); + } + + return { membershipId: membership.id, userId: targetUserId }; + }); + + return { + success: true, + data: { + membershipId: result.membershipId, + userId: result.userId, + email, + temporaryPassword, + }, + }; + } + + async updateMember( + actorUserId: string, + organizationId: string, + membershipId: string, + dto: UpdateStaffMemberDto, + ) { + const actor = await this.getActorMembership(actorUserId, organizationId); + if (!actor || !this.canEditStaff(actor)) { + throw new ForbiddenException('You cannot edit staff'); + } + + const target = await this.prisma.membership.findFirst({ + where: { id: membershipId, organizationId }, + include: { + user: true, + permissions: { include: { permission: true } }, + }, + }); + + if (!target) { + throw new NotFoundException('Member not found'); + } + if (target.isOwner) { + throw new ForbiddenException('Owner membership cannot be edited here'); + } + + if (dto.name !== undefined) { + await this.prisma.user.update({ + where: { id: target.userId }, + data: { name: dto.name.trim() }, + }); + } + + if (dto.permissionNames !== undefined) { + const normalizedPerms = normalizeTabPermissions(dto.permissionNames); + const permissionRows = await this.prisma.permission.findMany({ + where: { name: { in: normalizedPerms } }, + select: { id: true, name: true }, + }); + if (permissionRows.length !== normalizedPerms.length) { + const ok = new Set(permissionRows.map((p) => p.name)); + const missing = normalizedPerms.filter((n) => !ok.has(n)); + throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`); + } + + await this.prisma.$transaction([ + this.prisma.membershipPermission.deleteMany({ where: { membershipId: target.id } }), + ...(permissionRows.length + ? [ + this.prisma.membershipPermission.createMany({ + data: permissionRows.map((p) => ({ + membershipId: target.id, + permissionId: p.id, + })), + }), + ] + : []), + ]); + } + + return { success: true, message: 'Member updated' }; + } + + async removeMember(actorUserId: string, organizationId: string, membershipId: string) { + const actor = await this.getActorMembership(actorUserId, organizationId); + if (!actor || !this.canEditStaff(actor)) { + throw new ForbiddenException('You cannot remove staff'); + } + + const target = await this.prisma.membership.findFirst({ + where: { id: membershipId, organizationId }, + }); + + if (!target) { + throw new NotFoundException('Member not found'); + } + if (target.isOwner) { + throw new ForbiddenException('Cannot remove the organization owner'); + } + + await this.prisma.membership.delete({ where: { id: membershipId } }); + + return { success: true, message: 'Member removed' }; + } + + private async getActorMembership(userId: string, organizationId: string) { + return this.prisma.membership.findFirst({ + where: { userId, organizationId }, + include: { permissions: { include: { permission: true } } }, + }); + } + + private canViewStaff(m: { + isOwner: boolean; + permissions: { permission: { name: string } }[]; + }): boolean { + if (m.isOwner) return true; + return m.permissions.some( + (p) => + p.permission.name === 'TAB_STAFF_READ' || p.permission.name === 'TAB_STAFF_EDIT', + ); + } + + private canEditStaff(m: { + isOwner: boolean; + permissions: { permission: { name: string } }[]; + }): boolean { + if (m.isOwner) return true; + return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT'); + } +} diff --git a/frontend/src/access/dashboard-tab-access.ts b/frontend/src/access/dashboard-tab-access.ts new file mode 100644 index 0000000..addcb6d --- /dev/null +++ b/frontend/src/access/dashboard-tab-access.ts @@ -0,0 +1,56 @@ +/** + * Dashboard route ↔ TAB_* READ permission mapping and helpers used by the shell + * (Sidebar, layout guard). Cross-cutting access logic lives here — not in `lib`, + * which remains for generic utilities (API client, hooks, etc.). + */ +import type { Organization } from '@/types'; + +const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [ + { prefix: '/today', permission: 'TAB_TODAY_READ' }, + { prefix: '/patients', permission: 'TAB_PATIENTS_READ' }, + { prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' }, + { prefix: '/staff', permission: 'TAB_STAFF_READ' }, + { prefix: '/lab', permission: 'TAB_LAB_READ' }, + { prefix: '/billing', permission: 'TAB_BILLING_READ' }, + { prefix: '/reports', permission: 'TAB_REPORTS_READ' }, +]; + +export function hasPermission(org: Organization | null, permission: string): boolean { + if (!org) return false; + if (org.isOwner) return true; + return Boolean(org.permissions?.includes(permission)); +} + +/** Sidebar / route guard: READ access to a tab */ +export function canViewTab(org: Organization | null, readPermission: string): boolean { + return hasPermission(org, readPermission); +} + +export function getRequiredReadPermissionForPath(pathname: string): string | null { + for (const { prefix, permission } of ROUTE_TAB_READ) { + if (pathname === prefix || pathname.startsWith(`${prefix}/`)) { + return permission; + } + } + return null; +} + +/** First dashboard route the user may open (ordered). Fallback: account settings. */ +export function firstAccessibleDashboardPath(org: Organization | null): string { + if (!org) return '/today'; + if (org.isOwner) return '/today'; + for (const { prefix, permission } of ROUTE_TAB_READ) { + if (hasPermission(org, permission)) return prefix; + } + return '/settings/account'; +} + +export function canEditStaff(org: Organization | null): boolean { + return hasPermission(org, 'TAB_STAFF_EDIT'); +} + +export function canViewStaff(org: Organization | null): boolean { + return ( + hasPermission(org, 'TAB_STAFF_READ') || hasPermission(org, 'TAB_STAFF_EDIT') + ); +} diff --git a/frontend/src/access/index.ts b/frontend/src/access/index.ts new file mode 100644 index 0000000..abb16af --- /dev/null +++ b/frontend/src/access/index.ts @@ -0,0 +1 @@ +export * from './dashboard-tab-access'; diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx index c23c6d9..978b342 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -1,15 +1,21 @@ 'use client'; import { memo, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import Sidebar from '@/components/ui/Sidebar'; import { ThemeToggle } from '@/components/ui/ThemeToggle'; import { DashboardAccountMenu } from '@/components/ui/DashboardAccountMenu'; +import { + firstAccessibleDashboardPath, + getRequiredReadPermissionForPath, + hasPermission, +} from '@/access'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { const { user, currentOrganization, isAuthReady } = useAuth(); const router = useRouter(); + const pathname = usePathname(); // ✅ AUTH GUARD (runs once per navigation group) useEffect(() => { @@ -24,7 +30,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod router.replace('/select-organization'); return; } - }, [isAuthReady, user, currentOrganization, router]); + + const required = getRequiredReadPermissionForPath(pathname); + if (required && !hasPermission(currentOrganization, required)) { + router.replace(firstAccessibleDashboardPath(currentOrganization)); + } + }, [isAuthReady, user, currentOrganization, router, pathname]); // ✅ LOADING ONLY FOR INITIAL LOAD if (!isAuthReady) { diff --git a/frontend/src/components/patients/CreatePatientModal.tsx b/frontend/src/app/(dashboard)/patients/components/CreatePatientModal.tsx similarity index 100% rename from frontend/src/components/patients/CreatePatientModal.tsx rename to frontend/src/app/(dashboard)/patients/components/CreatePatientModal.tsx diff --git a/frontend/src/components/patients/PatientSearchSelect.tsx b/frontend/src/app/(dashboard)/patients/components/PatientSearchSelect.tsx similarity index 100% rename from frontend/src/components/patients/PatientSearchSelect.tsx rename to frontend/src/app/(dashboard)/patients/components/PatientSearchSelect.tsx diff --git a/frontend/src/components/patients/PatientSummaryCard.tsx b/frontend/src/app/(dashboard)/patients/components/PatientSummaryCard.tsx similarity index 100% rename from frontend/src/components/patients/PatientSummaryCard.tsx rename to frontend/src/app/(dashboard)/patients/components/PatientSummaryCard.tsx diff --git a/frontend/src/components/patients/TreatmentHistoryPreview.tsx b/frontend/src/app/(dashboard)/patients/components/TreatmentHistoryPreview.tsx similarity index 100% rename from frontend/src/components/patients/TreatmentHistoryPreview.tsx rename to frontend/src/app/(dashboard)/patients/components/TreatmentHistoryPreview.tsx diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx index 87bebfe..d22e28f 100644 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -10,10 +10,10 @@ import { Patient, TreatmentHistoryItem, } from '@/types/patient'; -import { PatientSearchSelect } from '@/components/patients/PatientSearchSelect'; -import { CreatePatientModal } from '@/components/patients/CreatePatientModal'; -import { PatientSummaryCard } from '@/components/patients/PatientSummaryCard'; -import { TreatmentHistoryPreview } from '@/components/patients/TreatmentHistoryPreview'; +import { PatientSearchSelect } from './components/PatientSearchSelect'; +import { CreatePatientModal } from './components/CreatePatientModal'; +import { PatientSummaryCard } from './components/PatientSummaryCard'; +import { TreatmentHistoryPreview } from './components/TreatmentHistoryPreview'; const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', diff --git a/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts b/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts new file mode 100644 index 0000000..bc30c97 --- /dev/null +++ b/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts @@ -0,0 +1,43 @@ +/** Feature groups for staff invite/edit UI — matches backend seed */ +export const STAFF_FEATURE_GROUPS = [ + { label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, + { label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, + { label: 'Appointments', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, + { label: 'Staff Management', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, + { label: 'Lab Management', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' }, + { label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, + { label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, +] as const; + +/** Map EDIT key -> { read, edit } for checkbox grid */ +export type FeaturePermState = Record; + +export function emptyFeaturePermissionState(): FeaturePermState { + const s: FeaturePermState = {}; + for (const g of STAFF_FEATURE_GROUPS) { + s[g.edit] = { read: false, edit: false }; + } + return s; +} + +export function featureStateFromPermissionNames(names: string[]): FeaturePermState { + const set = new Set(names); + const s = emptyFeaturePermissionState(); + for (const g of STAFF_FEATURE_GROUPS) { + const hasEdit = set.has(g.edit); + const hasRead = set.has(g.read) || hasEdit; + s[g.edit] = { read: hasRead, edit: hasEdit }; + } + return s; +} + +export function permissionNamesFromFeatureState(state: FeaturePermState): string[] { + const out: string[] = []; + for (const g of STAFF_FEATURE_GROUPS) { + const cell = state[g.edit]; + if (!cell) continue; + if (cell.edit) out.push(g.edit); + else if (cell.read) out.push(g.read); + } + return out; +} diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index e7167d2..e8dcf7c 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -1,10 +1,497 @@ -export default function StaffPage() { +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { + firstAccessibleDashboardPath, + canEditStaff, + canViewStaff, +} from '@/access'; +import { + STAFF_FEATURE_GROUPS, + permissionNamesFromFeatureState, + emptyFeaturePermissionState, + featureStateFromPermissionNames, + formatAccessSummary, + type FeaturePermState, +} from './staff-permission-form'; +import { UserPlus, Pencil, Trash2, Copy, Check, X } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Checkbox } from '@/components/ui/Checkbox'; +import { ApiError } from '@/types'; + +function formatApiMessage(err: unknown): string { + if (!err || typeof err !== 'object') return 'Something went wrong'; + const m = (err as ApiError).message; + if (Array.isArray(m)) return m.join(', '); + if (typeof m === 'string') return m; + return 'Something went wrong'; +} + +function PermissionGrid({ + state, + onChange, + disabled, +}: { + state: FeaturePermState; + onChange: (next: FeaturePermState) => void; + disabled?: boolean; +}) { + const setRead = (editKey: string, read: boolean) => { + const cur = state[editKey] ?? { read: false, edit: false }; + onChange({ + ...state, + [editKey]: { read, edit: read ? cur.edit : false }, + }); + }; + + const setEdit = (editKey: string, edit: boolean) => { + const cur = state[editKey] ?? { read: false, edit: false }; + onChange({ + ...state, + [editKey]: { read: edit || cur.read, edit }, + }); + }; + return ( -
-

Staff Management

-

- Staff management module is coming soon. -

+
+ {STAFF_FEATURE_GROUPS.map((g) => { + const cell = state[g.edit] ?? { read: false, edit: false }; + return ( +
+ {g.label} +
+ setRead(g.edit, v)} + /> + setEdit(g.edit, v)} + /> +
+
+ ); + })} +
+ ); +} + +export default function StaffPage() { + const router = useRouter(); + const { currentOrganization, user } = useAuth(); + const [members, setMembers] = useState([]); + const [seats, setSeats] = useState<{ + used: number; + limit: number | null; + unlimited: boolean; + } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const [inviteOpen, setInviteOpen] = useState(false); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteName, setInviteName] = useState(''); + const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState()); + const [inviteLoading, setInviteLoading] = useState(false); + const [lastTempPassword, setLastTempPassword] = useState(null); + const [copiedPw, setCopiedPw] = useState(false); + const [lastInviteInfo, setLastInviteInfo] = useState<{ + name: string; + email: string; + isNewAccount: boolean; + } | null>(null); + + const [editing, setEditing] = useState(null); + const [editName, setEditName] = useState(''); + const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState()); + const [editLoading, setEditLoading] = useState(false); + + const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); + const atSeatLimit = useMemo(() => { + if (!seats || seats.unlimited) return false; + if (seats.limit == null) return false; + return seats.used >= seats.limit; + }, [seats]); + + const load = useCallback(async () => { + setError(''); + setLoading(true); + try { + const res = await staffApi.list(); + setMembers(res.data.members); + setSeats(res.data.seats); + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (!currentOrganization) return; + if (!canViewStaff(currentOrganization)) { + router.replace(firstAccessibleDashboardPath(currentOrganization)); + } + }, [currentOrganization, router]); + + useEffect(() => { + if (!success) return; + const t = setTimeout(() => setSuccess(''), 4000); + return () => clearTimeout(t); + }, [success]); + + async function submitInvite() { + setInviteLoading(true); + setError(''); + setLastTempPassword(null); + setLastInviteInfo(null); + const displayName = inviteName.trim(); + const displayEmail = inviteEmail.trim(); + try { + const permissionNames = permissionNamesFromFeatureState(invitePerms); + const res = await staffApi.invite({ + email: displayEmail, + name: displayName, + permissionNames, + }); + const isNew = Boolean(res.data.temporaryPassword); + setLastTempPassword(res.data.temporaryPassword); + setLastInviteInfo({ + name: displayName, + email: res.data.email, + isNewAccount: isNew, + }); + setSuccess(''); + setInviteOpen(false); + setInviteEmail(''); + setInviteName(''); + setInvitePerms(emptyFeaturePermissionState()); + await load(); + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setInviteLoading(false); + } + } + + function openEdit(m: StaffMemberDto) { + if (m.isOwner) return; + setEditing(m); + setEditName(m.name); + setEditPerms( + featureStateFromPermissionNames(m.permissions ?? []), + ); + } + + async function submitEdit() { + if (!editing) return; + setEditLoading(true); + setError(''); + try { + await staffApi.updateMember(editing.id, { + name: editName.trim(), + permissionNames: permissionNamesFromFeatureState(editPerms), + }); + setSuccess('Member updated'); + setEditing(null); + await load(); + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setEditLoading(false); + } + } + + async function removeMember(m: StaffMemberDto) { + if (m.isOwner) return; + if (m.userId === user?.id) { + if (!confirm('Remove yourself from this organization? You will lose access.')) return; + } else { + if (!confirm(`Remove ${m.name} from this organization?`)) return; + } + setError(''); + try { + await staffApi.removeMember(m.id); + setSuccess('Member removed'); + await load(); + } catch (e) { + setError(formatApiMessage(e)); + } + } + + async function copyTempPassword() { + if (!lastTempPassword) return; + try { + await navigator.clipboard.writeText(lastTempPassword); + setCopiedPw(true); + setTimeout(() => setCopiedPw(false), 2000); + } catch { + setError('Could not copy to clipboard'); + } + } + + if (!currentOrganization || !canViewStaff(currentOrganization)) { + return ( +

Redirecting…

+ ); + } + + return ( +
+
+
+

Staff Management

+

+ Invite teammates, set tab access, and stay within your plan seat limit. +

+
+ {canEdit && ( + + )} +
+ + {seats && ( +

+ Seats:{' '} + + {seats.used} + {seats.unlimited ? ' (unlimited plan)' : ` / ${seats.limit}`} + + {!seats.unlimited && atSeatLimit && ( + + Limit reached — remove a member or upgrade your plan. + + )} +

+ )} + + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + + {lastInviteInfo && ( +
+ +

+ {lastInviteInfo.isNewAccount ? ( + <> + {lastInviteInfo.name} ({lastInviteInfo.email}) — new + account created and added to this organization. + {lastTempPassword + ? ' Share the temporary password below so they can sign in.' + : ''} + + ) : ( + <> + {lastInviteInfo.name} ({lastInviteInfo.email}) — this + person already had an account. They can select {currentOrganization.name}{' '} + from the organization switcher after signing in. + + )} +

+ {lastTempPassword && ( +
+

+ Temporary password (copy now — not stored) +

+
+ + {lastTempPassword} + + +
+

+ They should sign in with this password once, then change it under account settings. +

+
+ )} +
+ )} + + {loading ? ( +

Loading team…

+ ) : ( +
+ + + + + + + + {canEdit && } + + + + {members.map((m) => ( + + + + + + {canEdit && ( + + )} + + ))} + +
NameEmailRoleAccessActions
{m.name}{m.email} + {m.isOwner ? ( + Owner + ) : ( + Staff + )} + + {m.isOwner ? ( + All features + ) : ( + + {formatAccessSummary(m.permissions)} + + )} + + {!m.isOwner && ( +
+ + +
+ )} +
+
+ )} + + {inviteOpen && ( +
+
+

+ Invite team member +

+ setInviteEmail(e.target.value)} + autoComplete="off" + /> + setInviteName(e.target.value)} + /> +
+

Tab access

+ +
+
+ + +
+
+
+ )} + + {editing && ( +
+
+

Edit member

+

{editing.email}

+ setEditName(e.target.value)} /> +
+

Tab access

+ +
+
+ + +
+
+
+ )}
); } diff --git a/frontend/src/app/(dashboard)/staff/staff-permission-form.ts b/frontend/src/app/(dashboard)/staff/staff-permission-form.ts new file mode 100644 index 0000000..f80a171 --- /dev/null +++ b/frontend/src/app/(dashboard)/staff/staff-permission-form.ts @@ -0,0 +1,60 @@ +/** + * Staff route only: tab matrix + checkbox state ↔ TAB_* permission names. + * Add presentational pieces under ./components/ as the UI grows. + */ + +export const STAFF_FEATURE_GROUPS = [ + { label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, + { label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, + { label: 'Appointments', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, + { label: 'Staff Management', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, + { label: 'Lab Management', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' }, + { label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, + { label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, +] as const; + +export type FeaturePermState = Record; + +export function emptyFeaturePermissionState(): FeaturePermState { + const s: FeaturePermState = {}; + for (const g of STAFF_FEATURE_GROUPS) { + s[g.edit] = { read: false, edit: false }; + } + return s; +} + +export function featureStateFromPermissionNames(names: string[]): FeaturePermState { + const set = new Set(names); + const s = emptyFeaturePermissionState(); + for (const g of STAFF_FEATURE_GROUPS) { + const hasEdit = set.has(g.edit); + const hasRead = set.has(g.read) || hasEdit; + s[g.edit] = { read: hasRead, edit: hasEdit }; + } + return s; +} + +export function permissionNamesFromFeatureState(state: FeaturePermState): string[] { + const out: string[] = []; + for (const g of STAFF_FEATURE_GROUPS) { + const cell = state[g.edit]; + if (!cell) continue; + if (cell.edit) out.push(g.edit); + else if (cell.read) out.push(g.read); + } + return out; +} + +/** Human-readable access for the team table — feature name, or "Feature (Read only)" */ +export function formatAccessSummary(permissionNames: string[] | null | undefined): string { + if (!permissionNames?.length) return 'No tab access'; + const set = new Set(permissionNames); + const parts: string[] = []; + for (const g of STAFF_FEATURE_GROUPS) { + const hasEdit = set.has(g.edit); + const hasRead = set.has(g.read) || hasEdit; + if (!hasRead) continue; + parts.push(hasEdit ? g.label : `${g.label} (Read only)`); + } + return parts.length ? parts.join(' · ') : 'No tab access'; +} diff --git a/frontend/src/components/ui/Checkbox.tsx b/frontend/src/components/ui/Checkbox.tsx new file mode 100644 index 0000000..2aa1114 --- /dev/null +++ b/frontend/src/components/ui/Checkbox.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useId } from 'react'; +import { Check } from 'lucide-react'; + +type CheckboxProps = { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + label: string; + id?: string; + className?: string; +}; + +/** + * App design-system checkbox: primary fill when checked, rounded, focus-visible ring. + */ +export function Checkbox({ + checked, + onChange, + disabled = false, + label, + id, + className = '', +}: CheckboxProps) { + const genId = useId(); + const inputId = id ?? genId; + + return ( + + ); +} diff --git a/frontend/src/components/ui/Sidebar.tsx b/frontend/src/components/ui/Sidebar.tsx index 79d8d82..cbc23be 100644 --- a/frontend/src/components/ui/Sidebar.tsx +++ b/frontend/src/components/ui/Sidebar.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { usePathname } from 'next/navigation'; import { LayoutDashboard, @@ -12,19 +12,27 @@ import { FileText, CreditCard, } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { canViewTab } from '@/access'; const menu = [ - { name: 'Today', path: '/today', icon: LayoutDashboard }, - { name: 'Patients', path: '/patients', icon: Users }, - { name: 'Appointments', path: '/appointments', icon: Calendar }, - { name: 'Staff Management', path: '/staff', icon: UserCog }, - { name: 'Lab Management', path: '/lab', icon: FlaskConical }, - { name: 'Billing', path: '/billing', icon: CreditCard }, - { name: 'Reports', path: '/reports', icon: FileText }, + { name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const }, + { name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const }, + { name: 'Appointments', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const }, + { name: 'Staff Management', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const }, + { name: 'Lab Management', path: '/lab', icon: FlaskConical, read: 'TAB_LAB_READ' as const }, + { name: 'Billing', path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const }, + { name: 'Reports', path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const }, ]; function Sidebar() { const pathname = usePathname(); + const { currentOrganization } = useAuth(); + + const visibleMenu = useMemo( + () => menu.filter((item) => canViewTab(currentOrganization, item.read)), + [currentOrganization], + ); return (