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>
62 lines
2.3 KiB
TypeScript
62 lines
2.3 KiB
TypeScript
/**
|
|
* UTF-8-safe base64url encoding for OAuth `state` payloads.
|
|
*
|
|
* The /accept-invite flow round-trips an invite code + invited email through
|
|
* the OAuth provider's `state` parameter. Internationalized email addresses
|
|
* (e.g., `user@münchen.de`) contain code points > 255, which raw `btoa` /
|
|
* `atob` cannot represent — they throw `DOMException: The string to be
|
|
* encoded contains characters outside of the Latin1 range`.
|
|
*
|
|
* The classic `unescape(encodeURIComponent(...))` trick maps a UTF-16 string
|
|
* through its UTF-8 byte representation into a Latin-1 string that `btoa`
|
|
* accepts. The decode side reverses the transformation.
|
|
*/
|
|
|
|
export interface OAuthStatePayload {
|
|
csrf: string
|
|
accountInviteCode: string
|
|
invitedEmail: string
|
|
}
|
|
|
|
export interface DecodedOAuthState {
|
|
csrf: string
|
|
accountInviteCode?: string
|
|
invitedEmail?: string
|
|
}
|
|
|
|
/** Encode an OAuth state payload as URL-safe base64. UTF-8 safe. */
|
|
export function encodeOAuthState(payload: OAuthStatePayload): string {
|
|
const json = JSON.stringify(payload)
|
|
// unescape(encodeURIComponent(...)) converts UTF-16 -> UTF-8 -> Latin-1
|
|
// string so btoa can encode it without throwing on non-Latin-1 chars.
|
|
const b64 = btoa(unescape(encodeURIComponent(json)))
|
|
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
}
|
|
|
|
/** Best-effort base64url-decode. Returns null on legacy random-hex states or
|
|
* malformed input so the caller can fall back to a simple equality check. */
|
|
export function decodeOAuthState(raw: string | null): DecodedOAuthState | null {
|
|
if (!raw) return null
|
|
try {
|
|
const padded = raw.replace(/-/g, '+').replace(/_/g, '/')
|
|
const b64 = padded + '='.repeat((4 - (padded.length % 4)) % 4)
|
|
// decodeURIComponent(escape(...)) reverses the encode-side transform.
|
|
const json = decodeURIComponent(escape(atob(b64)))
|
|
const parsed = JSON.parse(json) as Partial<DecodedOAuthState>
|
|
if (typeof parsed?.csrf === 'string') {
|
|
return {
|
|
csrf: parsed.csrf,
|
|
accountInviteCode:
|
|
typeof parsed.accountInviteCode === 'string'
|
|
? parsed.accountInviteCode
|
|
: undefined,
|
|
invitedEmail:
|
|
typeof parsed.invitedEmail === 'string' ? parsed.invitedEmail : undefined,
|
|
}
|
|
}
|
|
return null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|