From 988f6ca808545af3d36ffcc6f9b5f14507dbd55c Mon Sep 17 00:00:00 2001 From: chko0 Date: Fri, 17 Jul 2026 22:28:30 +0300 Subject: [PATCH 1/6] Implement event registration and member portal updates --- MEMBER_MANAGEMENT_TEST_PLAN.md | 866 ++++++++++++++++++ apps/api/src/members/members.controller.ts | 136 ++- apps/api/src/members/members.module.ts | 3 + apps/api/src/members/members.service.ts | 582 +++++++++++- apps/web/app/(authenticated)/members/page.tsx | 586 +++++++++++- apps/web/app/invite/accept/page.tsx | 110 +++ apps/web/hooks/use-auth.ts | 17 +- apps/web/hooks/use-members.ts | 160 +++- apps/web/proxy.ts | 8 +- packages/contracts/src/members/index.ts | 9 + .../src/members/member-action.response.ts | 7 + .../src/members/member-create.request.ts | 9 + .../member-invitation-accept.request.ts | 8 + .../member-invitation-accept.response.ts | 9 + .../member-invitation-action.response.ts | 9 + .../member-invitation-list.response.ts | 10 + .../src/members/member-invitation.response.ts | 16 + .../src/members/member-invite.request.ts | 10 + .../src/members/member-update.request.ts | 12 + .../contracts/src/members/member.response.ts | 2 + .../migration.sql | 35 + packages/database/prisma/schema.prisma | 25 + 22 files changed, 2581 insertions(+), 48 deletions(-) create mode 100644 MEMBER_MANAGEMENT_TEST_PLAN.md create mode 100644 apps/web/app/invite/accept/page.tsx create mode 100644 packages/contracts/src/members/member-action.response.ts create mode 100644 packages/contracts/src/members/member-create.request.ts create mode 100644 packages/contracts/src/members/member-invitation-accept.request.ts create mode 100644 packages/contracts/src/members/member-invitation-accept.response.ts create mode 100644 packages/contracts/src/members/member-invitation-action.response.ts create mode 100644 packages/contracts/src/members/member-invitation-list.response.ts create mode 100644 packages/contracts/src/members/member-invitation.response.ts create mode 100644 packages/contracts/src/members/member-invite.request.ts create mode 100644 packages/contracts/src/members/member-update.request.ts create mode 100644 packages/database/prisma/migrations/20260717183454_add_member_invitations/migration.sql diff --git a/MEMBER_MANAGEMENT_TEST_PLAN.md b/MEMBER_MANAGEMENT_TEST_PLAN.md new file mode 100644 index 00000000..10baf469 --- /dev/null +++ b/MEMBER_MANAGEMENT_TEST_PLAN.md @@ -0,0 +1,866 @@ +# Member Management CRUD and Invitation Flow Test Plan + +## Scope + +This plan verifies the member management CRUD and invitation features: + +- Active member listing +- Create member without login +- Edit member username and role +- Delete member +- Pending invitation listing +- Invite member by email +- Resend invitation +- Cancel invitation +- Accept invitation from email link +- Tenant isolation and role-based access +- Event behavior after member role changes + +## Prerequisites + +Start from a clean, migrated local environment. + +```bash +npm run services:init +npm install +npx turbo run db:generate +npx turbo run db:deploy +npx turbo run db:seed +npm run dev +``` + +Expected local services: + +- Web: `http://localhost:3000` +- API: `http://localhost:3001` +- Mailpit: `http://localhost:8025` +- Postgres: `localhost:5433` +- Redis: `localhost:6380` + +Seeded accounts: + +- Super admin: `admin@bootcamp-starter.local` +- TechCorp org admin: `admin@techcorp.example.com` +- TechCorp member: `member@techcorp.example.com` + +Sign in through `http://localhost:3000/login`, then open the newest magic-link email in Mailpit. + +## Pre-Flight Verification + +Run the quality gates before manual testing: + +```bash +npx turbo run check-types +npx turbo run lint +npm run format:check +``` + +Expected result: all commands pass. + +Also verify the migration exists: + +```bash +dir packages\database\prisma\migrations +``` + +Expected result: a migration named similar to `*_add_member_invitations` is present. + +## Role Access Matrix + +| User | Expected `/members` Access | Notes | +| --------------- | -------------------------- | ----------------------------------------------------------------------- | +| `SUPER_ADMIN` | Allowed | Can view members across orgs and must select org when creating/inviting | +| `ORG_ADMIN` | Allowed | Scoped to their own organization | +| auth `MEMBER` | Denied | Should see access denied page | +| Unauthenticated | Redirected | Should redirect to `/login` | + +## Manual UI Tests + +### 1. Org Admin Can Open Member Management + +1. Sign in as `admin@techcorp.example.com`. +2. Go to `http://localhost:3000/members`. + +Expected: + +- Page loads without errors. +- Active Members table is visible. +- Pending Invitations section is visible. +- Create and Invite buttons are visible. +- Organization selector is not shown in create/invite dialogs. + +### 2. Super Admin Can Open Member Management + +1. Sign in as `admin@bootcamp-starter.local`. +2. Go to `http://localhost:3000/members`. +3. Open Create dialog. +4. Open Invite dialog. + +Expected: + +- Page loads without errors. +- Active Members table may show members across organizations. +- Create and Invite dialogs include an Organization selector. +- Organization selector lists active organizations. + +### 3. Auth Member Cannot Manage Members + +1. Sign in as `member@techcorp.example.com`. +2. Go to `http://localhost:3000/members`. + +Expected: + +- Access denied page is shown. +- Member table and mutation buttons are not shown. + +### 4. Create Presenter Member Without Login + +1. Sign in as TechCorp org admin. +2. Open Members. +3. Click Create. +4. Enter username: `manual-presenter-1`. +5. Select role: `Presenter`. +6. Submit. + +Expected: + +- Success toast appears. +- Dialog closes. +- New member appears in Active Members. +- Email column shows `No login`. +- Role badge shows `Presenter`. + +### 5. Create Admin Member Without Login + +1. Click Create. +2. Enter username: `manual-admin-1`. +3. Select role: `Admin`. +4. Submit. + +Expected: + +- Success toast appears. +- New member appears. +- Role badge shows `Admin`. +- Email column shows `No login`. + +### 6. Duplicate Username Is Blocked + +1. Click Create. +2. Enter username: `manual-presenter-1`. +3. Select any role. +4. Submit. + +Expected: + +- Error toast appears. +- No duplicate row is added. +- API should return a conflict-style error. + +### 7. Edit Member Username + +1. Find `manual-presenter-1`. +2. Click edit. +3. Change username to `manual-presenter-1-renamed`. +4. Save. + +Expected: + +- Success toast appears. +- Table updates with the new username. +- Role remains unchanged. + +### 8. Edit Member Role + +1. Find `manual-presenter-1-renamed`. +2. Click edit. +3. Change role to `Admin`. +4. Save. + +Expected: + +- Success toast appears. +- Role badge changes to `Admin`. + +### 9. Delete Member + +1. Find `manual-admin-1`. +2. Click delete. +3. Confirm browser confirmation. + +Expected: + +- Success toast appears. +- Member disappears from Active Members. +- Refreshing the page does not bring the member back. + +### 10. Cancel Delete + +1. Find any member. +2. Click delete. +3. Cancel browser confirmation. + +Expected: + +- No API request should complete. +- Member remains in table. +- No success toast appears. + +## Invitation Flow Tests + +Use unique emails for each run, for example: + +- `invite-presenter-001@example.com` +- `invite-admin-001@example.com` +- `invite-cancel-001@example.com` + +### 11. Invite Presenter + +1. Sign in as TechCorp org admin. +2. Open Members. +3. Click Invite. +4. Enter email: `invite-presenter-001@example.com`. +5. Enter username: `invite-presenter-001`. +6. Select role: `Presenter`. +7. Submit. + +Expected: + +- Success toast appears. +- Invitation appears in Pending Invitations. +- Mailpit receives an invitation email. +- Active Members does not contain `invite-presenter-001` yet. + +### 12. Invite Admin Domain Member + +1. Click Invite. +2. Enter email: `invite-admin-001@example.com`. +3. Enter username: `invite-admin-001`. +4. Select role: `Admin`. +5. Submit. + +Expected: + +- Success toast appears. +- Invitation appears in Pending Invitations with role `Admin`. +- Mailpit receives an invitation email. + +Important expected behavior after acceptance: + +- The auth user role should be `MEMBER`. +- The Coordly member role should be `ADMIN`. +- This user should not become auth `ORG_ADMIN`. + +### 13. Duplicate Pending Invite Email Is Blocked + +1. Click Invite. +2. Enter email: `invite-presenter-001@example.com`. +3. Enter username: `some-other-username`. +4. Submit. + +Expected: + +- Error toast appears. +- No second pending invite is created. + +### 14. Duplicate Pending Invite Username Is Blocked + +1. Click Invite. +2. Enter email: `some-other-email@example.com`. +3. Enter username: `invite-presenter-001`. +4. Submit. + +Expected: + +- Error toast appears. +- No second pending invite is created. + +### 15. Resend Invitation + +1. In Pending Invitations, find `invite-presenter-001@example.com`. +2. Click resend. +3. Open Mailpit. + +Expected: + +- Success toast appears. +- A new invitation email is delivered. +- The old invitation link should no longer be usable if token rotation is enforced. +- The newest invitation link should remain usable. + +### 16. Cancel Invitation + +1. Invite `invite-cancel-001@example.com` with username `invite-cancel-001`. +2. Confirm it appears in Pending Invitations. +3. Click cancel. +4. Confirm browser confirmation. + +Expected: + +- Success toast appears. +- Invitation disappears from Pending Invitations. +- Invitation link from Mailpit cannot be accepted. + +### 17. Accept Presenter Invitation + +1. Open the newest invitation email for `invite-presenter-001@example.com` in Mailpit. +2. Click the invitation link. + +Expected: + +- Browser opens `/invite/accept?token=...`. +- Acceptance page shows loading, then success. +- User is redirected to `/dashboard`. +- User is authenticated. +- Sidebar should show member-level navigation, not org-admin navigation. +- `/members` should show access denied for this invited user. + +Then sign back in as TechCorp org admin and verify: + +- `invite-presenter-001` appears in Active Members. +- Email column shows `invite-presenter-001@example.com`. +- The invitation no longer appears in Pending Invitations. + +### 18. Accept Admin Domain Member Invitation + +1. Open the newest invitation email for `invite-admin-001@example.com`. +2. Click the invitation link. +3. After redirect, inspect navigation. + +Expected: + +- User is signed in. +- User does not get `/members` access. +- User is auth `MEMBER`, not auth `ORG_ADMIN`. +- Their member role is `ADMIN`, so event attendee registration rules should treat them as a Coordly admin member. + +### 19. Accept Same Invitation Twice + +1. Copy an invitation link. +2. Open it once and accept it. +3. Open the same link again in a fresh/private browser session. + +Expected: + +- First acceptance succeeds. +- Second acceptance fails with an already accepted or invalid invitation error. +- No duplicate user is created. +- No duplicate member is created. + +### 20. Missing or Invalid Token + +Open: + +- `http://localhost:3000/invite/accept` +- `http://localhost:3000/invite/accept?token=bad-token` + +Expected: + +- Missing token shows a clear error. +- Bad token shows an error. +- No session is created. + +## Existing User Invite Tests + +### 21. Invite Existing Unassigned User + +If you have or create a user with no `organizationId`, invite that email. + +Expected: + +- Invite can be sent. +- Accepting the invite assigns the user to the inviting organization. +- A linked `Member` row is created. + +### 22. Invite User Already In Same Organization But Not Linked To Member + +If a user belongs to the same organization and has no linked `Member` row: + +Expected: + +- Invite can be sent. +- Accepting creates the linked `Member` row. +- User keeps their existing auth role unless product rules say otherwise. + +### 23. Invite User Already Linked To A Member + +Try inviting an email for a user already linked through `Member.userId`. + +Expected: + +- Invite is blocked. +- No pending invitation is created. + +### 24. Invite User In Another Organization + +Try inviting a user whose `organizationId` belongs to a different org. + +Expected: + +- Invite is blocked. +- Error explains the user already belongs to another organization. + +### 25. Invite Super Admin + +Try inviting `admin@bootcamp-starter.local`. + +Expected: + +- Invite is blocked. +- Super admin is not converted or linked as a member. + +## Tenant Isolation Tests + +These are critical. + +### 26. Org Admin Cannot See Another Org's Members + +1. Sign in as TechCorp org admin. +2. Open `/members`. + +Expected: + +- Only TechCorp members are visible. +- No members from Green Energy or other organizations appear. + +### 27. Org Admin Cannot Use `organizationId` Query To Access Another Org + +Use an API client while signed in as TechCorp org admin: + +```http +GET http://localhost:3001/members?organizationId= +``` + +Expected: + +- Request is rejected with forbidden access. + +Repeat for: + +```http +GET http://localhost:3001/members/invitations?organizationId= +``` + +Expected: + +- Request is rejected. + +### 28. Org Admin Cannot Update Another Org's Member + +Use an API client with a member ID from another org: + +```http +PATCH http://localhost:3001/members/ +Content-Type: application/json + +{ + "username": "cross-tenant-edit" +} +``` + +Expected: + +- Request returns not found or forbidden. +- Other org's member is unchanged. + +### 29. Org Admin Cannot Delete Another Org's Member + +Use an API client: + +```http +DELETE http://localhost:3001/members/ +``` + +Expected: + +- Request returns not found or forbidden. +- Other org's member remains. + +### 30. Org Admin Cannot Resend Or Cancel Another Org's Invitation + +Use another org's invitation ID: + +```http +POST http://localhost:3001/members/invitations//resend +DELETE http://localhost:3001/members/invitations/ +``` + +Expected: + +- Request returns not found or forbidden. +- Invitation remains unchanged. + +## Super Admin Tests + +### 31. Super Admin Must Select Organization For Create + +1. Sign in as super admin. +2. Open Create dialog. +3. Enter username and role but do not select organization. +4. Submit. + +Expected: + +- Request fails with `organizationId is required`. +- No member is created. + +### 32. Super Admin Must Select Organization For Invite + +1. Open Invite dialog as super admin. +2. Fill email, username, role but do not select organization. +3. Submit. + +Expected: + +- Request fails with `organizationId is required`. +- No invitation is created. + +### 33. Super Admin Creates Member In Selected Org + +1. Open Create dialog. +2. Select TechCorp. +3. Create username `super-created-techcorp-member`. + +Expected: + +- Member appears. +- Organization ID corresponds to selected org. + +### 34. Super Admin Invites Member To Selected Org + +1. Open Invite dialog. +2. Select TechCorp. +3. Invite `super-invite-techcorp@example.com`. + +Expected: + +- Invitation appears. +- Email is sent. +- Accepting link creates member in TechCorp. + +## Event Rule Regression Tests + +### 35. Created Presenter Can Be Used As Event Presenter + +If create/edit event UI exists later, verify presenter members are selectable as presenters. If only seed/API paths exist, verify event detail still displays presenter names normally after member CRUD changes. + +Expected: + +- Existing event pages load. +- Existing presenters still appear. +- Deleting a presenter sets event presenter to null or equivalent safe display, not a crash. + +### 36. Invited Presenter Cannot Register For Own Hosted Event + +If an invited presenter is assigned as an event presenter: + +Expected: + +- They can manage attendance for hosted event where existing logic allows. +- They cannot register as attendee for their own hosted event. + +### 37. MemberRole.ADMIN Cannot Register As Attendee + +1. Accept an invitation with role `Admin`. +2. Sign in as that invited user. +3. Open an upcoming event. + +Expected: + +- Event registration is unavailable or blocked. +- User is still not auth `ORG_ADMIN`. + +### 38. Regular Invited Presenter Can Register For Other Events + +1. Accept presenter invitation. +2. Open an upcoming event they are not hosting. + +Expected: + +- Registration is allowed when the event is in their organization. +- Duplicate registration is still prevented. + +## API Contract Tests + +Use an API client with cookies from a valid signed-in browser session. + +### 39. Create Member Request Validation + +Invalid payloads: + +```json +{} +``` + +```json +{ "username": "", "role": "PRESENTER" } +``` + +```json +{ "username": "valid", "role": "MEMBER" } +``` + +Expected: + +- Requests fail validation. +- No member is created. + +### 40. Update Member Request Validation + +Invalid payloads: + +```json +{} +``` + +```json +{ "role": "ORG_ADMIN" } +``` + +Expected: + +- Requests fail validation. +- Member is unchanged. + +### 41. Invite Request Validation + +Invalid payloads: + +```json +{ "email": "not-an-email", "username": "bad", "role": "PRESENTER" } +``` + +```json +{ "email": "valid@example.com", "username": "", "role": "PRESENTER" } +``` + +```json +{ "email": "valid@example.com", "username": "valid", "role": "SUPER_ADMIN" } +``` + +Expected: + +- Requests fail validation. +- No invitation is created. + +### 42. Accept Invite Request Validation + +Invalid payloads: + +```json +{} +``` + +```json +{ "token": "" } +``` + +Expected: + +- Requests fail validation. +- No user/member/session is created. + +## Database Integrity Checks + +Run these checks with a SQL client or Prisma Studio after manual tests. + +### 43. Invitation Token Is Not Stored Raw + +Inspect `private.MemberInvitation`. + +Expected: + +- Table has `tokenHash`. +- Raw emailed token is not stored. + +### 44. Accepted Invitation Has Linked User And Member + +For an accepted invitation: + +Expected: + +- `acceptedAt` is set. +- A `User` exists with invited email. +- `User.organizationId` equals invitation org. +- A `Member` exists with `userId` equal to accepted user ID. +- `Member.organizationId` equals invitation org. + +### 45. Canceled Invitation Has No Member + +For a canceled invitation: + +Expected: + +- `revokedAt` is set. +- No user/member is created solely from that canceled invite. + +### 46. Deleted Presenter Does Not Break Events + +1. Delete a presenter member who is assigned to an event. +2. Open that event detail page. + +Expected: + +- Page loads. +- Presenter field is blank, null, or safely displayed. +- No API 500 occurs. + +## Mailpit Tests + +### 47. Invitation Email Content + +Open a sent invitation email. + +Expected: + +- Subject mentions the organization. +- Body contains inviter name. +- Body contains organization name. +- Body contains invitation link. +- Expiry text says 7 days. + +### 48. Resend Email Creates New Message + +After clicking resend: + +Expected: + +- Mailpit message count increases. +- Newest email contains a working invite link. + +## Session Behavior Tests + +### 49. Accept Invite Creates Session + +After accepting an invite: + +Expected: + +- Browser has `bootcamp_starter_session` cookie. +- `/dashboard` loads without redirect to login. +- `/auth/me` returns the invited user. + +### 50. Accept Invite While Already Signed In + +1. Sign in as one user. +2. Open a different user's invitation link. + +Expected: + +- Invitation acceptance should either switch to the invited user session or fail clearly. +- No mixed identity state should occur. +- After acceptance, `/auth/me` should match the invited email if acceptance succeeded. + +## Regression Tests + +### 51. Existing Login Still Works + +Verify magic-link login still works for: + +- `admin@bootcamp-starter.local` +- `admin@techcorp.example.com` +- `member@techcorp.example.com` + +Expected: + +- Magic link emails still send. +- Verification still creates session. +- Role-based navigation remains correct. + +### 52. Existing Members Page Loads With Seed Data + +Before creating test data: + +Expected: + +- Seeded Coordly members display correctly. +- Linked seeded users show email. +- Unlinked members show `No login`. + +### 53. Existing Events Pages Still Load + +Visit: + +- `/events` +- `/events/` + +Expected: + +- Pages load. +- Registration status and attendance controls behave as before. + +### 54. Existing Announcements Pages Still Load + +Visit `/announcements`. + +Expected: + +- Page loads. +- Role-based announcement actions still behave as before. + +### 55. Existing Reports Pages Still Load + +Visit `/reports` as org admin and super admin. + +Expected: + +- Stats load. +- Organization scoping still works. + +## Suggested Automated Test Coverage + +Add API unit or e2e tests for: + +- `POST /members` creates scoped member. +- Duplicate username returns conflict. +- `PATCH /members/:id` respects org scope. +- `DELETE /members/:id` respects org scope. +- `POST /members/invitations` rejects cross-org existing users. +- `POST /members/invitations` rejects existing linked users. +- `POST /members/invitations` queues mail job. +- `POST /members/invitations/:id/resend` rotates token hash. +- `DELETE /members/invitations/:id` sets `revokedAt`. +- `POST /members/invitations/accept` creates user/member/session. +- Accepting expired/revoked/accepted invite fails. +- Auth `MEMBER` cannot access member management endpoints. +- `ORG_ADMIN` cannot access another organization's members or invitations. + +Add web tests for: + +- Member page access denied for auth member. +- Create dialog validation. +- Invite dialog validation. +- Successful invite appears in pending table. +- Successful create appears in active table. +- Edit updates row. +- Delete removes row. +- Accept invite page handles success and error states. + +## Cleanup After Manual Testing + +For repeatable test runs, reset and reseed: + +```bash +npx turbo run db:reset +npx turbo run db:seed +``` + +If you do not want a full reset, manually delete test records: + +- Test `Member` rows with usernames starting with `manual-`, `invite-`, or `super-`. +- Test `User` rows with emails used only for invitations. +- Test `MemberInvitation` rows for those emails. + +## Pass Criteria + +The feature is ready when: + +- All quality gates pass. +- Role access matrix behaves exactly as expected. +- CRUD works for org admins and super admins. +- Invitation send/resend/cancel/accept works. +- Invite acceptance creates exactly one user and one linked member. +- Single-org restrictions are enforced. +- `MemberRole.ADMIN` does not grant auth `ORG_ADMIN` or `SUPER_ADMIN`. +- Existing auth, events, announcements, and reports flows still work. +- No cross-tenant member or invitation access is possible. diff --git a/apps/api/src/members/members.controller.ts b/apps/api/src/members/members.controller.ts index b91ec6b9..f0b069cb 100644 --- a/apps/api/src/members/members.controller.ts +++ b/apps/api/src/members/members.controller.ts @@ -1,18 +1,111 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, + Res, +} from '@nestjs/common'; +import type { Response } from 'express'; import { MembersService } from './members.service'; -import { Roles, CurrentUser } from '../auth/decorators'; +import { Roles, CurrentUser, Public } from '../auth/decorators'; +import { SESSION_COOKIE_NAME } from '../auth/guards/auth.guard'; import { ZodValidationPipe } from '../common/pipes'; import type { User } from '@repo/db'; import { + memberCreateRequestSchema, + memberInvitationAcceptRequestSchema, + memberInviteRequestSchema, memberListQuerySchema, + memberUpdateRequestSchema, + type MemberActionResponse, + type MemberCreateRequest, + type MemberInvitationAcceptRequest, + type MemberInvitationAcceptResponse, + type MemberInvitationActionResponse, + type MemberInvitationListResponse, + type MemberInviteRequest, type MemberListQuery, type MemberListResponse, + type MemberUpdateRequest, } from '@repo/contracts'; +const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + @Controller('members') export class MembersController { constructor(private readonly membersService: MembersService) {} + @Get('invitations') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async findInvitations( + @Query(new ZodValidationPipe(memberListQuerySchema)) + query: MemberListQuery, + @CurrentUser() user: User, + ): Promise { + return this.membersService.findInvitations(query, user); + } + + @Post('invitations/accept') + @Public() + @HttpCode(HttpStatus.OK) + async acceptInvitation( + @Body( + new ZodValidationPipe( + memberInvitationAcceptRequestSchema, + ), + ) + body: MemberInvitationAcceptRequest, + @Res({ passthrough: true }) response: Response, + ): Promise { + const { sessionId, user } = await this.membersService.acceptInvitation( + body.token, + ); + + response.cookie(SESSION_COOKIE_NAME, sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: SESSION_MAX_AGE_MS, + path: '/', + }); + + return { user }; + } + + @Post('invitations') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async invite( + @Body(new ZodValidationPipe(memberInviteRequestSchema)) + body: MemberInviteRequest, + @CurrentUser() user: User, + ): Promise { + return this.membersService.invite(body, user); + } + + @Post('invitations/:id/resend') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async resendInvitation( + @Param('id') id: string, + @CurrentUser() user: User, + ): Promise { + return this.membersService.resendInvitation(id, user); + } + + @Delete('invitations/:id') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async revokeInvitation( + @Param('id') id: string, + @CurrentUser() user: User, + ): Promise { + return this.membersService.revokeInvitation(id, user); + } + @Get() @Roles('SUPER_ADMIN', 'ORG_ADMIN') async findAll( @@ -22,4 +115,43 @@ export class MembersController { ): Promise { return this.membersService.findAll(query, user); } + + @Post() + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async create( + @Body(new ZodValidationPipe(memberCreateRequestSchema)) + body: MemberCreateRequest, + @CurrentUser() user: User, + ): Promise { + return this.membersService.create(body, user); + } + + @Get(':id') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async findOne( + @Param('id') id: string, + @CurrentUser() user: User, + ): Promise { + return this.membersService.findOne(id, user); + } + + @Patch(':id') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async update( + @Param('id') id: string, + @Body(new ZodValidationPipe(memberUpdateRequestSchema)) + body: MemberUpdateRequest, + @CurrentUser() user: User, + ): Promise { + return this.membersService.update(id, body, user); + } + + @Delete(':id') + @Roles('SUPER_ADMIN', 'ORG_ADMIN') + async remove( + @Param('id') id: string, + @CurrentUser() user: User, + ): Promise { + return this.membersService.remove(id, user); + } } diff --git a/apps/api/src/members/members.module.ts b/apps/api/src/members/members.module.ts index 56518462..05efa8c8 100644 --- a/apps/api/src/members/members.module.ts +++ b/apps/api/src/members/members.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common'; import { MembersService } from './members.service'; import { MembersController } from './members.controller'; +import { AuthModule } from '../auth/auth.module'; +import { MailModule } from '../mail/mail.module'; @Module({ + imports: [AuthModule, MailModule], providers: [MembersService], controllers: [MembersController], }) diff --git a/apps/api/src/members/members.service.ts b/apps/api/src/members/members.service.ts index beda6109..a3632e82 100644 --- a/apps/api/src/members/members.service.ts +++ b/apps/api/src/members/members.service.ts @@ -1,14 +1,169 @@ -import { Injectable, Logger } from '@nestjs/common'; -import type { User } from '@repo/db'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import type { Queue } from 'bullmq'; +import { createHash, randomBytes } from 'crypto'; +import type { MemberRole, User } from '@repo/db'; import { PrismaService } from '../database/prisma.service'; import { resolveOrganizationScope } from '../common/organization-scope'; -import type { MemberListQuery, MemberListResponse } from '@repo/contracts'; +import { SessionService } from '../auth/session.service'; +import { MAIL_JOBS, MAIL_QUEUE } from '../mail/mail.constants'; +import type { + Member, + MemberActionResponse, + MemberCreateRequest, + MemberInvitation, + MemberInvitationAcceptResponse, + MemberInvitationActionResponse, + MemberInviteRequest, + MemberListQuery, + MemberInvitationListResponse, + MemberListResponse, + MemberUpdateRequest, + UserResponse, +} from '@repo/contracts'; + +const INVITATION_EXPIRY_DAYS = 7; @Injectable() export class MembersService { private readonly logger = new Logger(MembersService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly sessionService: SessionService, + @InjectQueue(MAIL_QUEUE) private readonly mailQueue: Queue, + ) {} + + private hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } + + private mapMember(member: { + id: string; + username: string; + role: MemberRole; + organizationId: string; + userId?: string | null; + user?: { email: string } | null; + }): Member { + return { + id: member.id, + username: member.username, + role: member.role, + organizationId: member.organizationId, + userId: member.userId ?? null, + userEmail: member.user?.email ?? null, + }; + } + + private mapInvitation(invitation: { + id: string; + email: string; + username: string; + role: MemberRole; + organizationId: string; + invitedById: string; + expiresAt: Date; + acceptedAt: Date | null; + revokedAt: Date | null; + createdAt: Date; + }): MemberInvitation { + return { + id: invitation.id, + email: invitation.email, + username: invitation.username, + role: invitation.role, + organizationId: invitation.organizationId, + invitedById: invitation.invitedById, + expiresAt: invitation.expiresAt.toISOString(), + acceptedAt: invitation.acceptedAt?.toISOString() ?? null, + revokedAt: invitation.revokedAt?.toISOString() ?? null, + createdAt: invitation.createdAt.toISOString(), + }; + } + + private mapUserResponse( + user: Pick< + User, + 'id' | 'email' | 'name' | 'role' | 'organizationId' | 'isConfirmed' + >, + memberRole: MemberRole | null, + ): UserResponse { + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + organizationId: user.organizationId, + isConfirmed: user.isConfirmed, + memberRole, + }; + } + + private resolveRequiredOrganizationScope( + user: User, + requestedOrganizationId?: string, + ): string { + const organizationId = resolveOrganizationScope( + user, + requestedOrganizationId, + ); + + if (!organizationId) { + throw new BadRequestException('organizationId is required'); + } + + return organizationId; + } + + private async getScopedMember(id: string, user: User) { + const organizationId = resolveOrganizationScope(user); + const member = await this.prisma.member.findFirst({ + where: { + id, + ...(organizationId ? { organizationId } : {}), + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + if (!member) { + throw new NotFoundException('Member not found'); + } + + return member; + } + + private async assertUsernameAvailable( + organizationId: string, + username: string, + excludingMemberId?: string, + ): Promise { + const existing = await this.prisma.member.findFirst({ + where: { + organizationId, + username, + ...(excludingMemberId ? { id: { not: excludingMemberId } } : {}), + }, + select: { id: true }, + }); + + if (existing) { + throw new ConflictException('A member with this username already exists'); + } + } async findAll( query: MemberListQuery, @@ -26,11 +181,12 @@ export class MembersService { skip, take: limit, orderBy: { createdAt: 'desc' }, - select: { - id: true, - username: true, - role: true, - organizationId: true, + include: { + user: { + select: { + email: true, + }, + }, }, }), this.prisma.member.count({ where }), @@ -38,6 +194,412 @@ export class MembersService { this.logger.log(`Listed ${members.length} members (total: ${total})`); - return { members, total }; + return { members: members.map((member) => this.mapMember(member)), total }; + } + + async findOne(id: string, user: User): Promise { + const member = await this.getScopedMember(id, user); + return { member: this.mapMember(member) }; + } + + async create( + body: MemberCreateRequest, + user: User, + ): Promise { + const organizationId = this.resolveRequiredOrganizationScope( + user, + body.organizationId, + ); + + await this.assertUsernameAvailable(organizationId, body.username); + + const member = await this.prisma.member.create({ + data: { + username: body.username, + role: body.role, + organizationId, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + this.logger.log(`Created member ${member.id} in org ${organizationId}`); + return { member: this.mapMember(member) }; + } + + async update( + id: string, + body: MemberUpdateRequest, + user: User, + ): Promise { + const existing = await this.getScopedMember(id, user); + + if (body.username && body.username !== existing.username) { + await this.assertUsernameAvailable( + existing.organizationId, + body.username, + existing.id, + ); + } + + const member = await this.prisma.member.update({ + where: { id: existing.id }, + data: { + ...(body.username ? { username: body.username } : {}), + ...(body.role ? { role: body.role } : {}), + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + this.logger.log(`Updated member ${member.id}`); + return { member: this.mapMember(member) }; + } + + async remove(id: string, user: User): Promise { + const existing = await this.getScopedMember(id, user); + const member = await this.prisma.member.delete({ + where: { id: existing.id }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + this.logger.log(`Deleted member ${member.id}`); + return { member: this.mapMember(member) }; + } + + async findInvitations( + query: MemberListQuery, + user: User, + ): Promise { + const { page = 1, limit = 20, organizationId: requestedOrgId } = query; + const skip = (page - 1) * limit; + const organizationId = resolveOrganizationScope(user, requestedOrgId); + const where = { + ...(organizationId ? { organizationId } : {}), + acceptedAt: null, + revokedAt: null, + }; + + const [invitations, total] = await Promise.all([ + this.prisma.memberInvitation.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.memberInvitation.count({ where }), + ]); + + return { + invitations: invitations.map((invitation) => + this.mapInvitation(invitation), + ), + total, + }; + } + + async invite( + body: MemberInviteRequest, + user: User, + ): Promise { + const organizationId = this.resolveRequiredOrganizationScope( + user, + body.organizationId, + ); + + const organization = await this.prisma.organization.findFirst({ + where: { id: organizationId }, + select: { id: true, name: true }, + }); + + if (!organization) { + throw new NotFoundException('Organization not found'); + } + + await this.assertUsernameAvailable(organizationId, body.username); + + const existingUser = await this.prisma.user.findUnique({ + where: { email: body.email }, + select: { + id: true, + role: true, + organizationId: true, + }, + }); + + if (existingUser?.role === 'SUPER_ADMIN') { + throw new ConflictException('Super admins cannot be invited as members'); + } + + if ( + existingUser?.organizationId && + existingUser.organizationId !== organizationId + ) { + throw new ConflictException( + 'This user already belongs to another organization', + ); + } + + if (existingUser) { + const existingMembership = await this.prisma.member.findFirst({ + where: { userId: existingUser.id }, + select: { id: true }, + }); + + if (existingMembership) { + throw new ConflictException('This user is already linked to a member'); + } + } + + const existingInvitation = await this.prisma.memberInvitation.findFirst({ + where: { + organizationId, + acceptedAt: null, + revokedAt: null, + expiresAt: { gt: new Date() }, + OR: [{ email: body.email }, { username: body.username }], + }, + select: { id: true }, + }); + + if (existingInvitation) { + throw new ConflictException( + 'An active invitation already exists for this email or username', + ); + } + + const token = randomBytes(32).toString('hex'); + const invitation = await this.prisma.memberInvitation.create({ + data: { + email: body.email, + username: body.username, + role: body.role, + organizationId, + invitedById: user.id, + tokenHash: this.hashToken(token), + expiresAt: new Date( + Date.now() + INVITATION_EXPIRY_DAYS * 24 * 60 * 60 * 1000, + ), + }, + }); + + const appUrl = process.env.APP_URL ?? 'http://localhost:3000'; + const invitationLink = `${appUrl}/invite/accept?token=${token}`; + + await this.mailQueue.add(MAIL_JOBS.SEND_INVITATION, { + email: invitation.email, + inviterName: user.name, + organizationName: organization.name, + invitationLink, + }); + + this.logger.log(`Queued member invitation ${invitation.id}`); + return { invitation: this.mapInvitation(invitation) }; + } + + async resendInvitation( + id: string, + user: User, + ): Promise { + const organizationId = resolveOrganizationScope(user); + const invitation = await this.prisma.memberInvitation.findFirst({ + where: { + id, + ...(organizationId ? { organizationId } : {}), + acceptedAt: null, + revokedAt: null, + }, + include: { + organization: { + select: { name: true }, + }, + }, + }); + + if (!invitation) { + throw new NotFoundException('Invitation not found'); + } + + const token = randomBytes(32).toString('hex'); + const updated = await this.prisma.memberInvitation.update({ + where: { id: invitation.id }, + data: { + tokenHash: this.hashToken(token), + expiresAt: new Date( + Date.now() + INVITATION_EXPIRY_DAYS * 24 * 60 * 60 * 1000, + ), + }, + }); + + const appUrl = process.env.APP_URL ?? 'http://localhost:3000'; + const invitationLink = `${appUrl}/invite/accept?token=${token}`; + + await this.mailQueue.add(MAIL_JOBS.SEND_INVITATION, { + email: invitation.email, + inviterName: user.name, + organizationName: invitation.organization.name, + invitationLink, + }); + + this.logger.log(`Resent member invitation ${invitation.id}`); + return { invitation: this.mapInvitation(updated) }; + } + + async revokeInvitation( + id: string, + user: User, + ): Promise { + const organizationId = resolveOrganizationScope(user); + const invitation = await this.prisma.memberInvitation.findFirst({ + where: { + id, + ...(organizationId ? { organizationId } : {}), + acceptedAt: null, + revokedAt: null, + }, + }); + + if (!invitation) { + throw new NotFoundException('Invitation not found'); + } + + const updated = await this.prisma.memberInvitation.update({ + where: { id: invitation.id }, + data: { revokedAt: new Date() }, + }); + + this.logger.log(`Revoked member invitation ${invitation.id}`); + return { invitation: this.mapInvitation(updated) }; + } + + async acceptInvitation( + token: string, + ): Promise<{ sessionId: string } & MemberInvitationAcceptResponse> { + const invitation = await this.prisma.memberInvitation.findUnique({ + where: { tokenHash: this.hashToken(token) }, + }); + + if (!invitation) { + throw new NotFoundException('Invalid or expired invitation'); + } + + if (invitation.acceptedAt) { + throw new BadRequestException( + 'This invitation has already been accepted', + ); + } + + if (invitation.revokedAt || invitation.expiresAt < new Date()) { + throw new NotFoundException('Invalid or expired invitation'); + } + + const result = await this.prisma.$transaction(async (tx) => { + const existingUser = await tx.user.findUnique({ + where: { email: invitation.email }, + }); + + if (existingUser?.role === 'SUPER_ADMIN') { + throw new ForbiddenException( + 'Super admins cannot accept member invitations', + ); + } + + if ( + existingUser?.organizationId && + existingUser.organizationId !== invitation.organizationId + ) { + throw new ConflictException( + 'This user already belongs to another organization', + ); + } + + if (existingUser) { + const existingMembership = await tx.member.findFirst({ + where: { userId: existingUser.id }, + select: { id: true }, + }); + + if (existingMembership) { + throw new ConflictException( + 'This user is already linked to a member', + ); + } + } + + const existingUsername = await tx.member.findFirst({ + where: { + organizationId: invitation.organizationId, + username: invitation.username, + }, + select: { id: true }, + }); + + if (existingUsername) { + throw new ConflictException( + 'A member with this username already exists', + ); + } + + const userRecord = existingUser + ? await tx.user.update({ + where: { id: existingUser.id }, + data: { + organizationId: invitation.organizationId, + isConfirmed: true, + }, + }) + : await tx.user.create({ + data: { + email: invitation.email, + name: invitation.username, + role: 'MEMBER', + organizationId: invitation.organizationId, + isConfirmed: true, + }, + }); + + const member = await tx.member.create({ + data: { + username: invitation.username, + role: invitation.role, + organizationId: invitation.organizationId, + userId: userRecord.id, + }, + }); + + await tx.memberInvitation.update({ + where: { id: invitation.id }, + data: { acceptedAt: new Date() }, + }); + + return { userRecord, memberRole: member.role }; + }); + + const sessionId = await this.sessionService.createSession( + result.userRecord.id, + ); + + this.logger.log(`Accepted member invitation ${invitation.id}`); + + return { + sessionId, + user: this.mapUserResponse(result.userRecord, result.memberRole), + }; } } diff --git a/apps/web/app/(authenticated)/members/page.tsx b/apps/web/app/(authenticated)/members/page.tsx index fd88ba74..495d0999 100644 --- a/apps/web/app/(authenticated)/members/page.tsx +++ b/apps/web/app/(authenticated)/members/page.tsx @@ -1,7 +1,35 @@ 'use client'; +import { useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast } from 'sonner'; +import { + Edit, + Loader2, + MailPlus, + Plus, + RefreshCcw, + ShieldX, + Trash2, + Users, + XCircle, +} from 'lucide-react'; import { useUser } from '@/hooks/use-auth'; -import { useMembers } from '@/hooks/use-members'; +import { useMembers, useMemberInvitations } from '@/hooks/use-members'; +import { useOrganizations } from '@/hooks/use-organizations'; +import { ApiError } from '@/lib/api'; +import { + memberCreateRequestSchema, + memberInviteRequestSchema, + memberUpdateRequestSchema, + type Member, + type MemberCreateRequest, + type MemberInvitation, + type MemberInviteRequest, + type MemberRole, + type MemberUpdateRequest, +} from '@repo/contracts'; import { Table, TableBody, @@ -10,19 +38,37 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; -import { Users, ShieldX } from 'lucide-react'; -const ROLE_COLORS: Record = { +const ROLE_COLORS: Record = { ADMIN: 'bg-blue-100 text-blue-800', PRESENTER: 'bg-purple-100 text-purple-800', }; -function RoleBadge({ role }: { role: string }) { +function RoleBadge({ role }: { role: MemberRole }) { return ( {role.charAt(0) + role.slice(1).toLowerCase()} @@ -35,8 +81,7 @@ function ForbiddenPage() {

Access Denied

- You don't have permission to access this page. Only Super Admins - and Organization Admins can view members. + Only super admins and organization admins can manage members.

); @@ -46,37 +91,137 @@ function LoadingSkeleton() { return (
-
- {[...Array(5)].map((_, i) => ( - - ))} -
+ +
); } function truncateId(id: string) { - return `${id.slice(0, 8)}…`; + return id.slice(0, 8); +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat('en', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(value)); } function canAccessMembers(role: string | undefined) { return role === 'SUPER_ADMIN' || role === 'ORG_ADMIN'; } +function errorMessage(error: unknown, fallback: string) { + return error instanceof ApiError ? error.message : fallback; +} + +function OrganizationField({ + value, + onChange, + organizations, +}: { + value: string | undefined; + onChange: (value: string) => void; + organizations: + | Array<{ + id: string; + name: string; + }> + | undefined; +}) { + return ( +
+ + +
+ ); +} + export default function MembersPage() { const { user, isLoading: userLoading } = useUser(); const canAccess = canAccessMembers(user?.role); + const isSuperAdmin = user?.role === 'SUPER_ADMIN'; + const [createOpen, setCreateOpen] = useState(false); + const [inviteOpen, setInviteOpen] = useState(false); + const [editingMember, setEditingMember] = useState(null); + + const { organizations } = useOrganizations({ + enabled: canAccess && isSuperAdmin, + status: 'ACTIVE', + }); const { members, total, isLoading: membersLoading, - error, + error: membersError, + createMember, + updateMember, + deleteMember, } = useMembers({ enabled: canAccess, }); - const isSuperAdmin = user?.role === 'SUPER_ADMIN'; + const { + invitations, + total: invitationTotal, + isLoading: invitationsLoading, + error: invitationsError, + inviteMember, + resendInvitation, + revokeInvitation, + } = useMemberInvitations({ + enabled: canAccess, + }); + + const createForm = useForm({ + resolver: zodResolver(memberCreateRequestSchema), + defaultValues: { + username: '', + role: 'PRESENTER', + organizationId: undefined, + }, + }); + + const inviteForm = useForm({ + resolver: zodResolver(memberInviteRequestSchema), + defaultValues: { + email: '', + username: '', + role: 'PRESENTER', + organizationId: undefined, + }, + }); + + const editForm = useForm({ + resolver: zodResolver(memberUpdateRequestSchema), + defaultValues: { + username: '', + role: 'PRESENTER', + }, + }); + + useEffect(() => { + if (editingMember) { + editForm.reset({ + username: editingMember.username, + role: editingMember.role, + }); + } + }, [editForm, editingMember]); if (userLoading) { return ; @@ -86,25 +231,252 @@ export default function MembersPage() { return ; } + const onCreate = async (body: MemberCreateRequest) => { + try { + await createMember(body); + toast.success('Member created'); + createForm.reset({ username: '', role: 'PRESENTER' }); + setCreateOpen(false); + } catch (error) { + toast.error(errorMessage(error, 'Failed to create member')); + } + }; + + const onInvite = async (body: MemberInviteRequest) => { + try { + await inviteMember(body); + toast.success('Invitation sent'); + inviteForm.reset({ email: '', username: '', role: 'PRESENTER' }); + setInviteOpen(false); + } catch (error) { + toast.error(errorMessage(error, 'Failed to send invitation')); + } + }; + + const onEdit = async (body: MemberUpdateRequest) => { + if (!editingMember) return; + + try { + await updateMember(editingMember.id, body); + toast.success('Member updated'); + setEditingMember(null); + } catch (error) { + toast.error(errorMessage(error, 'Failed to update member')); + } + }; + + const onDelete = async (member: Member) => { + if (!window.confirm(`Delete ${member.username}?`)) return; + + try { + await deleteMember(member.id); + toast.success('Member deleted'); + } catch (error) { + toast.error(errorMessage(error, 'Failed to delete member')); + } + }; + + const onResend = async (invitation: MemberInvitation) => { + try { + await resendInvitation(invitation.id); + toast.success('Invitation resent'); + } catch (error) { + toast.error(errorMessage(error, 'Failed to resend invitation')); + } + }; + + const onRevoke = async (invitation: MemberInvitation) => { + if (!window.confirm(`Cancel invitation to ${invitation.email}?`)) return; + + try { + await revokeInvitation(invitation.id); + toast.success('Invitation canceled'); + } catch (error) { + toast.error(errorMessage(error, 'Failed to cancel invitation')); + } + }; + return (
-
-

Members

-

- {isSuperAdmin - ? 'Coordly domain members across all organizations' - : 'Members in your organization'} -

+
+
+

Members

+

+ {isSuperAdmin + ? 'Coordly members across active organizations' + : 'Members in your organization'} +

+
+
+ + + + + + + Create Member + + Add a Coordly member without creating a login account. + + +
+
+ + +
+ ( +
+ + +
+ )} + /> + {isSuperAdmin && ( + ( + + )} + /> + )} + + + + +
+
+ + + + + + + + Invite Member + + Send an email invitation and create the member on acceptance. + + +
+
+ + +
+
+ + +
+ ( +
+ + +
+ )} + /> + {isSuperAdmin && ( + ( + + )} + /> + )} + + + + +
+
+
- Members + Active Members {total !== undefined && ( - ({total} total) + ({total}) )} @@ -116,7 +488,7 @@ export default function MembersPage() { ))}
- ) : error ? ( + ) : membersError ? (
Failed to load members
@@ -130,8 +502,10 @@ export default function MembersPage() { ID Username + Email Role - {isSuperAdmin && Organization ID} + {isSuperAdmin && Organization} + Actions @@ -143,6 +517,9 @@ export default function MembersPage() { {member.username} + + {member.userEmail ?? 'No login'} + @@ -151,6 +528,26 @@ export default function MembersPage() { {truncateId(member.organizationId)} )} + +
+ + +
+
))}
@@ -158,6 +555,143 @@ export default function MembersPage() { )} + + + + + + Pending Invitations + {invitationTotal !== undefined && ( + + ({invitationTotal}) + + )} + + + + {invitationsLoading ? ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ) : invitationsError ? ( +
+ Failed to load invitations +
+ ) : !invitations?.length ? ( +
+ No pending invitations +
+ ) : ( + + + + Email + Username + Role + Expires + {isSuperAdmin && Organization} + Actions + + + + {invitations.map((invitation) => ( + + + {invitation.email} + + {invitation.username} + + + + + {formatDate(invitation.expiresAt)} + + {isSuperAdmin && ( + + {truncateId(invitation.organizationId)} + + )} + +
+ + +
+
+
+ ))} +
+
+ )} +
+
+ + { + if (!open) setEditingMember(null); + }} + > + + + Edit Member + + Update the member username or role. + + +
+
+ + +
+ ( +
+ + +
+ )} + /> + + + + +
+
); } diff --git a/apps/web/app/invite/accept/page.tsx b/apps/web/app/invite/accept/page.tsx new file mode 100644 index 00000000..8bc86905 --- /dev/null +++ b/apps/web/app/invite/accept/page.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { Suspense, useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { CheckCircle2, Loader2, XCircle } from 'lucide-react'; +import { toast } from 'sonner'; +import { useAuth } from '@/hooks/use-auth'; +import { ApiError } from '@/lib/api'; + +type AcceptStatus = 'loading' | 'success' | 'error'; + +function AcceptInvitationContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { acceptMemberInvitation } = useAuth(); + const [status, setStatus] = useState('loading'); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + const token = searchParams.get('token'); + + if (!token) { + setStatus('error'); + setErrorMessage('Invalid or missing invitation token'); + return; + } + + const accept = async () => { + try { + await acceptMemberInvitation({ token }); + setStatus('success'); + toast.success('Invitation accepted'); + setTimeout(() => { + router.replace('/dashboard'); + }, 1000); + } catch (error) { + setStatus('error'); + if (error instanceof ApiError) { + setErrorMessage(error.message); + toast.error(error.message); + } else { + setErrorMessage('Invitation could not be accepted.'); + toast.error('Invitation could not be accepted.'); + } + } + }; + + accept(); + }, [acceptMemberInvitation, router, searchParams]); + + return ( +
+
+ {status === 'loading' && ( + <> + +

+ Accepting invitation... +

+

+ Please wait while we set up your access. +

+ + )} + + {status === 'success' && ( + <> + +

+ Invitation accepted +

+

+ Redirecting to dashboard... +

+ + )} + + {status === 'error' && ( + <> + +

+ Invitation failed +

+

{errorMessage}

+ + Back to login + + + )} +
+
+ ); +} + +export default function AcceptInvitationPage() { + return ( + + + + } + > + + + ); +} diff --git a/apps/web/hooks/use-auth.ts b/apps/web/hooks/use-auth.ts index c97b0c6a..db718dee 100644 --- a/apps/web/hooks/use-auth.ts +++ b/apps/web/hooks/use-auth.ts @@ -7,6 +7,8 @@ import { apiPost, ApiError } from '@/lib/api'; import type { MagicLinkRequest, MagicLinkVerifyRequest, + MemberInvitationAcceptRequest, + MemberInvitationAcceptResponse, UserResponse, } from '@repo/contracts'; @@ -23,7 +25,7 @@ interface UseUserReturn { } // Routes where we should NOT redirect on 401 -const AUTH_ROUTES = ['/login', '/auth']; +const AUTH_ROUTES = ['/login', '/auth', '/invite']; function isAuthRoute(pathname: string): boolean { return AUTH_ROUTES.some( @@ -80,6 +82,18 @@ export function useAuth() { [mutate], ); + const acceptMemberInvitation = useCallback( + async (data: MemberInvitationAcceptRequest) => { + const result = await apiPost( + '/members/invitations/accept', + data, + ); + mutate(); + return result; + }, + [mutate], + ); + const logout = useCallback(async () => { await apiPost<{ success: boolean }>('/auth/logout'); mutate(); @@ -88,6 +102,7 @@ export function useAuth() { return { requestMagicLink, verifyMagicLink, + acceptMemberInvitation, logout, }; } diff --git a/apps/web/hooks/use-members.ts b/apps/web/hooks/use-members.ts index a4cdc2c8..ab20d4f1 100644 --- a/apps/web/hooks/use-members.ts +++ b/apps/web/hooks/use-members.ts @@ -1,7 +1,17 @@ 'use client'; -import useSWR, { type KeyedMutator } from 'swr'; -import type { MemberListResponse } from '@repo/contracts'; +import useSWR, { mutate } from 'swr'; +import { useCallback } from 'react'; +import { apiDelete, apiPatch, apiPost } from '@/lib/api'; +import type { + MemberActionResponse, + MemberCreateRequest, + MemberInvitationActionResponse, + MemberInvitationListResponse, + MemberInviteRequest, + MemberListResponse, + MemberUpdateRequest, +} from '@repo/contracts'; interface UseMembersOptions { enabled?: boolean; @@ -13,16 +23,27 @@ interface UseMembersReturn { total: number | undefined; isLoading: boolean; error: Error | undefined; - mutate: KeyedMutator; + createMember: (body: MemberCreateRequest) => Promise; + updateMember: ( + id: string, + body: MemberUpdateRequest, + ) => Promise; + deleteMember: (id: string) => Promise; + mutate: () => void; +} + +function buildMembersEndpoint(base: string, organizationId?: string): string { + if (!organizationId) { + return base; + } + + const params = new URLSearchParams({ organizationId }); + return `${base}?${params.toString()}`; } export function useMembers(options: UseMembersOptions = {}): UseMembersReturn { const { enabled = true, organizationId } = options; - - const params = new URLSearchParams(); - if (organizationId) params.set('organizationId', organizationId); - const query = params.toString(); - const endpoint = query ? `/members?${query}` : '/members'; + const endpoint = buildMembersEndpoint('/members', organizationId); const { data, @@ -31,11 +52,134 @@ export function useMembers(options: UseMembersOptions = {}): UseMembersReturn { mutate: swrMutate, } = useSWR(enabled ? endpoint : null); + const invalidateMembers = useCallback(() => { + swrMutate(); + mutate( + (key) => typeof key === 'string' && key.startsWith('/members'), + undefined, + { revalidate: true }, + ); + }, [swrMutate]); + + const createMember = useCallback( + async (body: MemberCreateRequest) => { + const result = await apiPost('/members', body); + invalidateMembers(); + return result; + }, + [invalidateMembers], + ); + + const updateMember = useCallback( + async (id: string, body: MemberUpdateRequest) => { + const result = await apiPatch( + `/members/${id}`, + body, + ); + invalidateMembers(); + return result; + }, + [invalidateMembers], + ); + + const deleteMember = useCallback( + async (id: string) => { + const result = await apiDelete(`/members/${id}`); + invalidateMembers(); + return result; + }, + [invalidateMembers], + ); + return { members: data?.members, total: data?.total, isLoading, error, + createMember, + updateMember, + deleteMember, + mutate: invalidateMembers, + }; +} + +interface UseMemberInvitationsReturn { + invitations: MemberInvitationListResponse['invitations'] | undefined; + total: number | undefined; + isLoading: boolean; + error: Error | undefined; + inviteMember: ( + body: MemberInviteRequest, + ) => Promise; + resendInvitation: (id: string) => Promise; + revokeInvitation: (id: string) => Promise; + mutate: () => void; +} + +export function useMemberInvitations( + options: UseMembersOptions = {}, +): UseMemberInvitationsReturn { + const { enabled = true, organizationId } = options; + const endpoint = buildMembersEndpoint('/members/invitations', organizationId); + + const { + data, + error, + isLoading, mutate: swrMutate, + } = useSWR(enabled ? endpoint : null); + + const invalidateInvitations = useCallback(() => { + swrMutate(); + mutate( + (key) => typeof key === 'string' && key.startsWith('/members'), + undefined, + { revalidate: true }, + ); + }, [swrMutate]); + + const inviteMember = useCallback( + async (body: MemberInviteRequest) => { + const result = await apiPost( + '/members/invitations', + body, + ); + invalidateInvitations(); + return result; + }, + [invalidateInvitations], + ); + + const resendInvitation = useCallback( + async (id: string) => { + const result = await apiPost( + `/members/invitations/${id}/resend`, + ); + invalidateInvitations(); + return result; + }, + [invalidateInvitations], + ); + + const revokeInvitation = useCallback( + async (id: string) => { + const result = await apiDelete( + `/members/invitations/${id}`, + ); + invalidateInvitations(); + return result; + }, + [invalidateInvitations], + ); + + return { + invitations: data?.invitations, + total: data?.total, + isLoading, + error, + inviteMember, + resendInvitation, + revokeInvitation, + mutate: invalidateInvitations, }; } diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 0b2ca94b..e1f236b2 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -7,7 +7,13 @@ const SESSION_COOKIE_NAME = 'bootcamp_starter_session'; const authEntryRoutes = ['/login', '/auth/verify']; // Routes that do not require a session. -const publicRoutes = [...authEntryRoutes, '/browse', '/privacy', '/terms']; +const publicRoutes = [ + ...authEntryRoutes, + '/browse', + '/privacy', + '/terms', + '/invite/accept', +]; const DEFAULT_AUTHENTICATED_ROUTE = '/dashboard'; diff --git a/packages/contracts/src/members/index.ts b/packages/contracts/src/members/index.ts index ac721779..0cdb8648 100644 --- a/packages/contracts/src/members/index.ts +++ b/packages/contracts/src/members/index.ts @@ -2,3 +2,12 @@ export * from './member-role.schema'; export * from './member.response'; export * from './member-list.response'; export * from './member-list.query'; +export * from './member-create.request'; +export * from './member-update.request'; +export * from './member-action.response'; +export * from './member-invite.request'; +export * from './member-invitation.response'; +export * from './member-invitation-list.response'; +export * from './member-invitation-action.response'; +export * from './member-invitation-accept.request'; +export * from './member-invitation-accept.response'; diff --git a/packages/contracts/src/members/member-action.response.ts b/packages/contracts/src/members/member-action.response.ts new file mode 100644 index 00000000..8b8fc943 --- /dev/null +++ b/packages/contracts/src/members/member-action.response.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { memberSchema } from './member.response'; + +export const memberActionResponseSchema = z.object({ + member: memberSchema, +}); +export type MemberActionResponse = z.infer; diff --git a/packages/contracts/src/members/member-create.request.ts b/packages/contracts/src/members/member-create.request.ts new file mode 100644 index 00000000..21c1ed41 --- /dev/null +++ b/packages/contracts/src/members/member-create.request.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { memberRoleSchema } from './member-role.schema'; + +export const memberCreateRequestSchema = z.object({ + username: z.string().trim().min(1).max(120), + role: memberRoleSchema, + organizationId: z.uuid().optional(), +}); +export type MemberCreateRequest = z.infer; diff --git a/packages/contracts/src/members/member-invitation-accept.request.ts b/packages/contracts/src/members/member-invitation-accept.request.ts new file mode 100644 index 00000000..4fefef89 --- /dev/null +++ b/packages/contracts/src/members/member-invitation-accept.request.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const memberInvitationAcceptRequestSchema = z.object({ + token: z.string().min(1), +}); +export type MemberInvitationAcceptRequest = z.infer< + typeof memberInvitationAcceptRequestSchema +>; diff --git a/packages/contracts/src/members/member-invitation-accept.response.ts b/packages/contracts/src/members/member-invitation-accept.response.ts new file mode 100644 index 00000000..b05b4627 --- /dev/null +++ b/packages/contracts/src/members/member-invitation-accept.response.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { userResponseSchema } from '../users/user.response'; + +export const memberInvitationAcceptResponseSchema = z.object({ + user: userResponseSchema, +}); +export type MemberInvitationAcceptResponse = z.infer< + typeof memberInvitationAcceptResponseSchema +>; diff --git a/packages/contracts/src/members/member-invitation-action.response.ts b/packages/contracts/src/members/member-invitation-action.response.ts new file mode 100644 index 00000000..c6b732a5 --- /dev/null +++ b/packages/contracts/src/members/member-invitation-action.response.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { memberInvitationSchema } from './member-invitation.response'; + +export const memberInvitationActionResponseSchema = z.object({ + invitation: memberInvitationSchema, +}); +export type MemberInvitationActionResponse = z.infer< + typeof memberInvitationActionResponseSchema +>; diff --git a/packages/contracts/src/members/member-invitation-list.response.ts b/packages/contracts/src/members/member-invitation-list.response.ts new file mode 100644 index 00000000..295d7e22 --- /dev/null +++ b/packages/contracts/src/members/member-invitation-list.response.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { memberInvitationSchema } from './member-invitation.response'; + +export const memberInvitationListResponseSchema = z.object({ + invitations: z.array(memberInvitationSchema), + total: z.number(), +}); +export type MemberInvitationListResponse = z.infer< + typeof memberInvitationListResponseSchema +>; diff --git a/packages/contracts/src/members/member-invitation.response.ts b/packages/contracts/src/members/member-invitation.response.ts new file mode 100644 index 00000000..88158bff --- /dev/null +++ b/packages/contracts/src/members/member-invitation.response.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { memberRoleSchema } from './member-role.schema'; + +export const memberInvitationSchema = z.object({ + id: z.uuid(), + email: z.email(), + username: z.string(), + role: memberRoleSchema, + organizationId: z.uuid(), + invitedById: z.uuid(), + expiresAt: z.iso.datetime(), + acceptedAt: z.iso.datetime().nullable(), + revokedAt: z.iso.datetime().nullable(), + createdAt: z.iso.datetime(), +}); +export type MemberInvitation = z.infer; diff --git a/packages/contracts/src/members/member-invite.request.ts b/packages/contracts/src/members/member-invite.request.ts new file mode 100644 index 00000000..5bf8867b --- /dev/null +++ b/packages/contracts/src/members/member-invite.request.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { memberRoleSchema } from './member-role.schema'; + +export const memberInviteRequestSchema = z.object({ + email: z.email().transform((value) => value.toLowerCase()), + username: z.string().trim().min(1).max(120), + role: memberRoleSchema, + organizationId: z.uuid().optional(), +}); +export type MemberInviteRequest = z.infer; diff --git a/packages/contracts/src/members/member-update.request.ts b/packages/contracts/src/members/member-update.request.ts new file mode 100644 index 00000000..702b4ebc --- /dev/null +++ b/packages/contracts/src/members/member-update.request.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { memberRoleSchema } from './member-role.schema'; + +export const memberUpdateRequestSchema = z + .object({ + username: z.string().trim().min(1).max(120).optional(), + role: memberRoleSchema.optional(), + }) + .refine((value) => value.username !== undefined || value.role !== undefined, { + message: 'At least one field must be provided', + }); +export type MemberUpdateRequest = z.infer; diff --git a/packages/contracts/src/members/member.response.ts b/packages/contracts/src/members/member.response.ts index 63d42580..dde7a4b1 100644 --- a/packages/contracts/src/members/member.response.ts +++ b/packages/contracts/src/members/member.response.ts @@ -6,5 +6,7 @@ export const memberSchema = z.object({ username: z.string(), role: memberRoleSchema, organizationId: z.uuid(), + userId: z.uuid().nullable().optional(), + userEmail: z.email().nullable().optional(), }); export type Member = z.infer; diff --git a/packages/database/prisma/migrations/20260717183454_add_member_invitations/migration.sql b/packages/database/prisma/migrations/20260717183454_add_member_invitations/migration.sql new file mode 100644 index 00000000..90090cc9 --- /dev/null +++ b/packages/database/prisma/migrations/20260717183454_add_member_invitations/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE "private"."MemberInvitation" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "username" TEXT NOT NULL, + "role" "MemberRole" NOT NULL DEFAULT 'PRESENTER', + "organizationId" 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 "MemberInvitation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "MemberInvitation_tokenHash_key" ON "private"."MemberInvitation"("tokenHash"); + +-- CreateIndex +CREATE INDEX "MemberInvitation_organizationId_idx" ON "private"."MemberInvitation"("organizationId"); + +-- CreateIndex +CREATE INDEX "MemberInvitation_email_idx" ON "private"."MemberInvitation"("email"); + +-- CreateIndex +CREATE INDEX "MemberInvitation_expiresAt_idx" ON "private"."MemberInvitation"("expiresAt"); + +-- AddForeignKey +ALTER TABLE "private"."MemberInvitation" ADD CONSTRAINT "MemberInvitation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "private"."MemberInvitation" ADD CONSTRAINT "MemberInvitation_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 3033dccd..3ce8b4da 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -85,6 +85,7 @@ model User { eventAttendees EventAttendee[] orgMemberships Member[] announcements Announcement[] + sentMemberInvitations MemberInvitation[] @@index([organizationId]) @@schema("public") @@ -160,6 +161,7 @@ model Organization { events Event[] eventAttendees EventAttendee[] announcements Announcement[] + memberInvitations MemberInvitation[] @@index([approvedById]) @@index([status]) @@ -185,6 +187,29 @@ model Member { @@schema("public") } +model MemberInvitation { + id String @id @default(uuid()) + email String + username String + role MemberRole @default(PRESENTER) + organizationId String + invitedById String + tokenHash String @unique + expiresAt DateTime + acceptedAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + invitedBy User @relation(fields: [invitedById], references: [id], onDelete: Cascade) + + @@index([organizationId]) + @@index([email]) + @@index([expiresAt]) + @@schema("private") +} + model Event { id String @id @default(uuid()) eventName String From 1a6d86ed380e589f1cf59637d35a066aae0307f7 Mon Sep 17 00:00:00 2001 From: chko0 Date: Tue, 21 Jul 2026 20:43:27 +0300 Subject: [PATCH 2/6] chore: remove outdated member management test plan --- MEMBER_MANAGEMENT_TEST_PLAN.md | 866 --------------------------------- 1 file changed, 866 deletions(-) delete mode 100644 MEMBER_MANAGEMENT_TEST_PLAN.md diff --git a/MEMBER_MANAGEMENT_TEST_PLAN.md b/MEMBER_MANAGEMENT_TEST_PLAN.md deleted file mode 100644 index 10baf469..00000000 --- a/MEMBER_MANAGEMENT_TEST_PLAN.md +++ /dev/null @@ -1,866 +0,0 @@ -# Member Management CRUD and Invitation Flow Test Plan - -## Scope - -This plan verifies the member management CRUD and invitation features: - -- Active member listing -- Create member without login -- Edit member username and role -- Delete member -- Pending invitation listing -- Invite member by email -- Resend invitation -- Cancel invitation -- Accept invitation from email link -- Tenant isolation and role-based access -- Event behavior after member role changes - -## Prerequisites - -Start from a clean, migrated local environment. - -```bash -npm run services:init -npm install -npx turbo run db:generate -npx turbo run db:deploy -npx turbo run db:seed -npm run dev -``` - -Expected local services: - -- Web: `http://localhost:3000` -- API: `http://localhost:3001` -- Mailpit: `http://localhost:8025` -- Postgres: `localhost:5433` -- Redis: `localhost:6380` - -Seeded accounts: - -- Super admin: `admin@bootcamp-starter.local` -- TechCorp org admin: `admin@techcorp.example.com` -- TechCorp member: `member@techcorp.example.com` - -Sign in through `http://localhost:3000/login`, then open the newest magic-link email in Mailpit. - -## Pre-Flight Verification - -Run the quality gates before manual testing: - -```bash -npx turbo run check-types -npx turbo run lint -npm run format:check -``` - -Expected result: all commands pass. - -Also verify the migration exists: - -```bash -dir packages\database\prisma\migrations -``` - -Expected result: a migration named similar to `*_add_member_invitations` is present. - -## Role Access Matrix - -| User | Expected `/members` Access | Notes | -| --------------- | -------------------------- | ----------------------------------------------------------------------- | -| `SUPER_ADMIN` | Allowed | Can view members across orgs and must select org when creating/inviting | -| `ORG_ADMIN` | Allowed | Scoped to their own organization | -| auth `MEMBER` | Denied | Should see access denied page | -| Unauthenticated | Redirected | Should redirect to `/login` | - -## Manual UI Tests - -### 1. Org Admin Can Open Member Management - -1. Sign in as `admin@techcorp.example.com`. -2. Go to `http://localhost:3000/members`. - -Expected: - -- Page loads without errors. -- Active Members table is visible. -- Pending Invitations section is visible. -- Create and Invite buttons are visible. -- Organization selector is not shown in create/invite dialogs. - -### 2. Super Admin Can Open Member Management - -1. Sign in as `admin@bootcamp-starter.local`. -2. Go to `http://localhost:3000/members`. -3. Open Create dialog. -4. Open Invite dialog. - -Expected: - -- Page loads without errors. -- Active Members table may show members across organizations. -- Create and Invite dialogs include an Organization selector. -- Organization selector lists active organizations. - -### 3. Auth Member Cannot Manage Members - -1. Sign in as `member@techcorp.example.com`. -2. Go to `http://localhost:3000/members`. - -Expected: - -- Access denied page is shown. -- Member table and mutation buttons are not shown. - -### 4. Create Presenter Member Without Login - -1. Sign in as TechCorp org admin. -2. Open Members. -3. Click Create. -4. Enter username: `manual-presenter-1`. -5. Select role: `Presenter`. -6. Submit. - -Expected: - -- Success toast appears. -- Dialog closes. -- New member appears in Active Members. -- Email column shows `No login`. -- Role badge shows `Presenter`. - -### 5. Create Admin Member Without Login - -1. Click Create. -2. Enter username: `manual-admin-1`. -3. Select role: `Admin`. -4. Submit. - -Expected: - -- Success toast appears. -- New member appears. -- Role badge shows `Admin`. -- Email column shows `No login`. - -### 6. Duplicate Username Is Blocked - -1. Click Create. -2. Enter username: `manual-presenter-1`. -3. Select any role. -4. Submit. - -Expected: - -- Error toast appears. -- No duplicate row is added. -- API should return a conflict-style error. - -### 7. Edit Member Username - -1. Find `manual-presenter-1`. -2. Click edit. -3. Change username to `manual-presenter-1-renamed`. -4. Save. - -Expected: - -- Success toast appears. -- Table updates with the new username. -- Role remains unchanged. - -### 8. Edit Member Role - -1. Find `manual-presenter-1-renamed`. -2. Click edit. -3. Change role to `Admin`. -4. Save. - -Expected: - -- Success toast appears. -- Role badge changes to `Admin`. - -### 9. Delete Member - -1. Find `manual-admin-1`. -2. Click delete. -3. Confirm browser confirmation. - -Expected: - -- Success toast appears. -- Member disappears from Active Members. -- Refreshing the page does not bring the member back. - -### 10. Cancel Delete - -1. Find any member. -2. Click delete. -3. Cancel browser confirmation. - -Expected: - -- No API request should complete. -- Member remains in table. -- No success toast appears. - -## Invitation Flow Tests - -Use unique emails for each run, for example: - -- `invite-presenter-001@example.com` -- `invite-admin-001@example.com` -- `invite-cancel-001@example.com` - -### 11. Invite Presenter - -1. Sign in as TechCorp org admin. -2. Open Members. -3. Click Invite. -4. Enter email: `invite-presenter-001@example.com`. -5. Enter username: `invite-presenter-001`. -6. Select role: `Presenter`. -7. Submit. - -Expected: - -- Success toast appears. -- Invitation appears in Pending Invitations. -- Mailpit receives an invitation email. -- Active Members does not contain `invite-presenter-001` yet. - -### 12. Invite Admin Domain Member - -1. Click Invite. -2. Enter email: `invite-admin-001@example.com`. -3. Enter username: `invite-admin-001`. -4. Select role: `Admin`. -5. Submit. - -Expected: - -- Success toast appears. -- Invitation appears in Pending Invitations with role `Admin`. -- Mailpit receives an invitation email. - -Important expected behavior after acceptance: - -- The auth user role should be `MEMBER`. -- The Coordly member role should be `ADMIN`. -- This user should not become auth `ORG_ADMIN`. - -### 13. Duplicate Pending Invite Email Is Blocked - -1. Click Invite. -2. Enter email: `invite-presenter-001@example.com`. -3. Enter username: `some-other-username`. -4. Submit. - -Expected: - -- Error toast appears. -- No second pending invite is created. - -### 14. Duplicate Pending Invite Username Is Blocked - -1. Click Invite. -2. Enter email: `some-other-email@example.com`. -3. Enter username: `invite-presenter-001`. -4. Submit. - -Expected: - -- Error toast appears. -- No second pending invite is created. - -### 15. Resend Invitation - -1. In Pending Invitations, find `invite-presenter-001@example.com`. -2. Click resend. -3. Open Mailpit. - -Expected: - -- Success toast appears. -- A new invitation email is delivered. -- The old invitation link should no longer be usable if token rotation is enforced. -- The newest invitation link should remain usable. - -### 16. Cancel Invitation - -1. Invite `invite-cancel-001@example.com` with username `invite-cancel-001`. -2. Confirm it appears in Pending Invitations. -3. Click cancel. -4. Confirm browser confirmation. - -Expected: - -- Success toast appears. -- Invitation disappears from Pending Invitations. -- Invitation link from Mailpit cannot be accepted. - -### 17. Accept Presenter Invitation - -1. Open the newest invitation email for `invite-presenter-001@example.com` in Mailpit. -2. Click the invitation link. - -Expected: - -- Browser opens `/invite/accept?token=...`. -- Acceptance page shows loading, then success. -- User is redirected to `/dashboard`. -- User is authenticated. -- Sidebar should show member-level navigation, not org-admin navigation. -- `/members` should show access denied for this invited user. - -Then sign back in as TechCorp org admin and verify: - -- `invite-presenter-001` appears in Active Members. -- Email column shows `invite-presenter-001@example.com`. -- The invitation no longer appears in Pending Invitations. - -### 18. Accept Admin Domain Member Invitation - -1. Open the newest invitation email for `invite-admin-001@example.com`. -2. Click the invitation link. -3. After redirect, inspect navigation. - -Expected: - -- User is signed in. -- User does not get `/members` access. -- User is auth `MEMBER`, not auth `ORG_ADMIN`. -- Their member role is `ADMIN`, so event attendee registration rules should treat them as a Coordly admin member. - -### 19. Accept Same Invitation Twice - -1. Copy an invitation link. -2. Open it once and accept it. -3. Open the same link again in a fresh/private browser session. - -Expected: - -- First acceptance succeeds. -- Second acceptance fails with an already accepted or invalid invitation error. -- No duplicate user is created. -- No duplicate member is created. - -### 20. Missing or Invalid Token - -Open: - -- `http://localhost:3000/invite/accept` -- `http://localhost:3000/invite/accept?token=bad-token` - -Expected: - -- Missing token shows a clear error. -- Bad token shows an error. -- No session is created. - -## Existing User Invite Tests - -### 21. Invite Existing Unassigned User - -If you have or create a user with no `organizationId`, invite that email. - -Expected: - -- Invite can be sent. -- Accepting the invite assigns the user to the inviting organization. -- A linked `Member` row is created. - -### 22. Invite User Already In Same Organization But Not Linked To Member - -If a user belongs to the same organization and has no linked `Member` row: - -Expected: - -- Invite can be sent. -- Accepting creates the linked `Member` row. -- User keeps their existing auth role unless product rules say otherwise. - -### 23. Invite User Already Linked To A Member - -Try inviting an email for a user already linked through `Member.userId`. - -Expected: - -- Invite is blocked. -- No pending invitation is created. - -### 24. Invite User In Another Organization - -Try inviting a user whose `organizationId` belongs to a different org. - -Expected: - -- Invite is blocked. -- Error explains the user already belongs to another organization. - -### 25. Invite Super Admin - -Try inviting `admin@bootcamp-starter.local`. - -Expected: - -- Invite is blocked. -- Super admin is not converted or linked as a member. - -## Tenant Isolation Tests - -These are critical. - -### 26. Org Admin Cannot See Another Org's Members - -1. Sign in as TechCorp org admin. -2. Open `/members`. - -Expected: - -- Only TechCorp members are visible. -- No members from Green Energy or other organizations appear. - -### 27. Org Admin Cannot Use `organizationId` Query To Access Another Org - -Use an API client while signed in as TechCorp org admin: - -```http -GET http://localhost:3001/members?organizationId= -``` - -Expected: - -- Request is rejected with forbidden access. - -Repeat for: - -```http -GET http://localhost:3001/members/invitations?organizationId= -``` - -Expected: - -- Request is rejected. - -### 28. Org Admin Cannot Update Another Org's Member - -Use an API client with a member ID from another org: - -```http -PATCH http://localhost:3001/members/ -Content-Type: application/json - -{ - "username": "cross-tenant-edit" -} -``` - -Expected: - -- Request returns not found or forbidden. -- Other org's member is unchanged. - -### 29. Org Admin Cannot Delete Another Org's Member - -Use an API client: - -```http -DELETE http://localhost:3001/members/ -``` - -Expected: - -- Request returns not found or forbidden. -- Other org's member remains. - -### 30. Org Admin Cannot Resend Or Cancel Another Org's Invitation - -Use another org's invitation ID: - -```http -POST http://localhost:3001/members/invitations//resend -DELETE http://localhost:3001/members/invitations/ -``` - -Expected: - -- Request returns not found or forbidden. -- Invitation remains unchanged. - -## Super Admin Tests - -### 31. Super Admin Must Select Organization For Create - -1. Sign in as super admin. -2. Open Create dialog. -3. Enter username and role but do not select organization. -4. Submit. - -Expected: - -- Request fails with `organizationId is required`. -- No member is created. - -### 32. Super Admin Must Select Organization For Invite - -1. Open Invite dialog as super admin. -2. Fill email, username, role but do not select organization. -3. Submit. - -Expected: - -- Request fails with `organizationId is required`. -- No invitation is created. - -### 33. Super Admin Creates Member In Selected Org - -1. Open Create dialog. -2. Select TechCorp. -3. Create username `super-created-techcorp-member`. - -Expected: - -- Member appears. -- Organization ID corresponds to selected org. - -### 34. Super Admin Invites Member To Selected Org - -1. Open Invite dialog. -2. Select TechCorp. -3. Invite `super-invite-techcorp@example.com`. - -Expected: - -- Invitation appears. -- Email is sent. -- Accepting link creates member in TechCorp. - -## Event Rule Regression Tests - -### 35. Created Presenter Can Be Used As Event Presenter - -If create/edit event UI exists later, verify presenter members are selectable as presenters. If only seed/API paths exist, verify event detail still displays presenter names normally after member CRUD changes. - -Expected: - -- Existing event pages load. -- Existing presenters still appear. -- Deleting a presenter sets event presenter to null or equivalent safe display, not a crash. - -### 36. Invited Presenter Cannot Register For Own Hosted Event - -If an invited presenter is assigned as an event presenter: - -Expected: - -- They can manage attendance for hosted event where existing logic allows. -- They cannot register as attendee for their own hosted event. - -### 37. MemberRole.ADMIN Cannot Register As Attendee - -1. Accept an invitation with role `Admin`. -2. Sign in as that invited user. -3. Open an upcoming event. - -Expected: - -- Event registration is unavailable or blocked. -- User is still not auth `ORG_ADMIN`. - -### 38. Regular Invited Presenter Can Register For Other Events - -1. Accept presenter invitation. -2. Open an upcoming event they are not hosting. - -Expected: - -- Registration is allowed when the event is in their organization. -- Duplicate registration is still prevented. - -## API Contract Tests - -Use an API client with cookies from a valid signed-in browser session. - -### 39. Create Member Request Validation - -Invalid payloads: - -```json -{} -``` - -```json -{ "username": "", "role": "PRESENTER" } -``` - -```json -{ "username": "valid", "role": "MEMBER" } -``` - -Expected: - -- Requests fail validation. -- No member is created. - -### 40. Update Member Request Validation - -Invalid payloads: - -```json -{} -``` - -```json -{ "role": "ORG_ADMIN" } -``` - -Expected: - -- Requests fail validation. -- Member is unchanged. - -### 41. Invite Request Validation - -Invalid payloads: - -```json -{ "email": "not-an-email", "username": "bad", "role": "PRESENTER" } -``` - -```json -{ "email": "valid@example.com", "username": "", "role": "PRESENTER" } -``` - -```json -{ "email": "valid@example.com", "username": "valid", "role": "SUPER_ADMIN" } -``` - -Expected: - -- Requests fail validation. -- No invitation is created. - -### 42. Accept Invite Request Validation - -Invalid payloads: - -```json -{} -``` - -```json -{ "token": "" } -``` - -Expected: - -- Requests fail validation. -- No user/member/session is created. - -## Database Integrity Checks - -Run these checks with a SQL client or Prisma Studio after manual tests. - -### 43. Invitation Token Is Not Stored Raw - -Inspect `private.MemberInvitation`. - -Expected: - -- Table has `tokenHash`. -- Raw emailed token is not stored. - -### 44. Accepted Invitation Has Linked User And Member - -For an accepted invitation: - -Expected: - -- `acceptedAt` is set. -- A `User` exists with invited email. -- `User.organizationId` equals invitation org. -- A `Member` exists with `userId` equal to accepted user ID. -- `Member.organizationId` equals invitation org. - -### 45. Canceled Invitation Has No Member - -For a canceled invitation: - -Expected: - -- `revokedAt` is set. -- No user/member is created solely from that canceled invite. - -### 46. Deleted Presenter Does Not Break Events - -1. Delete a presenter member who is assigned to an event. -2. Open that event detail page. - -Expected: - -- Page loads. -- Presenter field is blank, null, or safely displayed. -- No API 500 occurs. - -## Mailpit Tests - -### 47. Invitation Email Content - -Open a sent invitation email. - -Expected: - -- Subject mentions the organization. -- Body contains inviter name. -- Body contains organization name. -- Body contains invitation link. -- Expiry text says 7 days. - -### 48. Resend Email Creates New Message - -After clicking resend: - -Expected: - -- Mailpit message count increases. -- Newest email contains a working invite link. - -## Session Behavior Tests - -### 49. Accept Invite Creates Session - -After accepting an invite: - -Expected: - -- Browser has `bootcamp_starter_session` cookie. -- `/dashboard` loads without redirect to login. -- `/auth/me` returns the invited user. - -### 50. Accept Invite While Already Signed In - -1. Sign in as one user. -2. Open a different user's invitation link. - -Expected: - -- Invitation acceptance should either switch to the invited user session or fail clearly. -- No mixed identity state should occur. -- After acceptance, `/auth/me` should match the invited email if acceptance succeeded. - -## Regression Tests - -### 51. Existing Login Still Works - -Verify magic-link login still works for: - -- `admin@bootcamp-starter.local` -- `admin@techcorp.example.com` -- `member@techcorp.example.com` - -Expected: - -- Magic link emails still send. -- Verification still creates session. -- Role-based navigation remains correct. - -### 52. Existing Members Page Loads With Seed Data - -Before creating test data: - -Expected: - -- Seeded Coordly members display correctly. -- Linked seeded users show email. -- Unlinked members show `No login`. - -### 53. Existing Events Pages Still Load - -Visit: - -- `/events` -- `/events/` - -Expected: - -- Pages load. -- Registration status and attendance controls behave as before. - -### 54. Existing Announcements Pages Still Load - -Visit `/announcements`. - -Expected: - -- Page loads. -- Role-based announcement actions still behave as before. - -### 55. Existing Reports Pages Still Load - -Visit `/reports` as org admin and super admin. - -Expected: - -- Stats load. -- Organization scoping still works. - -## Suggested Automated Test Coverage - -Add API unit or e2e tests for: - -- `POST /members` creates scoped member. -- Duplicate username returns conflict. -- `PATCH /members/:id` respects org scope. -- `DELETE /members/:id` respects org scope. -- `POST /members/invitations` rejects cross-org existing users. -- `POST /members/invitations` rejects existing linked users. -- `POST /members/invitations` queues mail job. -- `POST /members/invitations/:id/resend` rotates token hash. -- `DELETE /members/invitations/:id` sets `revokedAt`. -- `POST /members/invitations/accept` creates user/member/session. -- Accepting expired/revoked/accepted invite fails. -- Auth `MEMBER` cannot access member management endpoints. -- `ORG_ADMIN` cannot access another organization's members or invitations. - -Add web tests for: - -- Member page access denied for auth member. -- Create dialog validation. -- Invite dialog validation. -- Successful invite appears in pending table. -- Successful create appears in active table. -- Edit updates row. -- Delete removes row. -- Accept invite page handles success and error states. - -## Cleanup After Manual Testing - -For repeatable test runs, reset and reseed: - -```bash -npx turbo run db:reset -npx turbo run db:seed -``` - -If you do not want a full reset, manually delete test records: - -- Test `Member` rows with usernames starting with `manual-`, `invite-`, or `super-`. -- Test `User` rows with emails used only for invitations. -- Test `MemberInvitation` rows for those emails. - -## Pass Criteria - -The feature is ready when: - -- All quality gates pass. -- Role access matrix behaves exactly as expected. -- CRUD works for org admins and super admins. -- Invitation send/resend/cancel/accept works. -- Invite acceptance creates exactly one user and one linked member. -- Single-org restrictions are enforced. -- `MemberRole.ADMIN` does not grant auth `ORG_ADMIN` or `SUPER_ADMIN`. -- Existing auth, events, announcements, and reports flows still work. -- No cross-tenant member or invitation access is possible. From be90c47968ad34a99050ce3d218a825efb92399e Mon Sep 17 00:00:00 2001 From: chko0 Date: Tue, 21 Jul 2026 21:40:23 +0300 Subject: [PATCH 3/6] fix: address member management review findings --- apps/api/src/auth/session.service.ts | 10 +- apps/api/src/members/members.controller.ts | 11 +- apps/api/src/members/members.service.ts | 118 ++++++++++-------- apps/web/app/(authenticated)/members/page.tsx | 4 +- apps/web/app/invite/accept/page.tsx | 9 +- apps/web/hooks/use-members.ts | 36 ++---- .../migration.sql | 2 + packages/database/prisma/schema.prisma | 21 ++-- 8 files changed, 115 insertions(+), 96 deletions(-) create mode 100644 packages/database/prisma/migrations/20260721183637_add_member_invitation_invited_by_index/migration.sql diff --git a/apps/api/src/auth/session.service.ts b/apps/api/src/auth/session.service.ts index 89e1bbb4..5821a585 100644 --- a/apps/api/src/auth/session.service.ts +++ b/apps/api/src/auth/session.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { randomBytes } from 'crypto'; import Redis from 'ioredis'; import { PrismaService } from '../database/prisma.service'; -import { User } from '@repo/db'; +import type { Prisma, User } from '@repo/db'; const SESSION_PREFIX = 'session:'; const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days @@ -24,12 +24,16 @@ export class SessionService { /** * Creates a new session in both DB and Redis */ - async createSession(userId: string): Promise { + async createSession( + userId: string, + tx?: Prisma.TransactionClient, + ): Promise { const sessionId = this.generateSessionId(); const expiresAt = new Date(Date.now() + SESSION_TTL_SECONDS * 1000); + const prisma = tx ?? this.prisma; // Store in database (source of truth) - await this.prisma.session.create({ + await prisma.session.create({ data: { id: sessionId, userId, diff --git a/apps/api/src/members/members.controller.ts b/apps/api/src/members/members.controller.ts index f0b069cb..23ba3eff 100644 --- a/apps/api/src/members/members.controller.ts +++ b/apps/api/src/members/members.controller.ts @@ -6,6 +6,7 @@ import { HttpCode, HttpStatus, Param, + ParseUUIDPipe, Patch, Post, Query, @@ -91,7 +92,7 @@ export class MembersController { @Post('invitations/:id/resend') @Roles('SUPER_ADMIN', 'ORG_ADMIN') async resendInvitation( - @Param('id') id: string, + @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, ): Promise { return this.membersService.resendInvitation(id, user); @@ -100,7 +101,7 @@ export class MembersController { @Delete('invitations/:id') @Roles('SUPER_ADMIN', 'ORG_ADMIN') async revokeInvitation( - @Param('id') id: string, + @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, ): Promise { return this.membersService.revokeInvitation(id, user); @@ -129,7 +130,7 @@ export class MembersController { @Get(':id') @Roles('SUPER_ADMIN', 'ORG_ADMIN') async findOne( - @Param('id') id: string, + @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, ): Promise { return this.membersService.findOne(id, user); @@ -138,7 +139,7 @@ export class MembersController { @Patch(':id') @Roles('SUPER_ADMIN', 'ORG_ADMIN') async update( - @Param('id') id: string, + @Param('id', ParseUUIDPipe) id: string, @Body(new ZodValidationPipe(memberUpdateRequestSchema)) body: MemberUpdateRequest, @CurrentUser() user: User, @@ -149,7 +150,7 @@ export class MembersController { @Delete(':id') @Roles('SUPER_ADMIN', 'ORG_ADMIN') async remove( - @Param('id') id: string, + @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, ): Promise { return this.membersService.remove(id, user); diff --git a/apps/api/src/members/members.service.ts b/apps/api/src/members/members.service.ts index a3632e82..13774455 100644 --- a/apps/api/src/members/members.service.ts +++ b/apps/api/src/members/members.service.ts @@ -165,6 +165,53 @@ export class MembersService { } } + private async getOrganization(organizationId: string) { + const organization = await this.prisma.organization.findFirst({ + where: { id: organizationId }, + select: { id: true, name: true }, + }); + + if (!organization) { + throw new NotFoundException('Organization not found'); + } + + return organization; + } + + private async issueInvitationToken( + invitation: { + id: string; + email: string; + organization: { name: string }; + }, + inviterName: string | null, + ) { + const token = randomBytes(32).toString('hex'); + const expiresAt = new Date( + Date.now() + INVITATION_EXPIRY_DAYS * 24 * 60 * 60 * 1000, + ); + + const updated = await this.prisma.memberInvitation.update({ + where: { id: invitation.id }, + data: { + tokenHash: this.hashToken(token), + expiresAt, + }, + }); + + const appUrl = process.env.APP_URL ?? 'http://localhost:3000'; + const invitationLink = `${appUrl}/invite/accept?token=${token}`; + + await this.mailQueue.add(MAIL_JOBS.SEND_INVITATION, { + email: invitation.email, + inviterName, + organizationName: invitation.organization.name, + invitationLink, + }); + + return updated; + } + async findAll( query: MemberListQuery, user: User, @@ -210,6 +257,7 @@ export class MembersService { user, body.organizationId, ); + await this.getOrganization(organizationId); await this.assertUsernameAvailable(organizationId, body.username); @@ -323,14 +371,7 @@ export class MembersService { body.organizationId, ); - const organization = await this.prisma.organization.findFirst({ - where: { id: organizationId }, - select: { id: true, name: true }, - }); - - if (!organization) { - throw new NotFoundException('Organization not found'); - } + const organization = await this.getOrganization(organizationId); await this.assertUsernameAvailable(organizationId, body.username); @@ -384,7 +425,6 @@ export class MembersService { ); } - const token = randomBytes(32).toString('hex'); const invitation = await this.prisma.memberInvitation.create({ data: { email: body.email, @@ -392,25 +432,23 @@ export class MembersService { role: body.role, organizationId, invitedById: user.id, - tokenHash: this.hashToken(token), - expiresAt: new Date( - Date.now() + INVITATION_EXPIRY_DAYS * 24 * 60 * 60 * 1000, - ), + tokenHash: this.hashToken(randomBytes(32).toString('hex')), + expiresAt: new Date(), + }, + include: { + organization: { + select: { name: true }, + }, }, }); - const appUrl = process.env.APP_URL ?? 'http://localhost:3000'; - const invitationLink = `${appUrl}/invite/accept?token=${token}`; - - await this.mailQueue.add(MAIL_JOBS.SEND_INVITATION, { - email: invitation.email, - inviterName: user.name, - organizationName: organization.name, - invitationLink, - }); + const issuedInvitation = await this.issueInvitationToken( + { ...invitation, organization }, + user.name, + ); this.logger.log(`Queued member invitation ${invitation.id}`); - return { invitation: this.mapInvitation(invitation) }; + return { invitation: this.mapInvitation(issuedInvitation) }; } async resendInvitation( @@ -436,26 +474,7 @@ export class MembersService { throw new NotFoundException('Invitation not found'); } - const token = randomBytes(32).toString('hex'); - const updated = await this.prisma.memberInvitation.update({ - where: { id: invitation.id }, - data: { - tokenHash: this.hashToken(token), - expiresAt: new Date( - Date.now() + INVITATION_EXPIRY_DAYS * 24 * 60 * 60 * 1000, - ), - }, - }); - - const appUrl = process.env.APP_URL ?? 'http://localhost:3000'; - const invitationLink = `${appUrl}/invite/accept?token=${token}`; - - await this.mailQueue.add(MAIL_JOBS.SEND_INVITATION, { - email: invitation.email, - inviterName: user.name, - organizationName: invitation.organization.name, - invitationLink, - }); + const updated = await this.issueInvitationToken(invitation, user.name); this.logger.log(`Resent member invitation ${invitation.id}`); return { invitation: this.mapInvitation(updated) }; @@ -588,17 +607,18 @@ export class MembersService { data: { acceptedAt: new Date() }, }); - return { userRecord, memberRole: member.role }; - }); + const sessionId = await this.sessionService.createSession( + userRecord.id, + tx, + ); - const sessionId = await this.sessionService.createSession( - result.userRecord.id, - ); + return { sessionId, userRecord, memberRole: member.role }; + }); this.logger.log(`Accepted member invitation ${invitation.id}`); return { - sessionId, + sessionId: result.sessionId, user: this.mapUserResponse(result.userRecord, result.memberRole), }; } diff --git a/apps/web/app/(authenticated)/members/page.tsx b/apps/web/app/(authenticated)/members/page.tsx index 495d0999..d855cdee 100644 --- a/apps/web/app/(authenticated)/members/page.tsx +++ b/apps/web/app/(authenticated)/members/page.tsx @@ -61,8 +61,8 @@ import { import { Skeleton } from '@/components/ui/skeleton'; const ROLE_COLORS: Record = { - ADMIN: 'bg-blue-100 text-blue-800', - PRESENTER: 'bg-purple-100 text-purple-800', + ADMIN: 'bg-primary-100 text-primary-base', + PRESENTER: 'bg-warning-light text-gray-900', }; function RoleBadge({ role }: { role: MemberRole }) { diff --git a/apps/web/app/invite/accept/page.tsx b/apps/web/app/invite/accept/page.tsx index 8bc86905..dc4307fb 100644 --- a/apps/web/app/invite/accept/page.tsx +++ b/apps/web/app/invite/accept/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Suspense, useEffect, useState } from 'react'; +import { Suspense, useEffect, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { CheckCircle2, Loader2, XCircle } from 'lucide-react'; import { toast } from 'sonner'; @@ -15,8 +15,13 @@ function AcceptInvitationContent() { const { acceptMemberInvitation } = useAuth(); const [status, setStatus] = useState('loading'); const [errorMessage, setErrorMessage] = useState(''); + const hasAttemptedAccept = useRef(false); useEffect(() => { + if (hasAttemptedAccept.current) { + return; + } + const token = searchParams.get('token'); if (!token) { @@ -25,6 +30,8 @@ function AcceptInvitationContent() { return; } + hasAttemptedAccept.current = true; + const accept = async () => { try { await acceptMemberInvitation({ token }); diff --git a/apps/web/hooks/use-members.ts b/apps/web/hooks/use-members.ts index ab20d4f1..3e8a7bfd 100644 --- a/apps/web/hooks/use-members.ts +++ b/apps/web/hooks/use-members.ts @@ -45,21 +45,13 @@ export function useMembers(options: UseMembersOptions = {}): UseMembersReturn { const { enabled = true, organizationId } = options; const endpoint = buildMembersEndpoint('/members', organizationId); - const { - data, - error, - isLoading, - mutate: swrMutate, - } = useSWR(enabled ? endpoint : null); + const { data, error, isLoading } = useSWR( + enabled ? endpoint : null, + ); const invalidateMembers = useCallback(() => { - swrMutate(); - mutate( - (key) => typeof key === 'string' && key.startsWith('/members'), - undefined, - { revalidate: true }, - ); - }, [swrMutate]); + mutate((key) => typeof key === 'string' && key.startsWith('/members')); + }, []); const createMember = useCallback( async (body: MemberCreateRequest) => { @@ -122,21 +114,13 @@ export function useMemberInvitations( const { enabled = true, organizationId } = options; const endpoint = buildMembersEndpoint('/members/invitations', organizationId); - const { - data, - error, - isLoading, - mutate: swrMutate, - } = useSWR(enabled ? endpoint : null); + const { data, error, isLoading } = useSWR( + enabled ? endpoint : null, + ); const invalidateInvitations = useCallback(() => { - swrMutate(); - mutate( - (key) => typeof key === 'string' && key.startsWith('/members'), - undefined, - { revalidate: true }, - ); - }, [swrMutate]); + mutate((key) => typeof key === 'string' && key.startsWith('/members')); + }, []); const inviteMember = useCallback( async (body: MemberInviteRequest) => { diff --git a/packages/database/prisma/migrations/20260721183637_add_member_invitation_invited_by_index/migration.sql b/packages/database/prisma/migrations/20260721183637_add_member_invitation_invited_by_index/migration.sql new file mode 100644 index 00000000..9f6210fb --- /dev/null +++ b/packages/database/prisma/migrations/20260721183637_add_member_invitation_invited_by_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "MemberInvitation_invitedById_idx" ON "private"."MemberInvitation"("invitedById"); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 3ce8b4da..b056d4b7 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -79,9 +79,9 @@ model User { sessions Session[] magicLinks MagicLink[] profile UserProfile? - organization Organization? @relation("OrganizationMembers", fields: [organizationId], references: [id], onDelete: SetNull) - createdOrganization Organization? @relation("OrganizationCreator") - approvedOrganizations Organization[] @relation("OrganizationApprover") + organization Organization? @relation("OrganizationMembers", fields: [organizationId], references: [id], onDelete: SetNull) + createdOrganization Organization? @relation("OrganizationCreator") + approvedOrganizations Organization[] @relation("OrganizationApprover") eventAttendees EventAttendee[] orgMemberships Member[] announcements Announcement[] @@ -154,13 +154,13 @@ model Organization { updatedAt DateTime @updatedAt approvedAt DateTime? // When super admin approved - createdBy User @relation("OrganizationCreator", fields: [createdById], references: [id]) - approvedBy User? @relation("OrganizationApprover", fields: [approvedById], references: [id]) - users User[] @relation("OrganizationMembers") - members Member[] - events Event[] - eventAttendees EventAttendee[] - announcements Announcement[] + createdBy User @relation("OrganizationCreator", fields: [createdById], references: [id]) + approvedBy User? @relation("OrganizationApprover", fields: [approvedById], references: [id]) + users User[] @relation("OrganizationMembers") + members Member[] + events Event[] + eventAttendees EventAttendee[] + announcements Announcement[] memberInvitations MemberInvitation[] @@index([approvedById]) @@ -205,6 +205,7 @@ model MemberInvitation { invitedBy User @relation(fields: [invitedById], references: [id], onDelete: Cascade) @@index([organizationId]) + @@index([invitedById]) @@index([email]) @@index([expiresAt]) @@schema("private") From c4d81dcda689321fcfbe066856adfdc9bb51b089 Mon Sep 17 00:00:00 2001 From: chko0 Date: Tue, 21 Jul 2026 21:44:33 +0300 Subject: [PATCH 4/6] docs: add member management function docstrings --- apps/api/src/members/members.controller.ts | 27 ++++++++++ apps/api/src/members/members.service.ts | 54 +++++++++++++++++++ apps/web/app/(authenticated)/members/page.tsx | 27 ++++++++++ apps/web/app/invite/accept/page.tsx | 9 ++++ apps/web/hooks/use-auth.ts | 3 ++ apps/web/hooks/use-members.ts | 30 +++++++++++ 6 files changed, 150 insertions(+) diff --git a/apps/api/src/members/members.controller.ts b/apps/api/src/members/members.controller.ts index 23ba3eff..bcbcc81f 100644 --- a/apps/api/src/members/members.controller.ts +++ b/apps/api/src/members/members.controller.ts @@ -42,6 +42,9 @@ const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; export class MembersController { constructor(private readonly membersService: MembersService) {} + /** + * Lists pending member invitations visible to the current admin. + */ @Get('invitations') @Roles('SUPER_ADMIN', 'ORG_ADMIN') async findInvitations( @@ -52,6 +55,9 @@ export class MembersController { return this.membersService.findInvitations(query, user); } + /** + * Accepts a public member invitation and sets the session cookie. + */ @Post('invitations/accept') @Public() @HttpCode(HttpStatus.OK) @@ -79,6 +85,9 @@ export class MembersController { return { user }; } + /** + * Sends a new member invitation for the current organization scope. + */ @Post('invitations') @Roles('SUPER_ADMIN', 'ORG_ADMIN') async invite( @@ -89,6 +98,9 @@ export class MembersController { return this.membersService.invite(body, user); } + /** + * Issues a fresh token and email for an existing pending invitation. + */ @Post('invitations/:id/resend') @Roles('SUPER_ADMIN', 'ORG_ADMIN') async resendInvitation( @@ -98,6 +110,9 @@ export class MembersController { return this.membersService.resendInvitation(id, user); } + /** + * Revokes a pending member invitation in the current organization scope. + */ @Delete('invitations/:id') @Roles('SUPER_ADMIN', 'ORG_ADMIN') async revokeInvitation( @@ -117,6 +132,9 @@ export class MembersController { return this.membersService.findAll(query, user); } + /** + * Creates a Coordly member without creating an auth user. + */ @Post() @Roles('SUPER_ADMIN', 'ORG_ADMIN') async create( @@ -127,6 +145,9 @@ export class MembersController { return this.membersService.create(body, user); } + /** + * Finds one Coordly member in the current organization scope. + */ @Get(':id') @Roles('SUPER_ADMIN', 'ORG_ADMIN') async findOne( @@ -136,6 +157,9 @@ export class MembersController { return this.membersService.findOne(id, user); } + /** + * Updates a Coordly member in the current organization scope. + */ @Patch(':id') @Roles('SUPER_ADMIN', 'ORG_ADMIN') async update( @@ -147,6 +171,9 @@ export class MembersController { return this.membersService.update(id, body, user); } + /** + * Deletes a Coordly member in the current organization scope. + */ @Delete(':id') @Roles('SUPER_ADMIN', 'ORG_ADMIN') async remove( diff --git a/apps/api/src/members/members.service.ts b/apps/api/src/members/members.service.ts index 13774455..57417c04 100644 --- a/apps/api/src/members/members.service.ts +++ b/apps/api/src/members/members.service.ts @@ -41,10 +41,16 @@ export class MembersService { @InjectQueue(MAIL_QUEUE) private readonly mailQueue: Queue, ) {} + /** + * Hashes a raw invitation token for safe database storage. + */ private hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } + /** + * Maps a Prisma member record into the shared contract response shape. + */ private mapMember(member: { id: string; username: string; @@ -63,6 +69,9 @@ export class MembersService { }; } + /** + * Maps a Prisma invitation record into the shared contract response shape. + */ private mapInvitation(invitation: { id: string; email: string; @@ -89,6 +98,9 @@ export class MembersService { }; } + /** + * Maps an auth user and optional Coordly member role into a user response. + */ private mapUserResponse( user: Pick< User, @@ -107,6 +119,9 @@ export class MembersService { }; } + /** + * Resolves an organization scope and rejects requests without one. + */ private resolveRequiredOrganizationScope( user: User, requestedOrganizationId?: string, @@ -123,6 +138,9 @@ export class MembersService { return organizationId; } + /** + * Loads a member constrained to the caller's organization scope. + */ private async getScopedMember(id: string, user: User) { const organizationId = resolveOrganizationScope(user); const member = await this.prisma.member.findFirst({ @@ -146,6 +164,9 @@ export class MembersService { return member; } + /** + * Ensures a username is unused within an organization. + */ private async assertUsernameAvailable( organizationId: string, username: string, @@ -165,6 +186,9 @@ export class MembersService { } } + /** + * Loads an organization or raises the shared not-found response. + */ private async getOrganization(organizationId: string) { const organization = await this.prisma.organization.findFirst({ where: { id: organizationId }, @@ -178,6 +202,9 @@ export class MembersService { return organization; } + /** + * Rotates an invitation token, extends expiry, and queues the email. + */ private async issueInvitationToken( invitation: { id: string; @@ -244,11 +271,17 @@ export class MembersService { return { members: members.map((member) => this.mapMember(member)), total }; } + /** + * Finds a single Coordly member after scope validation. + */ async findOne(id: string, user: User): Promise { const member = await this.getScopedMember(id, user); return { member: this.mapMember(member) }; } + /** + * Creates an unlinked Coordly member in the resolved organization scope. + */ async create( body: MemberCreateRequest, user: User, @@ -280,6 +313,9 @@ export class MembersService { return { member: this.mapMember(member) }; } + /** + * Updates an existing Coordly member after scope and username checks. + */ async update( id: string, body: MemberUpdateRequest, @@ -314,6 +350,9 @@ export class MembersService { return { member: this.mapMember(member) }; } + /** + * Deletes an existing Coordly member after scope validation. + */ async remove(id: string, user: User): Promise { const existing = await this.getScopedMember(id, user); const member = await this.prisma.member.delete({ @@ -331,6 +370,9 @@ export class MembersService { return { member: this.mapMember(member) }; } + /** + * Lists active pending invitations visible to the current admin. + */ async findInvitations( query: MemberListQuery, user: User, @@ -362,6 +404,9 @@ export class MembersService { }; } + /** + * Creates and emails a pending member invitation. + */ async invite( body: MemberInviteRequest, user: User, @@ -451,6 +496,9 @@ export class MembersService { return { invitation: this.mapInvitation(issuedInvitation) }; } + /** + * Reissues a pending invitation with a fresh token and expiry. + */ async resendInvitation( id: string, user: User, @@ -480,6 +528,9 @@ export class MembersService { return { invitation: this.mapInvitation(updated) }; } + /** + * Marks a pending invitation as revoked. + */ async revokeInvitation( id: string, user: User, @@ -507,6 +558,9 @@ export class MembersService { return { invitation: this.mapInvitation(updated) }; } + /** + * Accepts an invitation by creating the user, member, and session atomically. + */ async acceptInvitation( token: string, ): Promise<{ sessionId: string } & MemberInvitationAcceptResponse> { diff --git a/apps/web/app/(authenticated)/members/page.tsx b/apps/web/app/(authenticated)/members/page.tsx index d855cdee..af884395 100644 --- a/apps/web/app/(authenticated)/members/page.tsx +++ b/apps/web/app/(authenticated)/members/page.tsx @@ -101,6 +101,9 @@ function truncateId(id: string) { return id.slice(0, 8); } +/** + * Formats an invitation expiration date for the members table. + */ function formatDate(value: string) { return new Intl.DateTimeFormat('en', { month: 'short', @@ -113,10 +116,16 @@ function canAccessMembers(role: string | undefined) { return role === 'SUPER_ADMIN' || role === 'ORG_ADMIN'; } +/** + * Converts unknown mutation errors into user-facing messages. + */ function errorMessage(error: unknown, fallback: string) { return error instanceof ApiError ? error.message : fallback; } +/** + * Renders the organization selector used by super admin member forms. + */ function OrganizationField({ value, onChange, @@ -231,6 +240,9 @@ export default function MembersPage() { return ; } + /** + * Submits a new unlinked Coordly member. + */ const onCreate = async (body: MemberCreateRequest) => { try { await createMember(body); @@ -242,6 +254,9 @@ export default function MembersPage() { } }; + /** + * Submits a new member invitation. + */ const onInvite = async (body: MemberInviteRequest) => { try { await inviteMember(body); @@ -253,6 +268,9 @@ export default function MembersPage() { } }; + /** + * Submits updates for the selected member. + */ const onEdit = async (body: MemberUpdateRequest) => { if (!editingMember) return; @@ -265,6 +283,9 @@ export default function MembersPage() { } }; + /** + * Confirms and deletes a member. + */ const onDelete = async (member: Member) => { if (!window.confirm(`Delete ${member.username}?`)) return; @@ -276,6 +297,9 @@ export default function MembersPage() { } }; + /** + * Resends a pending invitation. + */ const onResend = async (invitation: MemberInvitation) => { try { await resendInvitation(invitation.id); @@ -285,6 +309,9 @@ export default function MembersPage() { } }; + /** + * Confirms and revokes a pending invitation. + */ const onRevoke = async (invitation: MemberInvitation) => { if (!window.confirm(`Cancel invitation to ${invitation.email}?`)) return; diff --git a/apps/web/app/invite/accept/page.tsx b/apps/web/app/invite/accept/page.tsx index dc4307fb..188e4755 100644 --- a/apps/web/app/invite/accept/page.tsx +++ b/apps/web/app/invite/accept/page.tsx @@ -9,6 +9,9 @@ import { ApiError } from '@/lib/api'; type AcceptStatus = 'loading' | 'success' | 'error'; +/** + * Accepts a member invitation token from the URL and shows the result state. + */ function AcceptInvitationContent() { const router = useRouter(); const searchParams = useSearchParams(); @@ -32,6 +35,9 @@ function AcceptInvitationContent() { hasAttemptedAccept.current = true; + /** + * Sends the invitation token to the API and redirects after success. + */ const accept = async () => { try { await acceptMemberInvitation({ token }); @@ -102,6 +108,9 @@ function AcceptInvitationContent() { ); } +/** + * Renders the public invitation acceptance page with suspense for URL params. + */ export default function AcceptInvitationPage() { return ( { const result = await apiPost( diff --git a/apps/web/hooks/use-members.ts b/apps/web/hooks/use-members.ts index 3e8a7bfd..e5fdb67b 100644 --- a/apps/web/hooks/use-members.ts +++ b/apps/web/hooks/use-members.ts @@ -32,6 +32,9 @@ interface UseMembersReturn { mutate: () => void; } +/** + * Builds a members API endpoint with an optional organization filter. + */ function buildMembersEndpoint(base: string, organizationId?: string): string { if (!organizationId) { return base; @@ -49,10 +52,16 @@ export function useMembers(options: UseMembersOptions = {}): UseMembersReturn { enabled ? endpoint : null, ); + /** + * Revalidates all cached member and invitation lists. + */ const invalidateMembers = useCallback(() => { mutate((key) => typeof key === 'string' && key.startsWith('/members')); }, []); + /** + * Creates a member and refreshes member-related caches. + */ const createMember = useCallback( async (body: MemberCreateRequest) => { const result = await apiPost('/members', body); @@ -62,6 +71,9 @@ export function useMembers(options: UseMembersOptions = {}): UseMembersReturn { [invalidateMembers], ); + /** + * Updates a member and refreshes member-related caches. + */ const updateMember = useCallback( async (id: string, body: MemberUpdateRequest) => { const result = await apiPatch( @@ -74,6 +86,9 @@ export function useMembers(options: UseMembersOptions = {}): UseMembersReturn { [invalidateMembers], ); + /** + * Deletes a member and refreshes member-related caches. + */ const deleteMember = useCallback( async (id: string) => { const result = await apiDelete(`/members/${id}`); @@ -108,6 +123,9 @@ interface UseMemberInvitationsReturn { mutate: () => void; } +/** + * Fetches pending invitations and exposes invitation mutation helpers. + */ export function useMemberInvitations( options: UseMembersOptions = {}, ): UseMemberInvitationsReturn { @@ -118,10 +136,16 @@ export function useMemberInvitations( enabled ? endpoint : null, ); + /** + * Revalidates all cached member and invitation lists. + */ const invalidateInvitations = useCallback(() => { mutate((key) => typeof key === 'string' && key.startsWith('/members')); }, []); + /** + * Sends an invitation and refreshes member-related caches. + */ const inviteMember = useCallback( async (body: MemberInviteRequest) => { const result = await apiPost( @@ -134,6 +158,9 @@ export function useMemberInvitations( [invalidateInvitations], ); + /** + * Resends an invitation and refreshes member-related caches. + */ const resendInvitation = useCallback( async (id: string) => { const result = await apiPost( @@ -145,6 +172,9 @@ export function useMemberInvitations( [invalidateInvitations], ); + /** + * Revokes an invitation and refreshes member-related caches. + */ const revokeInvitation = useCallback( async (id: string) => { const result = await apiDelete( From c1acdd84230dc5623d2cdf94ca9f714adf0dd0e2 Mon Sep 17 00:00:00 2001 From: chko0 Date: Wed, 22 Jul 2026 07:47:33 +0300 Subject: [PATCH 5/6] feat: enhance session caching in session service and handle errors in member invitation acceptance --- apps/api/src/auth/session.service.ts | 47 ++++++++++++++++++++++--- apps/api/src/members/members.service.ts | 8 +++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/api/src/auth/session.service.ts b/apps/api/src/auth/session.service.ts index 5821a585..edfa16f1 100644 --- a/apps/api/src/auth/session.service.ts +++ b/apps/api/src/auth/session.service.ts @@ -41,16 +41,53 @@ export class SessionService { }, }); + if (tx) { + return sessionId; + } + // Cache in Redis with TTL - const sessionData: SessionData = { userId, expiresAt }; + await this.cacheSessionData(sessionId, { userId, expiresAt }); + + this.logger.log(`Session created for user ${userId}`); + return sessionId; + } + + /** + * Caches a committed database session in Redis. + */ + async cacheSession(sessionId: string): Promise { + const session = await this.prisma.session.findUnique({ + where: { id: sessionId }, + select: { userId: true, expiresAt: true }, + }); + + if (!session || session.expiresAt < new Date()) { + return; + } + + await this.cacheSessionData(sessionId, session); + } + + /** + * Writes session data to Redis using the remaining database TTL. + */ + private async cacheSessionData( + sessionId: string, + sessionData: SessionData, + ): Promise { + const ttlSeconds = Math.floor( + (sessionData.expiresAt.getTime() - Date.now()) / 1000, + ); + + if (ttlSeconds <= 0) { + return; + } + await this.redis.setex( `${SESSION_PREFIX}${sessionId}`, - SESSION_TTL_SECONDS, + ttlSeconds, JSON.stringify(sessionData), ); - - this.logger.log(`Session created for user ${userId}`); - return sessionId; } /** diff --git a/apps/api/src/members/members.service.ts b/apps/api/src/members/members.service.ts index 57417c04..84d75ab9 100644 --- a/apps/api/src/members/members.service.ts +++ b/apps/api/src/members/members.service.ts @@ -669,6 +669,14 @@ export class MembersService { return { sessionId, userRecord, memberRole: member.role }; }); + await this.sessionService.cacheSession(result.sessionId).catch((error) => { + this.logger.warn( + `Failed to cache accepted invitation session ${result.sessionId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + this.logger.log(`Accepted member invitation ${invitation.id}`); return { From 916eeebc7e0fdf8fa2d226943b6dcc03256824f7 Mon Sep 17 00:00:00 2001 From: chko0 Date: Wed, 22 Jul 2026 07:50:42 +0300 Subject: [PATCH 6/6] feat: create index concurrently for member invitation invitedById --- .../migration.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/database/prisma/migrations/20260721183637_add_member_invitation_invited_by_index/migration.sql b/packages/database/prisma/migrations/20260721183637_add_member_invitation_invited_by_index/migration.sql index 9f6210fb..c5a55c87 100644 --- a/packages/database/prisma/migrations/20260721183637_add_member_invitation_invited_by_index/migration.sql +++ b/packages/database/prisma/migrations/20260721183637_add_member_invitation_invited_by_index/migration.sql @@ -1,2 +1,2 @@ -- CreateIndex -CREATE INDEX "MemberInvitation_invitedById_idx" ON "private"."MemberInvitation"("invitedById"); +CREATE INDEX CONCURRENTLY "MemberInvitation_invitedById_idx" ON "private"."MemberInvitation"("invitedById");