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:migratethà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_FOUND404INVITATION_EXPIRED410INVITATION_ALREADY_ACCEPTED409INVITATION_REVOKED410EMAIL_ALREADY_INVITED409EMAIL_ALREADY_REGISTERED409
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
invitedByaudit 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:
"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]
Staff row update
- Badge
Pending invite(amber) cho userpassword=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"
- Badge
Edit resource form
- Bỏ field
email(immutable) - Bỏ field
passwordhoàn toàn - Giữ: name, role, phone, color, sortOrder, services, schedule, time-offs
- Bỏ field
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:
- Server component đọc
?token=xxx - Server-fetch
GET /api/auth/invitations/verify→ preview hoặc error - Error → render error state + CTA "Liên hệ chủ salon"
- 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
- 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
/adminthà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.rolekhỏiCreateResourceDto; bỏpasswordkhỏiUpdateResourceDtobooking-api/src/core/resource/resource.service.ts—createResourcekhông tạo User;updatekhông touch passwordbooking-api/src/core/resource/resource.controller.ts— chỉ accept resource fieldsbooking-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
passwordanywhere - 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:
- Backend deploy first (migration + new endpoints, backward compat UI cũ)
- Frontend deploy second (UI mới, remove password fields)
- 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