plans/staff-invitation-plan.md

Staff Invitation Plan

Status: 🚧 Chưa bắt đầu — scoped 2026-05-13 Mục tiêu: Chuyển từ owner-set-password sang email invitation, fix lỗ hổng owner đoán password cross-tenant. Effort: ~4 ngày dev Liên quan: Subdomain migration plan (deferred — invitation flow là interim fix; sau migration vẫn dùng tiếp)


Motivation

Lỗ hổng hiện tại: Owner tenant A có thể set password staff X. Nếu staff X dùng password đó ở tenant B → owner A login /admin/signin với credentials đó → tenant picker reveal tenant B → leak workplace của staff.

Fix: Owner không bao giờ biết/set password staff. Staff tự đặt password qua email invitation (pattern Slack/GitHub/Linear).


Decisions đã chốt

Topic Decision
User lifecycle Pre-create User + Resource pending (password=null, isActive=false, isBookableOnline=false) khi owner invite. Activate khi staff accept.
Email immutability Owner KHÔNG sửa email pending staff — sai email thì revoke + invite lại
Pending trong admin Hiển thị trong staff list + calendar; owner gán schedule/services trước được
Pending trong public booking Ẩn (isBookableOnline=false enforce)
Toggle on/off Giữ — isActive flag áp dụng cho staff đã accept
Reset password trong UI admin Xóa hoàn toàn — không ai ngoài chính staff được set password
Legacy staff migration Không force re-password (out of scope)
Copy invite link Không build — chỉ có Resend

Phase 1 — Schema + migration

File: booking-api/prisma/schema.prisma + migrations/<timestamp>_add_staff_invitation/migration.sql

model StaffInvitation {
  id            String    @id @default(uuid())
  tenantId      String    @map("tenant_id")
  userId        String    @map("user_id")
  invitedBy     String    @map("invited_by")
  email         String                              // snapshot, immutable
  tokenHash     String    @unique @map("token_hash")
  expiresAt     DateTime  @map("expires_at")
  acceptedAt    DateTime? @map("accepted_at")
  revokedAt     DateTime? @map("revoked_at")
  resentCount   Int       @default(0) @map("resent_count")
  lastResentAt  DateTime? @map("last_resent_at")
  createdAt     DateTime  @default(now()) @map("created_at")
  
  user      User    @relation(fields: [userId], references: [id], onDelete: Cascade)
  tenant    Tenant  @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  inviter   User    @relation("InviterInvitations", fields: [invitedBy], references: [id])
  
  @@index([tenantId])
  @@index([userId])
  @@index([expiresAt])
  @@map("staff_invitations")
}

User model: thêm relation inviterInvitations StaffInvitation[] @relation("InviterInvitations")

Acceptance:

  • Migration apply trên fresh DB không lỗi
  • Folder name sort lexicographically đúng intent order
  • Run yarn --cwd booking-api db:migrate thành công ở local

Phase 2 — Backend module staff-invitation/

Files mới:

booking-api/src/core/staff-invitation/
├── staff-invitation.module.ts
├── staff-invitation.controller.ts          # /admin/staff/invitations/*
├── staff-invitation-public.controller.ts   # /auth/invitations/verify, /accept
├── staff-invitation.service.ts
├── staff-invitation.service.spec.ts
├── staff-invitation.controller.spec.ts
├── staff-invitation.dto.ts
└── errors.ts

Endpoints:

Method Path Guard Action
POST /admin/staff/invitations OWNER Tạo invite
POST /admin/staff/invitations/:id/resend OWNER Regenerate token + email lại
POST /admin/staff/invitations/:id/revoke OWNER Cancel pending → cascade delete User+Resource
GET /auth/invitations/verify Public ?token=xxx{ tenantName, salonLogo, email, role }
POST /auth/invitations/accept Public { token, password } → set password + auto sign-in

Domain errors (extend Error, register via APP_FILTER + global fallback):

  • INVITATION_NOT_FOUND 404
  • INVITATION_EXPIRED 410
  • INVITATION_ALREADY_ACCEPTED 409
  • INVITATION_REVOKED 410
  • EMAIL_ALREADY_INVITED 409
  • EMAIL_ALREADY_REGISTERED 409

Rate limits:

  • Create invite: 10/h/tenant
  • Verify token: 5/min/IP
  • Accept: 3/min/IP
  • Resend: 3 max per invitation, ≥5min gap

Security:

  • Token raw 32 bytes crypto.randomBytes, DB lưu sha256
  • Single-use, expire 7 ngày
  • Không log raw token
  • invitedBy audit trail

Acceptance:

  • Service spec 100% coverage cho service logic
  • Controller integration tests cover happy path + 6 error codes + tenant isolation
  • Rate limit thực thi (test với 11 request liên tiếp)

Phase 3 — Email template

Folder: booking-api/src/core/email/templates/staff-invitation/

Files: components.ts, i18n.ts, render.spec.ts, index.ts, layout.ts (theo pattern hiện tại).

Content nb-NO + en:

  • Subject: <Inviter> har invitert deg til <Salon> / <Inviter> invited you to <Salon>
  • Body: salon logo, tên salon, role, CTA "Sett opp kontoen din" / "Set up your account"
  • Link: https://glamvoo.com/accept-invite?token=<raw> (subdomain-ready)
  • Footer: link expire 7 ngày + contact owner

Acceptance:

  • Snapshot test pass cho cả nb-NO + en
  • Branded với tenant logo + accent color (reuse branding.resolver)
  • QA manual: gửi test invite cho 1 email thật, render đúng

Phase 4 — Admin UI

Files: booking-web/src/app/admin/(dashboard)/staff/page.tsx + components/staff/InviteStaffModal.tsx

Changes:

  1. "Tạo nhân viên" → "Mời nhân viên"

    • Modal mới: email (Zod required) + name (optional, default email prefix) + role select (STAFF/MANAGER)
    • Hint: "Nhân viên sẽ nhận email và tự đặt mật khẩu"
    • Submit → invalidate ['staff', tenantId] + ['staff-invitations', tenantId]
  2. Staff row update

    • Badge Pending invite (amber) cho user password=null
    • Dropdown actions cho pending: Resend (disable kèm countdown nếu mới resend), Revoke, Edit info (name/role/color/services/schedule — KHÔNG email/password)
    • Dropdown cho active: Toggle active, Edit info, Delete — bỏ "Reset password"
  3. Edit resource form

    • Bỏ field email (immutable)
    • Bỏ field password hoàn toàn
    • Giữ: name, role, phone, color, sortOrder, services, schedule, time-offs

Acceptance:

  • Modal submit thành công → row mới với badge Pending
  • Resend disable đúng countdown
  • Revoke confirm dialog + cascade delete
  • Pending staff hiện trong calendar admin, ẩn trong customer booking page

Phase 5 — Accept page (customer-facing)

File mới: booking-web/src/app/(customer)/accept-invite/page.tsx (+ client form component)

Flow:

  1. Server component đọc ?token=xxx
  2. Server-fetch GET /api/auth/invitations/verify → preview hoặc error
  3. Error → render error state + CTA "Liên hệ chủ salon"
  4. OK → render form:
    • Salon logo + tên (branded)
    • Email pre-filled disabled
    • Password input + confirm (Zod min 8, complexity)
    • (Optional Phase 2 future) "Continue with Google" nếu Google email match
  5. Submit → POST /api/auth/invitations/accept → store tokens → redirect /admin

Acceptance:

  • Form validate Zod đúng (min 8, match confirm)
  • Error states cover 4 cases: not_found, expired, used, revoked
  • Tokens cookie set, redirect /admin thành công
  • i18n nb-NO + en đầy đủ

Phase 6 — Remove password attack surface

Files:

  • booking-api/src/core/resource/resource.dto.ts — bỏ login.password, login.email, login.role khỏi CreateResourceDto; bỏ password khỏi UpdateResourceDto
  • booking-api/src/core/resource/resource.service.tscreateResource không tạo User; update không touch password
  • booking-api/src/core/resource/resource.controller.ts — chỉ accept resource fields
  • booking-api/src/core/resource/resource.service.spec.ts — update tests, bỏ "create with login" tests

⚠️ Phải đồng bộ với Phase 4 — UI cũ "Tạo staff với password" phải remove cùng commit.

Acceptance:

  • DTO không còn field password anywhere
  • Resource service tests pass với DTO mới
  • Pre-existing staff login bình thường (legacy không impact)

Phase 7 — Tests

Unit (staff-invitation.service.spec.ts):

  • Create: success, duplicate email pending → idempotent return existing, duplicate email active → 409
  • Token: deterministic hash, raw random 32 bytes
  • Verify: expired/used/revoked/not_found → đúng error code
  • Accept: success flip isActive + set password, single-use, atomic rollback
  • Resend: rate limit 3 max, ≥5min gap, regenerate token (old invalidated)
  • Revoke: cascade-delete pending User + Resource

Integration (staff-invitation.controller.spec.ts):

  • JWT OWNER tạo invite → 201
  • JWT STAFF → 403
  • Public verify/accept không cần JWT → 200
  • Tenant isolation: invite của A không thấy từ JWT B

E2E (booking-web/tests/e2e/staff-invitation.spec.ts):

  • Full flow: owner invite → SMTP mock capture → click link → set password → land /admin
  • Resend → new token works, old token rejected
  • Revoke → pending row gone → accept page error
  • Security: edit resource form KHÔNG có input password (DOM assert)

Coverage target: ≥ 80% per file


Phase 8 — Docs + deploy

Docs cần update:

  • docs/architecture/api-design.md — section "Staff invitation"
  • docs/flows/staff-invitation-flow.md (mới) — sequence diagram + state machine + error codes
  • docs/progress/features.md — flip 🚧 → ✅
  • docs/progress/changelog.md — entry
  • docs/operations/troubleshooting.md — symptom "invite email không đến" → runbook

Deploy:

  1. Backend deploy first (migration + new endpoints, backward compat UI cũ)
  2. Frontend deploy second (UI mới, remove password fields)
  3. QA: gửi test invite cho email thật, verify nb-NO + en

Execution timeline (4 ngày)

Day Phases
1 Phase 1 (schema) + Phase 2 (service core: create + tests)
2 Phase 2 (verify/accept/resend/revoke) + Phase 3 (email template)
3 Phase 4 (admin UI) + Phase 5 (accept page) + integration tests
4 Phase 6 (remove password) + E2E + Phase 8 (docs + deploy)

Risks

Risk Mức Mitigation
Pending User existing trong DB (legacy password IS NULL) LOW Pre-migration audit: SELECT COUNT(*) FROM users WHERE password IS NULL
SMTP fail / email tới spam MEDIUM Resend button prominent, log queue success/fail
Legacy password attack window vẫn còn MEDIUM Out of scope (user decision); có thể ship "force rotate" sau
Token raw lộ trong log HIGH Audit log code: KHÔNG log token; redact email body trong audit
Multi-tab accept LOW Single-use token, lần 2 → "already used, login"

Out of scope (defer)

  • Force re-password cho legacy staff (separate feature "Security audit")
  • Copy invite link cho owner gửi manual (Resend đủ)
  • Google OAuth ngay tại accept-invite (chỉ password trước, OAuth có thể link sau)
  • Bulk invite (CSV upload) — Phase 2 nếu có demand