Replaces the auto-accept-on-signin behaviour with an explicit accept or
decline step. Every invitee (existing user or brand new) is now treated
the same way: an invitation lands in portfolio_invitations, the email
is a deep-link "Sign in to Ara", and the invitee accepts (or declines)
explicitly from a notifications panel hanging off their profile avatar.
Why: the previous flow silently added existing users to portfolios with
no way to refuse, and gave them no in-app confirmation that the invite
landed. Existing users complained they didn't know which account they
were signed in as either — both gaps are closed by the same panel.
Backend:
- POST /collaborators no longer creates portfolioUsers directly for
existing users; it writes a portfolio_invitations row in every case
except the trivial "already a member of THIS portfolio" path
(silent role update, no email)
- New /api/user/invitations endpoint: GET lists pending invitations
addressed to the current user across all portfolios, joined with
portfolio + inviter context; POST accepts or declines a single
invitation, scoped to session.email so users can't act on others'
- Accept reuses the existing planInvitationApplication helper for
the "already a member" idempotency check
- Decline is a silent delete (matches GitHub/Linear/Notion convention;
no email to inviter, no tombstone)
- signIn callback no longer auto-applies pending invitations — that
block is removed entirely along with its now-unused imports
- Email template subject + body unified, no longer suggests the user
is "added"; both modes say "invited" and direct them to the profile
menu
Frontend:
- ProfileDropDown rewritten as a notifications panel: shows the
signed-in email at the top (closing the "which account?" gap),
lists pending invitations with Accept/Decline buttons, displays a
red count badge on the avatar (max "9+"). Uses TanStack Query with
optimistic update on accept/decline and toast on outcome. Existing
Help + Sign Out menu items preserved.
- No useEffect — pending-count derived from query data, mutations
handle the rest
Vercel preview test plan:
- Invite a user already in another portfolio → red badge appears on
their next page load; Accept lands them in the portfolio
- Invite a new email → sign-up flow finishes; new account lands on
home with a badge waiting for Accept (no longer auto-accepted)
- Existing member of THIS portfolio re-invited → silent role update,
no email
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same email now contains a 6-digit code and a magic link, both backed by a
single verificationToken row. After submitting their email, the user
lands on /auth/verify-code with a single-input form (inputmode=numeric,
autocomplete=one-time-code, auto-submit on 6 digits or paste) and can
either type the code or use the link from the email. Either path
consumes the same row — single-use, replace-on-resend.
This is the structural fix for the silent-quarantine pattern observed
with Atkins and Sustainable Building UK: corporate gateways are happier
with short transactional content than long opaque token URLs, and a
code can't be broken by SafeLinks-style URL rewriting. The link path is
preserved so users whose email gets through unmangled keep one-click UX.
Security:
- Codes are 6-digit, crypto.randomInt-generated, stored as sha256
hashed against NEXTAUTH_SECRET on the same row as the link token
- 5-attempt lockout per code (attempts column); 6th attempt with the
correct code still fails
- Per-email send rate limit: 5/hour fixed window (authRateLimits
table); 6th send returns an error
- Code + link share a 10-minute window (maxAge dropped from 1h)
- Resending replaces any prior token rows for the identifier so only
the latest send is ever live
Implementation:
- verificationCode.ts holds generateCode + hashCode + the pure
evaluateCodeAttempt decision tree; 9 unit tests cover every branch
of the verification outcome (no-such-row, expired, locked-out, ok,
wrong-with-newAttempts, locked-out-still-rejects-correct-code)
- sendVerificationRequest now hashes the URL token the same way
/verify/[token]/page.tsx does, applies the rate limit + records the
code + replaces older rows in two transactions
- CredentialsProvider (id: "email-code") calls evaluateCodeAttempt
inside a transaction, handles all 5 outcomes, creates the user on
first successful code (parity with the magic-link callback path)
- oauthId backfill in the signIn callback is now guarded on
account.type === "oauth" so the credentials flow doesn't pollute
oauthProvider with "email-code"
- Migration is additive: code_hash nullable, attempts default 0; new
authRateLimits table is independent. In-flight tokens at deploy time
keep working via the link path.
Vercel preview deployment is the test surface; a Mailpit + Cypress E2E
loop is intentionally deferred per the lean-setup plan in docs/wip/
auth-email-code-fallback-plan.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the X-SES-CONFIGURATION-SET header on outbound auth emails so SES
bounce/delivery events flow through dev-ses-config to the dev-ses-events
SNS topic. Replaces the fire-and-forget "EMAIL MAGIC LINK SENT" log
(which fired before the SMTP transaction and swallowed downstream errors)
with structured EMAIL_MAGIC_LINK_SUCCESS/_FAILURE logs carrying the
Nodemailer messageId, so app-side sends are now correlatable with SES
events.
Motivated by the Atkins / Sustainable Building UK silent-quarantine
incidents where we couldn't tell whether SES had even tried to send.
Plan doc at docs/wip/auth-email-code-fallback-plan.md tracks the
broader email-code-fallback design that PR 2 will implement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>