From 1b8856f64efc5b9888aa183152b457868a102cf5 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 30 Apr 2026 12:55:39 +0330 Subject: [PATCH] feature: sending invitation link for newly created staff implemented. --- .../migration.sql | 29 +++ backend/prisma/schema.prisma | 23 +++ backend/src/modules/auth/auth.service.ts | 35 +++- .../staff/dto/accept-staff-invite.dto.ts | 14 ++ .../staff/dto/preview-staff-invite.dto.ts | 6 + backend/src/modules/staff/staff.controller.ts | 20 +- backend/src/modules/staff/staff.service.ts | 173 +++++++++++++++++- frontend/src/app/(dashboard)/staff/page.tsx | 92 +++++----- .../src/app/(public)/accept-invite/page.tsx | 166 +++++++++++++++++ frontend/src/lib/api/staff.ts | 33 +++- 10 files changed, 527 insertions(+), 64 deletions(-) create mode 100644 backend/prisma/migrations/20260430091832_staff_invites_activation/migration.sql create mode 100644 backend/src/modules/staff/dto/accept-staff-invite.dto.ts create mode 100644 backend/src/modules/staff/dto/preview-staff-invite.dto.ts create mode 100644 frontend/src/app/(public)/accept-invite/page.tsx 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/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 9e2814e..311023d 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -135,7 +135,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' @@ -149,7 +149,7 @@ export class AuthService { maxUsers: membership.organization.plan.maxUsers, } : undefined, - })) || []; + })); return { success: true, @@ -342,7 +342,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, @@ -356,7 +356,7 @@ export class AuthService { maxUsers: membership.organization.plan.maxUsers, } : undefined, - })) || []; + })); return { success: true, @@ -462,7 +462,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, @@ -476,7 +476,7 @@ export class AuthService { maxUsers: membership.organization.plan.maxUsers, } : undefined, - })) || []; + })); return { success: true, @@ -668,7 +668,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, @@ -682,7 +682,7 @@ export class AuthService { maxUsers: membership.organization.plan.maxUsers, } : undefined, - })) || []; + })); return { success: true, @@ -722,6 +722,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 = { @@ -763,6 +766,17 @@ export class AuthService { }; } + private toActiveOrganizations( + memberships: Array<{ + isOwner: boolean; + isActive: boolean; + organization: { id: string; name: string; type: { name: string }; plan?: { name: string; maxUsers: 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). @@ -805,7 +819,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; 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/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/staff.controller.ts b/backend/src/modules/staff/staff.controller.ts index a3641ad..df4ba8d 100644 --- a/backend/src/modules/staff/staff.controller.ts +++ b/backend/src/modules/staff/staff.controller.ts @@ -6,23 +6,38 @@ import { 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') -@UseGuards(JwtAuthGuard) @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); @@ -30,6 +45,7 @@ export class StaffController { } @Post('invite') + @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Invite staff (requires TAB_STAFF_EDIT or owner)' }) invite( @Req() req: { user: { id: string; organizationId?: string } }, @@ -40,6 +56,7 @@ export class StaffController { } @Patch('members/:membershipId') + @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Update staff member name and/or permissions' }) updateMember( @Req() req: { user: { id: string; organizationId?: string } }, @@ -51,6 +68,7 @@ export class StaffController { } @Delete('members/:membershipId') + @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Remove staff member from organization' }) removeMember( @Req() req: { user: { id: string; organizationId?: string } }, diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index f8729af..59880a9 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -6,8 +6,9 @@ import { NotFoundException, } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; -import { randomBytes } from 'crypto'; +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'; @@ -43,10 +44,19 @@ export class StaffService { 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 } }), + this.prisma.membership.count({ + where: { + organizationId, + OR: [{ isOwner: true }, { isActive: true }], + }, + }), ]); const maxUsers = org.plan.maxUsers; @@ -61,6 +71,10 @@ export class StaffService { 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), @@ -93,7 +107,8 @@ export class StaffService { throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`); } - let temporaryPassword: string | null = null; + const plainToken = this.generateInviteToken(); + const tokenHash = this.hashInviteToken(plainToken); const result = await this.prisma.$transaction(async (tx) => { const org = await tx.organization.findUnique({ @@ -105,7 +120,12 @@ export class StaffService { } const maxUsers = org.plan.maxUsers; - const seatsUsed = await tx.membership.count({ where: { organizationId } }); + 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.`, @@ -132,13 +152,11 @@ export class StaffService { } 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, + passwordHash: null, }, }); targetUserId = created.id; @@ -149,6 +167,7 @@ export class StaffService { userId: targetUserId, organizationId, isOwner: false, + isActive: existingUser ? true : false, }, }); @@ -161,7 +180,29 @@ export class StaffService { }); } - return { membershipId: membership.id, userId: targetUserId }; + 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 { @@ -170,11 +211,69 @@ export class StaffService { membershipId: result.membershipId, userId: result.userId, email, - temporaryPassword, + 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, @@ -267,6 +366,62 @@ export class StaffService { }); } + 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 } }[]; diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index e8dcf7c..888eee8 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -15,7 +15,7 @@ import { formatAccessSummary, type FeaturePermState, } from './staff-permission-form'; -import { UserPlus, Pencil, Trash2, Copy, Check, X } from 'lucide-react'; +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/Button'; @@ -105,12 +105,12 @@ export default function StaffPage() { 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 [copiedInviteLink, setCopiedInviteLink] = useState(false); const [lastInviteInfo, setLastInviteInfo] = useState<{ name: string; email: string; - isNewAccount: boolean; + invitationUrl: string | null; + invitationStatus: 'PENDING' | 'ACCEPTED'; } | null>(null); const [editing, setEditing] = useState(null); @@ -159,7 +159,6 @@ export default function StaffPage() { async function submitInvite() { setInviteLoading(true); setError(''); - setLastTempPassword(null); setLastInviteInfo(null); const displayName = inviteName.trim(); const displayEmail = inviteEmail.trim(); @@ -170,12 +169,11 @@ export default function StaffPage() { name: displayName, permissionNames, }); - const isNew = Boolean(res.data.temporaryPassword); - setLastTempPassword(res.data.temporaryPassword); setLastInviteInfo({ name: displayName, email: res.data.email, - isNewAccount: isNew, + invitationUrl: res.data.invitationUrl, + invitationStatus: res.data.invitationStatus, }); setSuccess(''); setInviteOpen(false); @@ -235,17 +233,6 @@ export default function StaffPage() { } } - 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…

@@ -266,7 +253,6 @@ export default function StaffPage() { size="sm" onClick={() => { setInviteOpen(true); - setLastTempPassword(null); setLastInviteInfo(null); }} disabled={atSeatLimit} @@ -313,44 +299,45 @@ export default function StaffPage() { aria-label="Dismiss" onClick={() => { setLastInviteInfo(null); - setLastTempPassword(null); }} >

- {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. - - )} + {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.'}

- {lastTempPassword && ( + {lastInviteInfo.invitationUrl && (

- Temporary password (copy now — not stored) + Invite link

- - {lastTempPassword} + + {lastInviteInfo.invitationUrl} -

- They should sign in with this password once, then change it under account settings. + Share this link manually via SMS or email. They must set password first.

)} @@ -367,6 +354,7 @@ export default function StaffPage() { Name Email Role + Status Access {canEdit && Actions} @@ -383,6 +371,22 @@ export default function StaffPage() { Staff )} + + {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( + + Active + + ) : m.invitationStatus === 'PENDING' ? ( + + + Pending + + ) : ( + + Expired + + )} + {m.isOwner ? ( All features 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..d367368 --- /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/Button'; +import { Input } from '@/components/ui/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/lib/api/staff.ts b/frontend/src/lib/api/staff.ts index faea3d1..25bfee6 100644 --- a/frontend/src/lib/api/staff.ts +++ b/frontend/src/lib/api/staff.ts @@ -6,6 +6,10 @@ export interface StaffMemberDto { email: string; name: string; isOwner: boolean; + isActive: boolean; + invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED'; + invitedAt: string | null; + acceptedAt: string | null; permissions: string[] | null; } @@ -27,7 +31,20 @@ export interface InviteStaffResponse { membershipId: string; userId: string; email: string; - temporaryPassword: string | null; + invitationId: string | null; + invitationUrl: string | null; + invitationStatus: 'PENDING' | 'ACCEPTED'; + }; +} + +export interface PreviewInviteResponse { + success: boolean; + data: { + email: string; + name: string; + organizationName: string; + expiresAt: string; + status: 'PENDING' | 'ACCEPTED'; }; } @@ -46,6 +63,20 @@ export const staffApi = { return response.data; }, + previewInvite: async (token: string): Promise => { + const response = await apiClient.get(`/staff/invitations/preview?token=${encodeURIComponent(token)}`); + return response.data; + }, + + acceptInvite: async (body: { + token: string; + password: string; + name: string; + }): Promise<{ success: boolean; message: string; data: { email: string } }> => { + const response = await apiClient.post('/staff/invitations/accept', body); + return response.data; + }, + updateMember: async ( membershipId: string, body: { name?: string; permissionNames?: string[] },