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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user