Adds the invitee-side flow for self-serve signup Phase 2 (Task 36):
Backend
- Public GET /accounts/invites/{code}/lookup returns
{account_name, inviter_name, invited_email, role} for a valid invite,
404 invite_invalid_or_expired_or_revoked otherwise (collapses unknown /
expired / revoked / used into one anti-enumeration response). Mounted
in a new account_invite_lookup endpoints module on the public route
list, uses get_admin_db (BYPASSRLS) since the caller has no tenant.
- OAuthCallbackPayload gains optional account_invite_code + invited_email.
_sign_in_or_register honors them: a new OAuth user with a valid invite
joins the invited account (no personal account, no Pro trial), the
invite is marked used, and OAuth-profile-email vs invite-email mismatch
raises invite_email_mismatch (matching the email+password register
contract).
Frontend
- New public route /accept-invite -> AcceptInvitePage. Reads ?code=,
calls inviteApi.lookupAccountInvite, renders "Join {account} on
ResolutionFlow" with the invited email locked (rendered as a div, not
an input), three sign-in options (set password, Google, Microsoft),
and a clear "ask {inviter} to resend" + mailto: fallback for invalid
codes.
- OAuth state for invitees is base64url(JSON({csrf, accountInviteCode,
invitedEmail})). OAuthCallbackPage decodes both shapes, forwards the
invite fields to the backend, and surfaces invite_email_mismatch /
invite_invalid_or_expired_or_revoked errors with friendly text.
Successful invite-OAuth lands on /?welcome=teammate (suppresses the
welcome wizard for invitees per spec).
- UserCreate type + invite/auth API clients extended for the new fields.
Tests
- Backend: invite lookup happy path + four invalid-state collapse, OAuth
callback links invite when supplied + rejects on email mismatch.
- Frontend Vitest: AcceptInvitePage renders account name + locked email
+ accept buttons; resend message + mailto on invalid code.
All 43 backend auth/account/invite/email-verification tests green;
frontend Vitest 120/120 green; tsc -b clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
54 lines
1.6 KiB
TypeScript
54 lines
1.6 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { encodeOAuthState, decodeOAuthState } from './oauthState'
|
|
|
|
describe('oauthState', () => {
|
|
it('round-trips ASCII payloads', () => {
|
|
const encoded = encodeOAuthState({
|
|
csrf: 'abc123',
|
|
accountInviteCode: 'CODE12345',
|
|
invitedEmail: 'user@example.com',
|
|
})
|
|
expect(encoded).not.toContain('+')
|
|
expect(encoded).not.toContain('/')
|
|
expect(encoded).not.toContain('=')
|
|
expect(decodeOAuthState(encoded)).toEqual({
|
|
csrf: 'abc123',
|
|
accountInviteCode: 'CODE12345',
|
|
invitedEmail: 'user@example.com',
|
|
})
|
|
})
|
|
|
|
it('round-trips non-Latin-1 email characters without throwing', () => {
|
|
// Pre-fix: btoa(json) throws DOMException on code points > 255.
|
|
const payload = {
|
|
csrf: 'abc123',
|
|
accountInviteCode: 'CODE12345',
|
|
invitedEmail: 'user@münchen.de',
|
|
}
|
|
const encoded = encodeOAuthState(payload)
|
|
expect(decodeOAuthState(encoded)).toEqual(payload)
|
|
})
|
|
|
|
it('round-trips emoji and CJK characters', () => {
|
|
const payload = {
|
|
csrf: 'abc123',
|
|
accountInviteCode: 'CODE12345',
|
|
invitedEmail: '日本語+🎉@例え.jp',
|
|
}
|
|
expect(decodeOAuthState(encodeOAuthState(payload))).toEqual(payload)
|
|
})
|
|
|
|
it('returns null for legacy raw-hex CSRF state (not JSON)', () => {
|
|
expect(decodeOAuthState('a1b2c3d4e5f60718293a4b5c6d7e8f90')).toBeNull()
|
|
})
|
|
|
|
it('returns null for null / empty input', () => {
|
|
expect(decodeOAuthState(null)).toBeNull()
|
|
expect(decodeOAuthState('')).toBeNull()
|
|
})
|
|
|
|
it('returns null for malformed base64', () => {
|
|
expect(decodeOAuthState('!!!not-base64!!!')).toBeNull()
|
|
})
|
|
})
|