직원 초대 흐름 — 운영 runbook
직원 초대의 전체 흐름 · 토큰 정책 · 오류 분기 · 트러블슈팅 SSoT. 2026-06-11 사고 체인 (PR #2747~#2751) 이후의 현행 동작을 기술한다. 배경은 회고 참조.
1. 전체 흐름
- 발송:
apps/web/app/(workspace)/settings구성원 관리 (대표 전용). 비즈니스 로직은packages/business-logic/src/members/invite.ts - 수락 페이지:
apps/web/app/staff-invite/page.tsx(공개 라우트 — 그룹 layout 가드 밖) - 수락 액션:
apps/web/app/(setup)/actions/invite.ts—ActionResult반환 (throw 는 production 에서 메시지가 마스킹되므로 금지)
2. 토큰 정책 (2026-06-11 이후)
| 동작 | 토큰/코드 | 만료 | 비고 |
|---|---|---|---|
| 신규 초대 | 신규 발급 (randomBytes(32) hex 64자 + INV-XXXXXXX) | 7일 | |
| 재발송 | 기존 재사용 | 연장 (now+7일) | 수신함의 모든 초대 메일 링크가 계속 유효. #2563 의 회전 동작은 #2747 에서 폐기 |
| 링크 무효화 | "구성원 제거 → 재초대" 만 | — | 의도적 무효화의 유일한 명시 경로 |
| left 재활성 | 신규 발급 | 7일 | 이전 멤버십의 옛 링크 부활 차단 |
| 수락 완료 | 토큰 필드는 doc 에 보존 | — | "이미 수락" 분기 식별에 사용 (이메일 등 상세는 미노출) |
3. 오류 분기 (errorCode)
| errorCode | 화면 | 원인 | 사용자/운영자 행동 |
|---|---|---|---|
invalid | 초대 링크 오류 | 토큰 미존재 — 2026-06-10 이전 회전된 링크 · 제거된 초대 · 오타 | 가장 최근 메일 확인 → 없으면 대표에게 재초대 요청 |
expired | 만료 안내 | expiresAt 경과 | 대표가 재발송 (기존 링크 유지 + 만료 연장) |
accepted | 이미 수락된 초대 (성공 톤) | 수락 후 재클릭 | 로그인 버튼으로 진입 |
| (없음) | 일시 오류 | 조회 실패 (인프라) | 재시도 |
4. 수락 경로별 주의점
- 비밀번호: 신규 계정 생성 또는 기존 비밀번호 로그인.
invalid-credential시 구글 경로 안내 카피 노출 - Google 팝업:
login_hint로 초대 이메일 유도. 팝업 후 클라이언트 이메일 일치 선검사 + 서버의invite.email !== session.email가드가 최종 차단 — 제3자가 링크를 입수해도 다른 계정으로 수락 불가. 구글 전용 계정 (password provider 없음) 의 유일한 정상 경로 - 세션 원클릭: 초대받은 이메일로 이미 로그인된 브라우저면 비밀번호 생략. 이메일 대소문자 무시 비교
보안 불변식:
- 초대 대상 이메일은 검증 응답의 pending 분기에서만 반환 (수락 완료/무효 분기는 미노출 — #163 정합)
- 수락은 transaction 으로 race 차단 (두 탭 동시 클릭 시 1회만 성공)
5. 운영 가시성 — ops /health 구성원 초대 헬스 패널
ops 콘솔 /health 의 구성원 초대 헬스 (#2751) 가 전 tenant pending 초대를 스윕한다:
- stuck (3일+): 수신자가 메일을 못 받았거나 (스팸함·발송 실패) 막힌 신호 → 해당 사무소 대표에게 재발송 안내
- 만료: 재발송 1회로 복구 (만료 연장)
- 나이는 최초 초대 기준 — 재발송해도 리셋되지 않으므로 "오래 머문 초대" 가 숨지 않음
이메일 발송 자체의 실패는 발송 시점에 UI 가 분기 (emailSent=false → 초대 코드 수동 공유 안내) 하고, member_invited 활동 로그의 metadata.emailSent 로 사후 추적 가능 (#2748 이후 caseless 로그가 실제 기록됨).
6. 트러블슈팅
| 증상 | 원인 후보 | 확인 | 조치 |
|---|---|---|---|
| "유효하지 않은 초대 링크" | 2026-06-11 fix 이전 재발송으로 회전된 옛 링크 | ops 초대 헬스에서 해당 이메일 pending 확인 | 재발송 1회 (이후 모든 메일 유효) |
| "비밀번호가 올바르지 않습니다" 반복 | 구글 전용 계정 | — | "Google 계정으로 계속하기" 안내 |
| 버튼 클릭 시 영어 "Server Action … was not found" | 배포 skew (옛 빌드 탭) | ops /health 배포 현황에서 rollout 시각 확인 | 새로고침 후 재시도 (배포 & 릴리스 배포 후 체크리스트) |
| 초대 메일 미수신 | 발송 실패 또는 스팸함 | 발송 시 toast / activityLogs metadata.emailSent | 초대 코드 수동 공유 (온보딩 "초대 코드 참여" 탭) 또는 재발송 |
| 수락 후 대시보드 진입 실패 | Claims 미반영 토큰 | — | 페이지가 force-refresh + 쿠키 동기화를 수행 — 재현 시 로그아웃 후 재로그인 |
7. 데이터 모델 (참조)
tenants/{tid}/members/{memberId} (pending 초대 시):
| 필드 | 의미 |
|---|---|
status | pending → 수락 시 active |
inviteToken | 링크 토큰 (hex 64) — 재발송 시 보존 |
inviteCode | INV-XXXXXXX — 온보딩 수동 입력 경로 (cross-tenant 유일) |
expiresAt | 만료 (기본 7일, 재발송 시 연장) |
invitedAt / resentAt | 마지막 (재)발송 시각 / 재발송 흔적 |
createdAt | 최초 초대 — 초대 나이의 기준 |