feat: add team invitation acceptance flow with email link routing

Implement end-to-end invitation acceptance: neutral entry route validates
token and routes to register (new users), login (existing users), or
auto-accepts (authenticated users). Handles 2FA token survival via
session, email case-insensitive matching, and dedicated error pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 15:16:45 +01:00
parent 8f39bd9b73
commit 88e5803061
13 changed files with 422 additions and 19 deletions

View File

@@ -6,3 +6,9 @@
- **Cross-page selection UX on declarations page** — Navigating between pages clears selections silently. Consider persisting selections across pages or warning the user.
- **Bulk notify count mismatch UX** — When some selected declarations are filtered out (no client), the success message count differs from the selection count with no explanation. Consider showing skipped count.
- **Nudge email template null guards**`nudge-notification.blade.php` renders `$clientName`, `$declarationType`, `$dueDate` without null fallbacks, producing blank labels.
## From: tech-spec-team-invitation-acceptance (2026-03-27)
- **Race condition on concurrent invitation acceptance** — Two users clicking the same invitation link simultaneously could both pass `isValid()` before either sets `accepted_at`. Fix with `SELECT FOR UPDATE` or atomic `UPDATE WHERE accepted_at IS NULL`.
- **Multiple pending invitations per email/workspace** — No unique constraint on `[workspace_id, email]` in `team_invitations`. Multiple tokens can exist for the same email+workspace. Second token in `CreateNewUser` path would hit unique constraint on `workspace_user` and throw.
- **Vue cross-link URLs should come from PHP props** — Register.vue and Login.vue construct invitation-aware login/register URLs via JS string interpolation instead of receiving them as props from the controller.

View File

@@ -0,0 +1,90 @@
---
title: 'Team Invitation Acceptance Flow'
type: 'feature'
created: '2026-03-27'
status: 'done'
baseline_commit: '8f39bd9'
context: ['_bmad-output/project-context.md']
---
# Team Invitation Acceptance Flow
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
## Intent
**Problem:** Clicking the team invitation email link lands on a standard registration page that ignores the invitation token. New users register without being attached to the inviting workspace; existing users have no acceptance path at all.
**Approach:** Create a neutral entry route `/team/invitation/{token}/accept` that validates the token and routes to the correct flow: auto-accept for authenticated users, redirect to login (with message) for existing accounts, or redirect to register (with pre-filled email) for new users. Modify `CreateNewUser` to process the invitation token after registration. Update both auth Vue pages to pass the token through.
## Boundaries & Constraints
**Always:**
- Validate invitation via `TeamInvitation::isValid()` at every entry point
- Attach user to workspace using role from invitation + default permissions from `config('permissions.defaults')`
- Set `current_workspace_id` in session after acceptance
- Mark invitation `accepted_at = now()` in a DB transaction
- Show a dedicated error page for invalid/expired tokens
**Ask First:**
- Any changes to the invitation email subject or body beyond the URL
**Never:**
- Modify Fortify's core auth pipeline or middleware stack
- Allow accepting an invitation for an email that doesn't match the authenticated user's email
- Allow a user who is already a member of the workspace to re-accept
## I/O & Edge-Case Matrix
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|----------|--------------|---------------------------|----------------|
| New user, valid token | Guest visits `/team/invitation/{token}/accept` | Redirect to `/register?invitation={token}` with email pre-filled and read-only | N/A |
| New user completes registration | POST register with `invitation` field | User created, attached to workspace, session set, redirect to `/dashboard` | N/A |
| Existing user, authenticated, valid token | Auth user visits `/team/invitation/{token}/accept` | Auto-accept: attach to workspace, set session, redirect to `/dashboard` with success flash | N/A |
| Existing user, not authenticated | Guest with existing account visits entry route | Redirect to `/login?invitation={token}` with status message | N/A |
| Post-login with invitation token | User logs in with `invitation` query param | Process invitation after login, redirect to dashboard | N/A |
| Expired token | Any visitor | Dedicated error page: "Cette invitation a expiré" | Inertia error page |
| Already-accepted token | Any visitor | Dedicated error page: "Cette invitation a deja ete utilisee" | Inertia error page |
| Email mismatch (auth user email != invitation email) | Auth user visits entry route | Error flash: "Cette invitation est destinee a une autre adresse email" + redirect back | Redirect with error |
| Already a workspace member | Auth user visits entry route | Flash "Vous etes deja membre de cet espace de travail" + redirect to dashboard | Redirect with info |
</frozen-after-approval>
## Code Map
- `app/Http/Controllers/TeamInvitationAcceptController.php` -- NEW: entry route controller, handles routing logic and acceptance for authenticated users
- `app/Actions/Fortify/CreateNewUser.php` -- ADD: invitation processing after user creation
- `app/Providers/FortifyServiceProvider.php` -- MODIFY: pass invitation data to register/login views, add custom registration response
- `resources/js/pages/auth/Register.vue` -- MODIFY: accept invitation props, pre-fill email, add hidden field
- `resources/js/pages/auth/Login.vue` -- MODIFY: accept invitation prop, pass token through form, show invitation message
- `routes/web.php` -- ADD: `/team/invitation/{token}/accept` route
- `app/Mail/TeamInvitationMail.php` -- MODIFY: change URL to new acceptance route
- `resources/js/pages/auth/InvitationError.vue` -- NEW: dedicated error page for invalid/expired tokens
- `app/Http/Responses/RegisterResponse.php` -- NEW: custom Fortify response to redirect to dashboard with workspace session after invitation registration
## Tasks & Acceptance
**Execution:**
- [ ] `app/Http/Controllers/TeamInvitationAcceptController.php` -- Create single-action controller with `__invoke()`: validate token, check auth state, check if email has existing account, route accordingly
- [ ] `routes/web.php` -- Add `GET /team/invitation/{token}/accept` route (no auth middleware, publicly accessible)
- [ ] `resources/js/pages/auth/InvitationError.vue` -- Create error page with `AuthLayout`, show message + link to home
- [ ] `app/Providers/FortifyServiceProvider.php` -- Update `registerView` to pass `invitation` query param and invitation email as props; update `loginView` to pass `invitation` query param
- [ ] `resources/js/pages/auth/Register.vue` -- Accept `invitation` and `invitationEmail` props, pre-fill email (read-only when invitation present), add hidden `invitation` input
- [ ] `resources/js/pages/auth/Login.vue` -- Accept `invitation` prop, add hidden `invitation` input, show invitation status message
- [ ] `app/Actions/Fortify/CreateNewUser.php` -- After user creation: if `invitation` token present, validate, attach workspace, mark accepted, set session
- [ ] `app/Http/Responses/RegisterResponse.php` -- Create custom `RegisterResponse` implementing `Fortify\Contracts\RegisterResponse`: if session has `invitation_workspace_id`, redirect to `/dashboard`; bind in `FortifyServiceProvider`
- [ ] `app/Mail/TeamInvitationMail.php` -- Change `registerUrl` to `route('team.invitation.accept', $this->invitation->token)`
**Acceptance Criteria:**
- Given a new user clicks the invitation email link, when they complete registration, then they are attached to the workspace with the correct role and land on the workspace dashboard
- Given an authenticated user clicks the invitation link, when the token is valid and email matches, then they are auto-attached and redirected to the dashboard with the new workspace active
- Given a non-authenticated user with an existing account clicks the link, when they log in, then the invitation is processed and they land on the workspace dashboard
- Given an expired or used token, when any user visits the link, then they see a clear French-language error page
- Given an email mismatch between authenticated user and invitation, when they visit the link, then they see an error and are not attached
## Verification
**Commands:**
- `composer test` -- expected: all existing tests pass (no regressions)
- `npm run lint` -- expected: no lint errors
- `composer lint` -- expected: no PHP formatting errors