diff --git a/backend/prisma/migrations/20260430091832_staff_invites_activation/migration.sql b/backend/prisma/migrations/20260430091832_staff_invites_activation/migration.sql new file mode 100644 index 0000000..6d1feac --- /dev/null +++ b/backend/prisma/migrations/20260430091832_staff_invites_activation/migration.sql @@ -0,0 +1,29 @@ +-- AlterTable +ALTER TABLE "memberships" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true; + +-- CreateTable +CREATE TABLE "staff_invitations" ( + "id" TEXT NOT NULL, + "membershipId" TEXT NOT NULL, + "invitedById" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "acceptedAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "staff_invitations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "staff_invitations_tokenHash_key" ON "staff_invitations"("tokenHash"); + +-- CreateIndex +CREATE INDEX "staff_invitations_membershipId_createdAt_idx" ON "staff_invitations"("membershipId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "staff_invitations" ADD CONSTRAINT "staff_invitations_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "memberships"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_invitations" ADD CONSTRAINT "staff_invitations_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index db7b5fa..7f8280c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -20,6 +20,7 @@ model User { memberships Membership[] ownedOrganizations Organization[] @relation("OrganizationOwner") sessions Session[] // πŸ‘ˆ ADD THIS - opposite relation for Session + sentStaffInvites StaffInvitation[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -122,11 +123,13 @@ model Membership { organizationId String isOwner Boolean @default(false) + isActive Boolean @default(true) user User @relation(fields: [userId], references: [id]) organization Organization @relation(fields: [organizationId], references: [id]) permissions MembershipPermission[] + invitations StaffInvitation[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -135,6 +138,26 @@ model Membership { @@map("memberships") } +model StaffInvitation { + id String @id @default(uuid()) + + membershipId String + invitedById String + tokenHash String @unique + expiresAt DateTime + acceptedAt DateTime? + revokedAt DateTime? + + membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) + invitedBy User @relation(fields: [invitedById], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([membershipId, createdAt]) + @@map("staff_invitations") +} + model Permission { id String @id @default(uuid()) name String @unique 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..627beee 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -48,8 +48,9 @@ export class AuthService { */ async validateUser(email: string, password: string): Promise { try { + const normalizedEmail = email.trim().toLowerCase(); const user = await this.prisma.user.findUnique({ - where: { email }, + where: { email: normalizedEmail }, include: { memberships: { include: { @@ -135,7 +136,7 @@ export class AuthService { }); // Transform memberships to include organization info and permissions - const organizations = user.memberships?.map(membership => ({ + const organizations = this.toActiveOrganizations(user.memberships).map(membership => ({ id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, // 'CLINIC' or 'LAB' @@ -147,9 +148,10 @@ export class AuthService { ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, + price: membership.organization.plan.price, } : undefined, - })) || []; + })); return { success: true, @@ -177,7 +179,8 @@ export class AuthService { * @returns Created user info without password */ async register(registerDto: RegisterDto) { - const { email, password, name, organizationName, organizationEmail, organizationType } = registerDto; + const { password, name, organizationName, organizationEmail, organizationType } = registerDto; + const email = registerDto.email.trim().toLowerCase(); // 1. Check existing user const existingUser = await this.prisma.user.findUnique({ @@ -342,7 +345,7 @@ export class AuthService { const { passwordHash, ...result } = user; // Transform memberships for frontend consumption - const organizations = user.memberships?.map(membership => ({ + const organizations = this.toActiveOrganizations(user.memberships).map(membership => ({ id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, @@ -354,9 +357,10 @@ export class AuthService { ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, + price: membership.organization.plan.price, } : undefined, - })) || []; + })); return { success: true, @@ -462,7 +466,7 @@ export class AuthService { }); // Transform memberships for response - const organizations = session.user.memberships?.map(membership => ({ + const organizations = this.toActiveOrganizations(session.user.memberships).map(membership => ({ id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, @@ -474,9 +478,10 @@ export class AuthService { ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, + price: membership.organization.plan.price, } : undefined, - })) || []; + })); return { success: true, @@ -668,7 +673,7 @@ export class AuthService { const { passwordHash, ...user } = session.user; - const organizations = session.user.memberships?.map(membership => ({ + const organizations = this.toActiveOrganizations(session.user.memberships).map(membership => ({ id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, @@ -680,9 +685,10 @@ export class AuthService { ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, + price: membership.organization.plan.price, } : undefined, - })) || []; + })); return { success: true, @@ -722,6 +728,9 @@ export class AuthService { if (!membership) { throw new UnauthorizedException('Access denied to this organization'); } + if (!membership.isOwner && !membership.isActive) { + throw new UnauthorizedException('Your invitation is still pending activation'); + } // 2. Build payload WITH org context const payload = { @@ -751,18 +760,35 @@ 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, maxUsers: membership.organization.plan.maxUsers, + price: membership.organization.plan.price, } : undefined, }, - permissions, }, }; } + private toActiveOrganizations( + memberships: Array<{ + isOwner: boolean; + isActive: boolean; + organization: { + id: string; + name: string; + type: { name: string }; + plan?: { name: string; maxUsers: number; price: number } | null; + }; + permissions?: Array<{ permission: { name: string } }>; + }> = [], + ) { + return memberships.filter((m) => m.isOwner || m.isActive); + } + /** * Owner-only subscription / seat alerts for the current org (from JWT). * Used for a subtle warning indicator in the app shell (not staff-facing banners). @@ -776,6 +802,8 @@ export class AuthService { seatsLow: false, trialEndingSoon: false, trialExpired: false, + daysUntilPlanEnd: null, + planEndsAt: null, }, }; } @@ -797,6 +825,8 @@ export class AuthService { seatsLow: false, trialEndingSoon: false, trialExpired: false, + daysUntilPlanEnd: null, + planEndsAt: null, }, }; } @@ -805,7 +835,10 @@ export class AuthService { const plan = org.plan; const maxUsers = plan.maxUsers; const seatsUsed = await this.prisma.membership.count({ - where: { organizationId: org.id }, + where: { + organizationId: org.id, + OR: [{ isOwner: true }, { isActive: true }], + }, }); const unlimited = maxUsers >= 999999; @@ -813,23 +846,16 @@ export class AuthService { const seatsLow = !unlimited && remaining >= 0 && remaining <= 2 && maxUsers > 0; - let trialEndingSoon = false; - let trialExpired = false; - let daysUntilTrialEnd: number | null = null; - let trialEndsAt: string | null = null; + // Current pricing model: trial lasts 30 days; paid plans last 90 days. + const durationDays = plan.name === 'trial' ? 30 : 90; + const end = new Date(org.createdAt); + end.setDate(end.getDate() + durationDays); + const planEndsAt = end.toISOString(); + const ms = end.getTime() - Date.now(); + const daysUntilPlanEnd = Math.ceil(ms / (1000 * 60 * 60 * 24)); - if (plan.name === 'trial') { - const end = new Date(org.createdAt); - end.setDate(end.getDate() + 30); - trialEndsAt = end.toISOString(); - const ms = end.getTime() - Date.now(); - daysUntilTrialEnd = Math.ceil(ms / (1000 * 60 * 60 * 24)); - if (daysUntilTrialEnd <= 0) { - trialExpired = true; - } else if (daysUntilTrialEnd <= 7) { - trialEndingSoon = true; - } - } + const trialExpired = plan.name === 'trial' && daysUntilPlanEnd <= 0; + const trialEndingSoon = plan.name === 'trial' && daysUntilPlanEnd > 0 && daysUntilPlanEnd <= 7; const showWarning = seatsLow || trialEndingSoon || trialExpired; @@ -842,8 +868,10 @@ export class AuthService { trialExpired, seatsUsed, seatsLimit: maxUsers, - daysUntilTrialEnd, - trialEndsAt, + daysUntilTrialEnd: plan.name === 'trial' ? daysUntilPlanEnd : null, + trialEndsAt: plan.name === 'trial' ? planEndsAt : null, + daysUntilPlanEnd, + planEndsAt, }, }; } diff --git a/backend/src/modules/staff/dto/accept-staff-invite.dto.ts b/backend/src/modules/staff/dto/accept-staff-invite.dto.ts new file mode 100644 index 0000000..03809b5 --- /dev/null +++ b/backend/src/modules/staff/dto/accept-staff-invite.dto.ts @@ -0,0 +1,14 @@ +import { IsString, MinLength } from 'class-validator'; + +export class AcceptStaffInviteDto { + @IsString() + token: string; + + @IsString() + @MinLength(8) + password: string; + + @IsString() + @MinLength(1) + name: string; +} 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/preview-staff-invite.dto.ts b/backend/src/modules/staff/dto/preview-staff-invite.dto.ts new file mode 100644 index 0000000..c60547e --- /dev/null +++ b/backend/src/modules/staff/dto/preview-staff-invite.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class PreviewStaffInviteDto { + @IsString() + token: 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..df4ba8d --- /dev/null +++ b/backend/src/modules/staff/staff.controller.ts @@ -0,0 +1,80 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto'; +import { InviteStaffDto } from './dto/invite-staff.dto'; +import { PreviewStaffInviteDto } from './dto/preview-staff-invite.dto'; +import { UpdateStaffMemberDto } from './dto/update-staff-member.dto'; +import { StaffService } from './staff.service'; + +@ApiTags('staff') +@ApiBearerAuth('JWT-auth') +@Controller('staff') +export class StaffController { + constructor(private readonly staffService: StaffService) {} + + @Get('invitations/preview') + @ApiOperation({ summary: 'Preview invite info by token (public)' }) + previewInvite(@Query() query: PreviewStaffInviteDto) { + return this.staffService.previewInvite(query.token); + } + + @Post('invitations/accept') + @ApiOperation({ summary: 'Accept invite and activate account (public)' }) + acceptInvite(@Body() dto: AcceptStaffInviteDto) { + return this.staffService.acceptInvite(dto); + } + + @Get() + @UseGuards(JwtAuthGuard) + @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') + @UseGuards(JwtAuthGuard) + @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') + @UseGuards(JwtAuthGuard) + @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') + @UseGuards(JwtAuthGuard) + @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..59880a9 --- /dev/null +++ b/backend/src/modules/staff/staff.service.ts @@ -0,0 +1,443 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { createHash, randomBytes } from 'crypto'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto'; +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 } }, + invitations: { + orderBy: { createdAt: 'desc' }, + take: 1, + }, + }, + orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }], + }), + this.prisma.membership.count({ + where: { + organizationId, + OR: [{ isOwner: true }, { isActive: true }], + }, + }), + ]); + + 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, + isActive: m.isOwner ? true : m.isActive, + invitationStatus: this.getInvitationStatus(m), + invitedAt: m.invitations[0]?.createdAt?.toISOString() || null, + acceptedAt: m.invitations[0]?.acceptedAt?.toISOString() || null, + 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(', ')}`); + } + + const plainToken = this.generateInviteToken(); + const tokenHash = this.hashInviteToken(plainToken); + + 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, + OR: [{ isOwner: true }, { isActive: true }], + }, + }); + 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 { + const created = await tx.user.create({ + data: { + email, + name: dto.name.trim(), + passwordHash: null, + }, + }); + targetUserId = created.id; + } + + const membership = await tx.membership.create({ + data: { + userId: targetUserId, + organizationId, + isOwner: false, + isActive: existingUser ? true : false, + }, + }); + + if (permissionRows.length > 0) { + await tx.membershipPermission.createMany({ + data: permissionRows.map((p) => ({ + membershipId: membership.id, + permissionId: p.id, + })), + }); + } + + let inviteUrl: string | null = null; + let invitationId: string | null = null; + + if (!existingUser) { + const invitation = await tx.staffInvitation.create({ + data: { + membershipId: membership.id, + invitedById: userId, + tokenHash, + expiresAt: this.getInviteExpiryDate(), + }, + }); + invitationId = invitation.id; + inviteUrl = this.buildInviteUrl(plainToken); + } + + return { + membershipId: membership.id, + userId: targetUserId, + invitationId, + inviteUrl, + isPending: !existingUser, + }; + }); + + return { + success: true, + data: { + membershipId: result.membershipId, + userId: result.userId, + email, + invitationId: result.invitationId, + invitationUrl: result.inviteUrl, + invitationStatus: result.isPending ? 'PENDING' : 'ACCEPTED', + }, + }; + } + + async previewInvite(token: string) { + const invitation = await this.findValidInvitation(token); + const org = invitation.membership.organization; + const user = invitation.membership.user; + + return { + success: true, + data: { + email: user.email, + name: user.name, + organizationName: org.name, + expiresAt: invitation.expiresAt.toISOString(), + status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING', + }, + }; + } + + async acceptInvite(dto: AcceptStaffInviteDto) { + const invitation = await this.findValidInvitation(dto.token); + + if (invitation.acceptedAt) { + throw new BadRequestException('This invitation has already been accepted'); + } + + const passwordHash = await bcrypt.hash(dto.password, 10); + const now = new Date(); + + await this.prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: invitation.membership.userId }, + data: { + passwordHash, + name: dto.name.trim(), + }, + }); + + await tx.membership.update({ + where: { id: invitation.membershipId }, + data: { isActive: true }, + }); + + await tx.staffInvitation.update({ + where: { id: invitation.id }, + data: { acceptedAt: now }, + }); + }); + + return { + success: true, + data: { + email: invitation.membership.user.email, + }, + message: 'Invitation accepted. You can now log in.', + }; + } + + 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 getInvitationStatus(m: { + isOwner: boolean; + isActive: boolean; + invitations: { acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date }[]; + }): 'ACTIVE' | 'PENDING' | 'EXPIRED' { + if (m.isOwner || m.isActive) return 'ACTIVE'; + const invitation = m.invitations[0]; + if (!invitation) return 'EXPIRED'; + if (invitation.acceptedAt || invitation.revokedAt) return 'ACTIVE'; + return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED'; + } + + private generateInviteToken(): string { + return randomBytes(32).toString('hex'); + } + + private hashInviteToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } + + private getInviteExpiryDate(): Date { + const d = new Date(); + d.setDate(d.getDate() + 7); + return d; + } + + private buildInviteUrl(token: string): string { + const appUrl = process.env.FRONTEND_URL || 'http://localhost:3001'; + return `${appUrl}/accept-invite?token=${encodeURIComponent(token)}`; + } + + private async findValidInvitation(token: string) { + const invitation = await this.prisma.staffInvitation.findUnique({ + where: { tokenHash: this.hashInviteToken(token) }, + include: { + membership: { + include: { + user: { select: { id: true, email: true, name: true } }, + organization: { select: { id: true, name: true } }, + }, + }, + }, + }); + + if (!invitation) { + throw new NotFoundException('Invitation not found'); + } + if (invitation.revokedAt) { + throw new BadRequestException('Invitation has been revoked'); + } + if (invitation.expiresAt.getTime() <= Date.now()) { + throw new BadRequestException('Invitation has expired'); + } + return invitation; + } + + 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/next.config.ts b/frontend/next.config.ts index 8b9ab96..cb6f00d 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -9,11 +9,12 @@ const nextConfig = { // Disable x-powered-by header for security poweredByHeader: false, - // Configure image domains if needed + // Configure allowed remote image sources images: { - domains: process.env.NODE_ENV === 'production' - ? ['yourdomain.com'] - : ['localhost'], + remotePatterns: + process.env.NODE_ENV === 'production' + ? [{ protocol: 'https', hostname: 'yourdomain.com' }] + : [{ protocol: 'http', hostname: 'localhost' }], }, // Environment variables that will be available at build time diff --git a/frontend/src/app/(dashboard)/billing/page.tsx b/frontend/src/app/(dashboard)/billing/page.tsx index a43a2d3..19598b0 100644 --- a/frontend/src/app/(dashboard)/billing/page.tsx +++ b/frontend/src/app/(dashboard)/billing/page.tsx @@ -2,9 +2,9 @@ 'use client'; import { useState } from 'react'; import { Search, Filter, Plus } from 'lucide-react'; -import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; -import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/common/Button'; +import { Input } from '@/components/ui/common/Input'; +import { Badge } from '@/components/ui/common/Badge'; // Mock data matching your design const invoices = [ { id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' }, diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx index c23c6d9..f371642 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 Sidebar from '@/components/ui/common/Sidebar'; +import { ThemeToggle } from '@/components/ui/common/ThemeToggle'; +import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu'; +import { + firstAccessibleDashboardPath, + getRequiredReadPermissionForPath, + hasPermission, +} from '@/shared/permissions'; 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) { @@ -66,7 +77,7 @@ const DashboardHeader = memo(function DashboardHeader({ organizationName: string; }) { return ( -
+

{organizationName}

diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx index 87bebfe..62ab228 100644 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Plus } from 'lucide-react'; -import { Button } from '@/components/ui/Button'; +import { Button } from '@/components/ui/common/Button'; import { patientsApi } from '@/lib/api/patients'; import { CreatePatientInput, @@ -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/ui/patient/PatientSearchSelect'; +import { CreatePatientModal } from '../../../components/ui/patient/CreatePatientModal'; +import { PatientSummaryCard } from '../../../components/ui/patient/PatientSummaryCard'; +import { TreatmentHistoryPreview } from '../../../components/ui/patient/TreatmentHistoryPreview'; const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', @@ -201,7 +201,7 @@ export default function PatientsPage() {
{(errorMessage || successMessage) && ( -
+
{errorMessage && (
{errorMessage} diff --git a/frontend/src/app/(dashboard)/settings/organizations/page.tsx b/frontend/src/app/(dashboard)/settings/organizations/page.tsx new file mode 100644 index 0000000..e7f8503 --- /dev/null +++ b/frontend/src/app/(dashboard)/settings/organizations/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import Link from 'next/link'; +import { OrganizationSelectorContent } from '@/components/ui/organization/OrganizationSelectorContent'; + +export default function DashboardOrganizationsSettingsPage() { + return ( +
+
+ + ← Back to app + +
+ +
+ ); +} diff --git a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx index 7ac3bb3..7763f07 100644 --- a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx +++ b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { authApi } from '@/lib/api/auth'; -import type { SubscriptionAlertData } from '@/types'; +import type { SubscriptionAlertData } from '@/types/subscription'; export default function SubscriptionsSettingsPage() { const { currentOrganization } = useAuth(); @@ -39,9 +39,24 @@ export default function SubscriptionsSettingsPage() { const plan = currentOrganization.plan; const maxUsers = plan?.maxUsers; + const isUnlimited = typeof maxUsers === 'number' && maxUsers >= 999999; + const seatsUsed = alert?.seatsUsed; + const seatsRemaining = + typeof seatsUsed === 'number' && typeof maxUsers === 'number' && !isUnlimited + ? Math.max(0, maxUsers - seatsUsed) + : null; + const daysUntilPlanEnd = alert?.daysUntilPlanEnd ?? null; + const planDayTone = + daysUntilPlanEnd == null + ? 'text-text-primary' + : daysUntilPlanEnd > 20 + ? 'text-emerald-400' + : daysUntilPlanEnd >= 10 + ? 'text-amber-300' + : 'text-red-400'; return ( -
+
-
+

Current plan

{plan?.name ?? 'β€”'}

- {typeof maxUsers === 'number' && maxUsers < 999999 && ( -
-

Seats (this org)

-

- {alert?.seatsUsed ?? 'β€”'} / {maxUsers} -

-
- )} +
+

Plan price

+

+ {typeof plan?.price === 'number' ? `$${plan.price}` : 'β€”'} +

+
+
+

Seats used

+

+ {typeof seatsUsed === 'number' ? seatsUsed : 'β€”'} + {typeof maxUsers === 'number' ? ` / ${isUnlimited ? 'Unlimited' : maxUsers}` : ''} +

+
+
+

Seats remaining

+

+ {isUnlimited ? 'Unlimited' : seatsRemaining ?? 'β€”'} +

+
+
+

Days remaining

+

+ {daysUntilPlanEnd ?? 'β€”'} +

+
{alert?.showWarning && ( 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..9249c3f 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -1,10 +1,501 @@ -export default function StaffPage() { +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { + firstAccessibleDashboardPath, + canEditStaff, + canViewStaff, +} from '@/shared/permissions'; +import { + STAFF_FEATURE_GROUPS, + permissionNamesFromFeatureState, + emptyFeaturePermissionState, + featureStateFromPermissionNames, + formatAccessSummary, + type FeaturePermState, +} from './staff-permission-form'; +import { UserPlus, Pencil, Trash2, Copy, Check, X, Clock3 } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; +import { Button } from '@/components/ui/common/Button'; +import { Input } from '@/components/ui/common/Input'; +import { Checkbox } from '@/components/ui/common/Checkbox'; +import type { ApiError } from '@/types/api'; + +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 [copiedInviteLink, setCopiedInviteLink] = useState(false); + const [lastInviteInfo, setLastInviteInfo] = useState<{ + name: string; + email: string; + invitationUrl: string | null; + invitationStatus: 'PENDING' | 'ACCEPTED'; + } | 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(''); + 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, + }); + setLastInviteInfo({ + name: displayName, + email: res.data.email, + invitationUrl: res.data.invitationUrl, + invitationStatus: res.data.invitationStatus, + }); + 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)); + } + } + + 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.name} ({lastInviteInfo.email}) was invited. + {lastInviteInfo.invitationStatus === 'PENDING' + ? ' Invitation is pending until they open the link, set a password, and log in.' + : ' Invitation was accepted immediately.'} +

+ {lastInviteInfo.invitationUrl && ( +
+

+ Invite link +

+
+ + {lastInviteInfo.invitationUrl} + + +
+

+ Share this link manually via SMS or email. They must set password first. +

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

Loading team…

+ ) : ( +
+ + + + + + + + + {canEdit && } + + + + {members.map((m) => ( + + + + + + + {canEdit && ( + + )} + + ))} + +
NameEmailRoleStatusAccessActions
{m.name}{m.email} + {m.isOwner ? ( + Owner + ) : ( + Staff + )} + + {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( + + Active + + ) : m.invitationStatus === 'PENDING' ? ( + + + Pending + + ) : ( + + Expired + + )} + + {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/app/(public)/accept-invite/page.tsx b/frontend/src/app/(public)/accept-invite/page.tsx new file mode 100644 index 0000000..7f2553f --- /dev/null +++ b/frontend/src/app/(public)/accept-invite/page.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Suspense } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Button } from '@/components/ui/common/Button'; +import { Input } from '@/components/ui/common/Input'; +import { staffApi } from '@/lib/api/staff'; + +function AcceptInviteContent() { + const params = useSearchParams(); + const router = useRouter(); + const token = useMemo(() => params.get('token') || '', [params]); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [inviteInfo, setInviteInfo] = useState<{ + email: string; + name: string; + organizationName: string; + expiresAt: string; + status: 'PENDING' | 'ACCEPTED'; + } | null>(null); + + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + useEffect(() => { + if (!token) { + setLoading(false); + setError('Invalid invitation link'); + return; + } + + void (async () => { + setLoading(true); + setError(''); + try { + const res = await staffApi.previewInvite(token); + setInviteInfo(res.data); + setName(res.data.name || ''); + if (res.data.status === 'ACCEPTED') { + setSuccess('This invitation is already accepted. You can log in now.'); + } + } catch (e: any) { + setError(e?.message || 'Could not load invitation'); + } finally { + setLoading(false); + } + })(); + }, [token]); + + async function onAccept() { + if (!token) return; + setError(''); + setSuccess(''); + if (!name.trim()) { + setError('Name is required'); + return; + } + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + setSubmitting(true); + try { + await staffApi.acceptInvite({ + token, + name: name.trim(), + password, + }); + setSuccess('Invitation accepted. Redirecting to login...'); + setTimeout(() => { + router.replace('/login'); + }, 1000); + } catch (e: any) { + setError(e?.message || 'Could not accept invitation'); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+

Accept invitation

+ + {loading ? ( +

Loading invitation...

+ ) : ( + <> + {inviteInfo && ( +
+

+ Organization: {inviteInfo.organizationName} +

+

+ Email: {inviteInfo.email} +

+
+ )} + + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {inviteInfo?.status !== 'ACCEPTED' && ( +
+ setName(e.target.value)} /> + setPassword(e.target.value)} + /> + setConfirmPassword(e.target.value)} + /> + +
+ )} + +

+ Already have access? Go to login +

+ + )} +
+
+ ); +} + +export default function AcceptInvitePage() { + return ( + +

Loading invitation...

+
+ } + > + + + ); +} diff --git a/frontend/src/app/(public)/login/page.tsx b/frontend/src/app/(public)/login/page.tsx index b546fa8..280d948 100644 --- a/frontend/src/app/(public)/login/page.tsx +++ b/frontend/src/app/(public)/login/page.tsx @@ -116,8 +116,8 @@ import Link from 'next/link'; import { Mail, Lock } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; -import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/common/Button'; +import { Input } from '@/components/ui/common/Input'; const loginSchema = z.object({ email: z.string().email('Please enter a valid email address'), diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index b05dd4e..be60d10 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -2,8 +2,8 @@ import Link from 'next/link'; import { useAuth } from '@/lib/hooks/useAuth'; -import { Button } from '@/components/ui/Button'; -import { ThemeToggle } from '@/components/ui/ThemeToggle'; +import { Button } from '@/components/ui/common/Button'; +import { ThemeToggle } from '@/components/ui/common/ThemeToggle'; import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react'; export default function HomePage() { diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx index 03fc30b..1d32b65 100644 --- a/frontend/src/app/(public)/register/page.tsx +++ b/frontend/src/app/(public)/register/page.tsx @@ -7,8 +7,8 @@ import * as z from 'zod'; import Link from 'next/link'; import { Building2, Mail, Lock, User, ChevronRight } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; -import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/common/Button'; +import { Input } from '@/components/ui/common/Input'; const registerSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Please enter a valid email address'), diff --git a/frontend/src/app/(public)/select-organization/page.tsx b/frontend/src/app/(public)/select-organization/page.tsx index 4e9f86d..5307a43 100644 --- a/frontend/src/app/(public)/select-organization/page.tsx +++ b/frontend/src/app/(public)/select-organization/page.tsx @@ -1,170 +1,12 @@ 'use client'; -import { useState } from 'react'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { Building2, Beaker, Mail, Plus } from 'lucide-react'; -import { Input } from '@/components/ui/Input'; -import { Button } from '@/components/ui/Button'; +import { OrganizationSelectorContent } from '@/components/ui/organization/OrganizationSelectorContent'; export default function SelectOrganizationPage() { - const { organizations, selectOrganization, createOrganization, isLoading, error, clearError } = useAuth(); - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [organizationName, setOrganizationName] = useState(''); - const [organizationEmail, setOrganizationEmail] = useState(''); - const [organizationType, setOrganizationType] = useState<'CLINIC' | 'LAB'>('CLINIC'); - - const getIcon = (type: string) => { - return type === 'CLINIC' - ? - : ; - }; - - const handleCreateOrganization = async () => { - try { - clearError(); - const createdId = await createOrganization( - organizationName.trim(), - organizationEmail.trim(), - organizationType, - ); - setOrganizationName(''); - setOrganizationEmail(''); - setOrganizationType('CLINIC'); - setIsCreateOpen(false); - await selectOrganization(createdId); - } catch { - // Error is already handled in auth context. - } - }; - - if (isLoading) { - return ( -
-

Loading...

-
- ); - } - return (
-
-
-

Organizations

-

- Select an organization to continue, or create a new one. -

-
- -
- - {isCreateOpen && ( -
- setOrganizationName(event.target.value)} - placeholder="Sunshine Dental Clinic" - icon={} - /> - setOrganizationEmail(event.target.value)} - placeholder="contact@sunshineclinic.com" - type="email" - icon={} - /> -
- -
- - -
-
- {error && ( -
-

{error}

-
- )} -
- -
-
- )} - - {!organizations.length ? ( -
-

No organizations found. Create your first one to continue.

-
- ) : ( -
- {organizations.map((org) => ( - - ))} -
- )} +
); diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/common/Badge.tsx similarity index 100% rename from frontend/src/components/ui/Badge.tsx rename to frontend/src/components/ui/common/Badge.tsx diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/common/Button.tsx similarity index 100% rename from frontend/src/components/ui/Button.tsx rename to frontend/src/components/ui/common/Button.tsx diff --git a/frontend/src/components/ui/common/Checkbox.tsx b/frontend/src/components/ui/common/Checkbox.tsx new file mode 100644 index 0000000..2aa1114 --- /dev/null +++ b/frontend/src/components/ui/common/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/Input.tsx b/frontend/src/components/ui/common/Input.tsx similarity index 100% rename from frontend/src/components/ui/Input.tsx rename to frontend/src/components/ui/common/Input.tsx diff --git a/frontend/src/components/ui/OrganizationCard.tsx b/frontend/src/components/ui/common/OrganizationCard.tsx similarity index 95% rename from frontend/src/components/ui/OrganizationCard.tsx rename to frontend/src/components/ui/common/OrganizationCard.tsx index 4dd5841..0d67055 100644 --- a/frontend/src/components/ui/OrganizationCard.tsx +++ b/frontend/src/components/ui/common/OrganizationCard.tsx @@ -1,7 +1,7 @@ // src/components/ui/OrganizationCard.tsx import React from 'react'; import { Building2, Beaker, ChevronRight } from 'lucide-react'; -import { Organization } from '@/types'; +import type { Organization } from '@/types/organization'; interface OrganizationCardProps { organization: Organization; diff --git a/frontend/src/components/ui/Sidebar.tsx b/frontend/src/components/ui/common/Sidebar.tsx similarity index 58% rename from frontend/src/components/ui/Sidebar.tsx rename to frontend/src/components/ui/common/Sidebar.tsx index 79d8d82..2a4e464 100644 --- a/frontend/src/components/ui/Sidebar.tsx +++ b/frontend/src/components/ui/common/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 '@/shared/permissions'; 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 (