feat: self-serve signup Phase 2 (frontend cutover) #162

Merged
chihlasm merged 29 commits from feat/self-serve-signup-phase-2 into main 2026-05-07 18:42:22 +00:00
14 changed files with 1201 additions and 40 deletions
Showing only changes of commit 39e85c9770 - Show all commits

View File

@@ -0,0 +1,54 @@
"""Public endpoint for resolving an account invite code into display info.
Mounted as a public route (no tenant context, no auth) — used by the
/accept-invite page on the frontend so an invitee can see what account they
are about to join before they sign up. Uses the BYPASSRLS admin session
factory because account_invites is account-scoped under Phase 4 RLS but the
caller has no tenant identity yet.
"""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.core.admin_database import get_admin_db
from app.models.account_invite import AccountInvite
from app.schemas.oauth import InviteLookupResponse
router = APIRouter(prefix="/accounts", tags=["account-invite-lookup"])
@router.get("/invites/{code}/lookup", response_model=InviteLookupResponse)
async def lookup_invite(
code: str,
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> InviteLookupResponse:
"""Return minimal display data for a valid (unused, unexpired, not revoked)
invite. Returns 404 with `invite_invalid_or_expired_or_revoked` for any
invalid state — the AcceptInvitePage shows a single "ask the inviter to
resend" message regardless of which condition failed (anti-enumeration)."""
result = await db.execute(
select(AccountInvite)
.where(AccountInvite.code == code)
.options(
joinedload(AccountInvite.account),
joinedload(AccountInvite.invited_by),
)
)
invite = result.scalar_one_or_none()
if invite is None or not invite.is_valid:
raise HTTPException(
status_code=404,
detail={"error": "invite_invalid_or_expired_or_revoked"},
)
return InviteLookupResponse(
account_name=invite.account.name,
inviter_name=invite.invited_by.name,
invited_email=invite.email,
role=invite.role,
)

View File

@@ -11,6 +11,7 @@ from app.core.admin_database import get_admin_db
from app.core.config import settings
from app.core.security import create_access_token, create_refresh_token
from app.models.account import Account
from app.models.account_invite import AccountInvite
from app.models.oauth_identity import OAuthIdentity
from app.models.user import User
from app.schemas.oauth import OAuthCallbackPayload, OAuthCallbackResponse
@@ -31,9 +32,21 @@ def _generate_display_code(length: int = 8) -> str:
async def _sign_in_or_register(
db: AsyncSession, provider: str, profile: OAuthProfile
db: AsyncSession,
provider: str,
profile: OAuthProfile,
*,
account_invite_code: str | None = None,
invited_email: str | None = None,
) -> tuple[User, bool]:
"""Returns (user, is_new_user). Idempotent on (provider, provider_subject)."""
"""Returns (user, is_new_user). Idempotent on (provider, provider_subject).
When ``account_invite_code`` is supplied (from the /accept-invite flow),
a brand-new user is created inside the invited account instead of getting
a personal account + Pro trial. Mismatch between the OAuth profile email
and ``invited_email`` raises ``invite_email_mismatch`` per the spec
contract that mirrors the email+password register path.
"""
identity = (
await db.execute(
select(OAuthIdentity).where(
@@ -53,28 +66,96 @@ async def _sign_in_or_register(
await db.execute(select(User).where(User.email == profile.email))
).scalar_one_or_none()
is_new_user = user is None
# If the user arrived via an invite link but already has a ResolutionFlow
# account (e.g., previously signed up with email+password), silently
# linking the OAuth identity to that existing account would bypass the
# invite — they'd stay in their personal account and the invite would
# never be consumed. Fail loud instead so they can sign in and accept the
# invite from the dashboard. The "invited user wants to transfer accounts"
# case is a v2 concern.
if account_invite_code and not is_new_user:
raise HTTPException(
status_code=400,
detail={
"error": "email_already_registered_use_login",
"message": (
"An account already exists for this email. Please sign in "
"instead, then accept the invite from your dashboard."
),
},
)
invite_record: AccountInvite | None = None
if is_new_user and account_invite_code:
# SELECT FOR UPDATE so two concurrent OAuth callbacks can't both
# consume the same invite code.
invite_record = (
await db.execute(
select(AccountInvite)
.where(AccountInvite.code == account_invite_code)
.with_for_update()
)
).scalar_one_or_none()
if invite_record is None or not invite_record.is_valid:
raise HTTPException(
status_code=400,
detail={"error": "invite_invalid_or_expired_or_revoked"},
)
# Verify the OAuth profile email matches what was invited. We compare
# against the invite row directly (source of truth), but also accept
# the client-supplied invited_email as a defensive equality check.
if invite_record.email.lower() != profile.email.lower():
raise HTTPException(
status_code=400,
detail={"error": "invite_email_mismatch"},
)
if invited_email and invited_email.lower() != invite_record.email.lower():
raise HTTPException(
status_code=400,
detail={"error": "invite_email_mismatch"},
)
if is_new_user:
account = Account(
name=f"{profile.name}'s Account",
display_code=_generate_display_code(),
)
db.add(account)
await db.flush()
user = User(
email=profile.email,
name=profile.name,
password_hash=None,
account_id=account.id,
account_role="owner",
role="engineer",
email_verified_at=datetime.now(timezone.utc),
)
db.add(user)
await db.flush()
account.owner_id = user.id
await db.flush()
# start_trial commits internally; flushed account/user above.
await BillingService.start_trial(db, account.id)
if invite_record is not None:
# Join the invited account directly — no personal account, no
# trial creation.
user = User(
email=profile.email,
name=profile.name,
password_hash=None,
account_id=invite_record.account_id,
account_role=invite_record.role,
role="engineer",
email_verified_at=datetime.now(timezone.utc),
)
db.add(user)
await db.flush()
invite_record.accepted_by_id = user.id
invite_record.used_at = datetime.now(timezone.utc)
await db.flush()
else:
account = Account(
name=f"{profile.name}'s Account",
display_code=_generate_display_code(),
)
db.add(account)
await db.flush()
user = User(
email=profile.email,
name=profile.name,
password_hash=None,
account_id=account.id,
account_role="owner",
role="engineer",
email_verified_at=datetime.now(timezone.utc),
)
db.add(user)
await db.flush()
account.owner_id = user.id
await db.flush()
# start_trial commits internally; flushed account/user above.
await BillingService.start_trial(db, account.id)
db.add(
OAuthIdentity(
@@ -98,7 +179,13 @@ async def google_callback(
raise HTTPException(status_code=503, detail="Google sign-in not configured")
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/google/callback"
profile = await google_exchange_code(payload.code, redirect_uri)
user, is_new = await _sign_in_or_register(db, "google", profile)
user, is_new = await _sign_in_or_register(
db,
"google",
profile,
account_invite_code=payload.account_invite_code,
invited_email=payload.invited_email,
)
return OAuthCallbackResponse(
access_token=create_access_token({"sub": str(user.id)}),
refresh_token=create_refresh_token({"sub": str(user.id)}),
@@ -115,7 +202,13 @@ async def microsoft_callback(
raise HTTPException(status_code=503, detail="Microsoft sign-in not configured")
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/microsoft/callback"
profile = await microsoft_exchange_code(payload.code, redirect_uri)
user, is_new = await _sign_in_or_register(db, "microsoft", profile)
user, is_new = await _sign_in_or_register(
db,
"microsoft",
profile,
account_invite_code=payload.account_invite_code,
invited_email=payload.invited_email,
)
return OAuthCallbackResponse(
access_token=create_access_token({"sub": str(user.id)}),
refresh_token=create_refresh_token({"sub": str(user.id)}),

View File

@@ -70,6 +70,7 @@ from app.api.endpoints import (
uploads,
webhooks,
accounts,
account_invite_lookup,
)
api_router = APIRouter()
@@ -95,6 +96,7 @@ api_router.include_router(webhooks.router) # Stripe webhook receiver
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited)
api_router.include_router(survey.router) # Public survey flow (no auth, rate-limited)
api_router.include_router(config_endpoints.router) # Public runtime feature flags
api_router.include_router(account_invite_lookup.router) # Public invite-code lookup for /accept-invite
# ---------------------------------------------------------------------------
# Admin endpoints — super_admin only

View File

@@ -4,6 +4,11 @@ from pydantic import BaseModel
class OAuthCallbackPayload(BaseModel):
code: str
state: str | None = None
# When the OAuth flow originated from /accept-invite, the frontend round-trips
# the invite code + invited email so the backend can link the new user to the
# invited account instead of creating a personal one.
account_invite_code: str | None = None
invited_email: str | None = None
class OAuthCallbackResponse(BaseModel):
@@ -11,3 +16,17 @@ class OAuthCallbackResponse(BaseModel):
refresh_token: str
token_type: str = "bearer"
is_new_user: bool
class InviteLookupResponse(BaseModel):
"""Public response surface for GET /accounts/invites/{code}/lookup.
Returns the minimum context needed for the AcceptInvitePage:
account name (so we can title the card), inviter name (for the resend
fallback message), invited email (locked into the form), and role.
"""
account_name: str
inviter_name: str
invited_email: str
role: str

View File

@@ -0,0 +1,290 @@
"""Tests for the public GET /accounts/invites/{code}/lookup endpoint
(consumed by the /accept-invite page on the frontend)."""
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy import select
from app.models.account_invite import AccountInvite
@pytest.mark.asyncio
async def test_invite_lookup_returns_account_info_for_valid_code(
client, test_db, test_user, auth_headers
):
"""A freshly-created, unused, unexpired invite resolves to the inviter's
account name + the inviter's display name + the invited email + role."""
with patch(
"app.core.email.EmailService.send_account_invite_email",
new_callable=AsyncMock,
return_value=True,
):
create_resp = await client.post(
"/api/v1/accounts/me/invites",
json={"email": "lookup@example.com", "role": "engineer"},
headers=auth_headers,
)
assert create_resp.status_code == 201, create_resp.json()
code = create_resp.json()["code"]
response = await client.get(f"/api/v1/accounts/invites/{code}/lookup")
assert response.status_code == 200, response.json()
body = response.json()
assert body["invited_email"] == "lookup@example.com"
assert body["role"] == "engineer"
assert body["inviter_name"] == test_user["user_data"]["name"]
# account_name is whatever the test_user fixture seeded for the account.
assert isinstance(body["account_name"], str) and body["account_name"]
@pytest.mark.asyncio
async def test_invite_lookup_returns_404_for_invalid_or_expired_code(
client, test_db, test_user
):
"""Three failure modes (unknown code, expired, revoked, used) all collapse
to the same 404 + invite_invalid_or_expired_or_revoked error code."""
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
account_id = uuid.UUID(test_user["user_data"]["account_id"])
# 1) Unknown code
unknown = await client.get("/api/v1/accounts/invites/DOESNOTEXIST/lookup")
assert unknown.status_code == 404
assert unknown.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked"
# 2) Expired
expired_invite = AccountInvite(
account_id=account_id,
invited_by_id=invited_by_id,
email="expired@example.com",
code="EXPIREDLOOKUP01",
role="engineer",
expires_at=datetime.now(timezone.utc) - timedelta(days=1),
)
test_db.add(expired_invite)
await test_db.commit()
expired = await client.get("/api/v1/accounts/invites/EXPIREDLOOKUP01/lookup")
assert expired.status_code == 404
assert expired.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked"
# 3) Revoked
revoked_invite = AccountInvite(
account_id=account_id,
invited_by_id=invited_by_id,
email="revoked@example.com",
code="REVOKEDLOOKUP01",
role="engineer",
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
revoked_at=datetime.now(timezone.utc),
)
test_db.add(revoked_invite)
await test_db.commit()
revoked = await client.get("/api/v1/accounts/invites/REVOKEDLOOKUP01/lookup")
assert revoked.status_code == 404
assert revoked.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked"
# 4) Already used
used_invite = AccountInvite(
account_id=account_id,
invited_by_id=invited_by_id,
email="used@example.com",
code="USEDLOOKUP01",
role="engineer",
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
accepted_by_id=invited_by_id,
used_at=datetime.now(timezone.utc),
)
test_db.add(used_invite)
await test_db.commit()
used = await client.get("/api/v1/accounts/invites/USEDLOOKUP01/lookup")
assert used.status_code == 404
assert used.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked"
# Sanity: rows survived (no destructive side effects).
persisted = (
await test_db.execute(
select(AccountInvite).where(
AccountInvite.code.in_(
["EXPIREDLOOKUP01", "REVOKEDLOOKUP01", "USEDLOOKUP01"]
)
)
)
).scalars().all()
assert len(persisted) == 3
@pytest.mark.asyncio
async def test_oauth_callback_links_invite_when_account_invite_code_supplied(
client, test_db, test_user, auth_headers, monkeypatch
):
"""Brand-new OAuth user with account_invite_code joins the invited account
instead of getting a personal one. Invite is marked used."""
from app.core.config import settings
from app.models.user import User
from app.services.oauth_providers import OAuthProfile
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
with patch(
"app.core.email.EmailService.send_account_invite_email",
new_callable=AsyncMock,
return_value=True,
):
create_resp = await client.post(
"/api/v1/accounts/me/invites",
json={"email": "oauth-invite@example.com", "role": "engineer"},
headers=auth_headers,
)
code = create_resp.json()["code"]
inviter_account_id = uuid.UUID(test_user["user_data"]["account_id"])
profile = OAuthProfile(
provider_subject="google_invite_subject_1",
email="oauth-invite@example.com",
name="OAuth Invitee",
)
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
response = await client.post(
"/api/v1/auth/google/callback",
json={
"code": "auth_code_xyz",
"account_invite_code": code,
"invited_email": "oauth-invite@example.com",
},
)
assert response.status_code == 200, response.json()
assert response.json()["is_new_user"] is True
user = (
await test_db.execute(
select(User).where(User.email == "oauth-invite@example.com")
)
).scalar_one()
assert user.account_id == inviter_account_id
assert user.account_role == "engineer"
invite = (
await test_db.execute(
select(AccountInvite).where(AccountInvite.code == code)
)
).scalar_one()
assert invite.used_at is not None
assert invite.accepted_by_id == user.id
@pytest.mark.asyncio
async def test_oauth_callback_existing_email_with_invite_returns_400(
client, test_db, test_user, auth_headers, monkeypatch
):
"""If a user already exists with the invited email (e.g., previously
registered via password), arriving via /accept-invite OAuth must NOT
silently link the OAuth identity to their existing account and skip the
invite. Surface email_already_registered_use_login so the user signs in
and accepts the invite from the dashboard instead."""
from app.core.config import settings
from app.services.oauth_providers import OAuthProfile
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
# 1) Pre-existing user with a password (separate from the inviter).
existing_email = "already-here@example.com"
register_resp = await client.post(
"/api/v1/auth/register",
json={
"email": existing_email,
"password": "PreviousPassword123!",
"name": "Already Here",
},
)
assert register_resp.status_code in (200, 201), register_resp.json()
# 2) Inviter creates an invite for that exact email.
with patch(
"app.core.email.EmailService.send_account_invite_email",
new_callable=AsyncMock,
return_value=True,
):
create_resp = await client.post(
"/api/v1/accounts/me/invites",
json={"email": existing_email, "role": "engineer"},
headers=auth_headers,
)
assert create_resp.status_code == 201, create_resp.json()
code = create_resp.json()["code"]
# 3) The existing user does Google OAuth and the callback receives the
# invite code. Backend must reject — not link silently.
profile = OAuthProfile(
provider_subject="google_existing_subject_1",
email=existing_email,
name="Already Here",
)
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
response = await client.post(
"/api/v1/auth/google/callback",
json={
"code": "auth_code_xyz",
"account_invite_code": code,
"invited_email": existing_email,
},
)
assert response.status_code == 400, response.json()
assert (
response.json()["detail"]["error"] == "email_already_registered_use_login"
)
# 4) Sanity: the invite was NOT consumed.
invite = (
await test_db.execute(
select(AccountInvite).where(AccountInvite.code == code)
)
).scalar_one()
assert invite.used_at is None
assert invite.accepted_by_id is None
@pytest.mark.asyncio
async def test_oauth_callback_invite_email_mismatch_returns_400(
client, test_db, test_user, auth_headers, monkeypatch
):
"""If the OAuth profile's email differs from the invite's email, the
backend rejects the link with invite_email_mismatch (mirrors register)."""
from app.core.config import settings
from app.services.oauth_providers import OAuthProfile
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
with patch(
"app.core.email.EmailService.send_account_invite_email",
new_callable=AsyncMock,
return_value=True,
):
create_resp = await client.post(
"/api/v1/accounts/me/invites",
json={"email": "expected@example.com", "role": "engineer"},
headers=auth_headers,
)
code = create_resp.json()["code"]
profile = OAuthProfile(
provider_subject="google_invite_subject_2",
email="different@example.com",
name="Wrong Email",
)
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
response = await client.post(
"/api/v1/auth/google/callback",
json={
"code": "auth_code_xyz",
"account_invite_code": code,
"invited_email": "expected@example.com",
},
)
assert response.status_code == 400, response.json()
assert response.json()["detail"]["error"] == "invite_email_mismatch"

View File

@@ -79,18 +79,32 @@ export const authApi = {
await apiClient.post('/auth/email/verify', { token })
},
async googleCallback(code: string): Promise<OAuthCallbackResponse> {
async googleCallback(
code: string,
options?: { accountInviteCode?: string; invitedEmail?: string },
): Promise<OAuthCallbackResponse> {
const response = await apiClient.post<OAuthCallbackResponse>(
'/auth/google/callback',
{ code },
{
code,
account_invite_code: options?.accountInviteCode,
invited_email: options?.invitedEmail,
},
)
return response.data
},
async microsoftCallback(code: string): Promise<OAuthCallbackResponse> {
async microsoftCallback(
code: string,
options?: { accountInviteCode?: string; invitedEmail?: string },
): Promise<OAuthCallbackResponse> {
const response = await apiClient.post<OAuthCallbackResponse>(
'/auth/microsoft/callback',
{ code },
{
code,
account_invite_code: options?.accountInviteCode,
invited_email: options?.invitedEmail,
},
)
return response.data
},

View File

@@ -1,11 +1,30 @@
import apiClient from './client'
import type { InviteCodeValidation } from '@/types'
/** Public response from GET /accounts/invites/{code}/lookup. */
export interface AccountInviteLookup {
account_name: string
inviter_name: string
invited_email: string
role: string
}
export const inviteApi = {
async validateCode(code: string): Promise<InviteCodeValidation> {
const response = await apiClient.get<InviteCodeValidation>(`/invites/validate/${code}`)
return response.data
},
/** Public lookup of an account invite code — no auth required. Used by
* /accept-invite to render the "Join {account} on ResolutionFlow" card.
* Resolves to 404 with `invite_invalid_or_expired_or_revoked` for any
* invalid state. */
async lookupAccountInvite(code: string): Promise<AccountInviteLookup> {
const response = await apiClient.get<AccountInviteLookup>(
`/accounts/invites/${encodeURIComponent(code)}/lookup`,
)
return response.data
},
}
export default inviteApi

View File

@@ -0,0 +1,53 @@
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()
})
})

View File

@@ -0,0 +1,61 @@
/**
* 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
}
}

View File

@@ -0,0 +1,371 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { inviteApi, type AccountInviteLookup } from '@/api/invite'
import { useAuthStore } from '@/store/authStore'
import { useAppConfig } from '@/hooks/useAppConfig'
import { BrandLogo } from '@/components/common/BrandLogo'
import { PasswordInput } from '@/components/common/PasswordInput'
import { PageMeta } from '@/components/common/PageMeta'
import { buildOAuthAuthorizeUrl } from './RegisterPage'
import { cn } from '@/lib/utils'
import { encodeOAuthState } from '@/lib/oauthState'
function randomCsrf(): string {
const buf = new Uint8Array(16)
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(buf)
} else {
for (let i = 0; i < buf.length; i++) buf[i] = Math.floor(Math.random() * 256)
}
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('')
}
type LookupState =
| { status: 'loading' }
| { status: 'ok'; data: AccountInviteLookup }
| { status: 'invalid' }
| { status: 'missing-code' }
export function AcceptInvitePage() {
const navigate = useNavigate()
const location = useLocation()
const { register, isLoading, error, clearError } = useAuthStore()
const appConfig = useAppConfig()
const code = useMemo(() => {
const search = new URLSearchParams(location.search)
return (search.get('code') || '').trim()
}, [location.search])
const [lookup, setLookup] = useState<LookupState>(
code ? { status: 'loading' } : { status: 'missing-code' },
)
const [name, setName] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [localError, setLocalError] = useState('')
useEffect(() => {
if (!code) {
setLookup({ status: 'missing-code' })
return
}
let cancelled = false
setLookup({ status: 'loading' })
void (async () => {
try {
const data = await inviteApi.lookupAccountInvite(code)
if (cancelled) return
setLookup({ status: 'ok', data })
} catch {
if (cancelled) return
// Any error — 404, 410, network — collapses to the same "ask the
// inviter to resend" UX. Anti-enumeration is enforced server-side.
setLookup({ status: 'invalid' })
}
})()
return () => {
cancelled = true
}
}, [code])
const googleAvailable = appConfig.oauth_providers.includes('google')
const microsoftAvailable = appConfig.oauth_providers.includes('microsoft')
const handleOAuth = (provider: 'google' | 'microsoft') => {
if (lookup.status !== 'ok') return
const csrf = randomCsrf()
try {
sessionStorage.setItem('rf-oauth-state', csrf)
} catch {
// ignore — non-fatal
}
const stateValue = encodeOAuthState({
csrf,
accountInviteCode: code,
invitedEmail: lookup.data.invited_email,
})
const url = buildOAuthAuthorizeUrl(provider, stateValue)
window.location.href = url
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLocalError('')
clearError()
if (lookup.status !== 'ok') return
if (!name || !password) {
setLocalError('Please fill in all fields')
return
}
if (password !== confirmPassword) {
setLocalError('Passwords do not match')
return
}
if (password.length < 10) {
setLocalError('Password must be at least 10 characters')
return
}
try {
await register({
email: lookup.data.invited_email,
password,
name,
account_invite_code: code,
})
// Invitees skip the welcome wizard — they're joining an existing shop.
// The `?welcome=teammate` marker is decoded by the dashboard in Task 41
// to surface the "Welcome to {account_name}" toast and pre-checked
// checklist items.
navigate('/?welcome=teammate', { replace: true })
} catch {
// Error is set in the store
}
}
return (
<>
<PageMeta
title="Join your team on ResolutionFlow"
description="Accept an invite to join an existing ResolutionFlow account"
/>
<div className="flex min-h-screen items-center justify-center bg-black px-4">
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
<div className="relative w-full max-w-md space-y-6">
<div className="text-center">
<div className="mb-4 flex justify-center sm:mb-6">
<BrandLogo size="lg" />
</div>
<h1 className="text-3xl font-bold font-heading text-foreground tracking-tight">
ResolutionFlow
</h1>
</div>
{lookup.status === 'loading' && (
<div className="bg-card border border-border rounded-xl p-6 text-center">
<p className="text-sm text-muted-foreground">Loading invite</p>
</div>
)}
{(lookup.status === 'invalid' || lookup.status === 'missing-code') && (
<div className="bg-card border border-border rounded-xl p-6 space-y-3">
<h2 className="text-lg font-semibold text-foreground">
This invite is no longer valid
</h2>
<p className="text-sm text-muted-foreground">
{lookup.status === 'missing-code'
? 'The invite link is missing its code.'
: 'This invite has expired, been used, or been revoked.'}{' '}
Ask the person who invited you to resend it.
</p>
<a
href="mailto:?subject=Please%20resend%20my%20ResolutionFlow%20invite&body=Hi%2C%20could%20you%20resend%20my%20ResolutionFlow%20invite%3F%20The%20link%20I%20got%20is%20no%20longer%20valid.%20Thanks!"
className={cn(
'inline-block rounded-xl px-4 py-2 text-sm font-semibold btn-press',
'bg-primary text-white hover:brightness-110',
)}
>
Email your inviter
</a>
<p className="text-xs text-muted-foreground">
Already have an account?{' '}
<Link to="/login" className="font-medium text-foreground hover:underline">
Sign in
</Link>
</p>
</div>
)}
{lookup.status === 'ok' && (
<>
<div className="text-center">
<p className="text-base font-medium text-foreground">
Join <span className="font-semibold">{lookup.data.account_name}</span> on
ResolutionFlow
</p>
<p className="mt-1 text-sm text-muted-foreground">
{lookup.data.inviter_name} invited you as {lookup.data.role}.
</p>
</div>
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
{(error || localError) && (
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
{localError || error}
</div>
)}
<div>
<p className="block text-sm font-medium text-foreground">
Joining as
</p>
<p
className={cn(
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
'text-foreground',
)}
data-testid="invited-email"
>
{lookup.data.invited_email}
</p>
<p className="mt-1 text-xs text-muted-foreground">
The invite is locked to this email address.
</p>
</div>
{(googleAvailable || microsoftAvailable) && (
<div className="space-y-3">
{googleAvailable && (
<button
type="button"
onClick={() => handleOAuth('google')}
data-testid="oauth-google"
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-card border border-border text-foreground hover:bg-foreground/5',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'transition-all',
)}
>
Continue with Google
</button>
)}
{microsoftAvailable && (
<button
type="button"
onClick={() => handleOAuth('microsoft')}
data-testid="oauth-microsoft"
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-card border border-border text-foreground hover:bg-foreground/5',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'transition-all',
)}
>
Continue with Microsoft
</button>
)}
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase tracking-wider">
<span className="bg-card px-2 text-muted-foreground">
or set a password
</span>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-foreground"
>
Full name
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
className={cn(
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
)}
placeholder="Jane Doe"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-foreground"
>
Password
</label>
<PasswordInput
id="password"
name="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className={cn(
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
)}
placeholder="••••••••••"
/>
<p className="mt-1 text-xs text-muted-foreground">
Must be at least 10 characters
</p>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-foreground"
>
Confirm password
</label>
<PasswordInput
id="confirmPassword"
name="confirmPassword"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={cn(
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
)}
placeholder="••••••••••"
/>
</div>
<button
type="submit"
data-testid="accept-submit"
disabled={isLoading}
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-primary text-white hover:brightness-110',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-black',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-all',
)}
>
{isLoading ? 'Joining…' : `Join ${lookup.data.account_name}`}
</button>
</form>
</div>
</>
)}
<p className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<Link to="/login" className="font-medium text-foreground hover:underline">
Sign in
</Link>
</p>
</div>
</div>
</>
)
}
export default AcceptInvitePage

View File

@@ -4,6 +4,7 @@ import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import { BrandLogo } from '@/components/common/BrandLogo'
import { PageMeta } from '@/components/common/PageMeta'
import { decodeOAuthState } from '@/lib/oauthState'
type Provider = 'google' | 'microsoft'
@@ -13,8 +14,16 @@ type Provider = 'google' | 'microsoft'
* public routes (NOT inside ProtectedRoute).
*
* Reads `?code=...` from the URL, POSTs it to the backend, stores the
* returned tokens, hydrates the auth store via fetchUser(), and redirects
* to /welcome (new user) or / (returning user).
* returned tokens, hydrates the auth store via fetchUser(), and redirects.
*
* Two state forms are supported:
* - Legacy: `state` is a raw random hex string. CSRF check against
* sessionStorage('rf-oauth-state').
* - /accept-invite: `state` is base64url(JSON({csrf, accountInviteCode,
* invitedEmail})). The CSRF value is compared against
* sessionStorage('rf-oauth-state'); the invite fields are forwarded to
* the backend so the new user joins the invited account instead of
* getting a personal one.
*/
export function OAuthCallbackPage() {
const navigate = useNavigate()
@@ -35,9 +44,10 @@ export function OAuthCallbackPage() {
const oauthError = search.get('error')
const returnedState = search.get('state')
// CSRF: validate state round-trip against the value RegisterPage stashed
// in sessionStorage before redirecting to the provider. Always clear the
// stored value so a stale entry can't be re-used by a later attempt.
// CSRF: validate state round-trip against the value RegisterPage /
// AcceptInvitePage stashed in sessionStorage before redirecting to the
// provider. Always clear the stored value so a stale entry can't be
// re-used by a later attempt.
let storedState: string | null = null
try {
storedState = sessionStorage.getItem('rf-oauth-state')
@@ -51,7 +61,17 @@ export function OAuthCallbackPage() {
setError(`OAuth error: ${oauthError}`)
return
}
if (!storedState || returnedState !== storedState) {
if (!storedState || !returnedState) {
setError('Invalid OAuth state — possible CSRF. Please try again.')
return
}
// The decoded form encodes the original CSRF value; compare that.
const decoded = decodeOAuthState(returnedState)
const matchesCsrf = decoded
? decoded.csrf === storedState
: returnedState === storedState
if (!matchesCsrf) {
setError('Invalid OAuth state — possible CSRF. Please try again.')
return
}
@@ -63,10 +83,16 @@ export function OAuthCallbackPage() {
let cancelled = false
void (async () => {
try {
const inviteOptions = decoded
? {
accountInviteCode: decoded.accountInviteCode,
invitedEmail: decoded.invitedEmail,
}
: undefined
const result =
provider === 'microsoft'
? await authApi.microsoftCallback(code)
: await authApi.googleCallback(code)
? await authApi.microsoftCallback(code, inviteOptions)
: await authApi.googleCallback(code, inviteOptions)
if (cancelled) return
// Persist tokens for apiClient interceptor + zustand store.
@@ -81,7 +107,15 @@ export function OAuthCallbackPage() {
await fetchUser()
if (cancelled) return
const dest = result.is_new_user ? '/welcome' : '/'
// Invitee path lands on the dashboard with the teammate-welcome
// marker; new self-serve owners go to the welcome wizard; returning
// users to /.
let dest = '/'
if (decoded?.accountInviteCode) {
dest = '/?welcome=teammate'
} else if (result.is_new_user) {
dest = '/welcome'
}
navigate(dest, { replace: true })
} catch (err: unknown) {
if (cancelled) return
@@ -89,8 +123,28 @@ export function OAuthCallbackPage() {
response?: { data?: { detail?: unknown } }
}
const detail = axiosErr.response?.data?.detail
const msg =
(typeof detail === 'string' ? detail : null) ||
// Backend returns { error: "invite_email_mismatch" } etc.
let msg: string | null = null
if (typeof detail === 'string') {
msg = detail
} else if (
detail &&
typeof detail === 'object' &&
'error' in (detail as Record<string, unknown>)
) {
const code = (detail as { error: string }).error
if (code === 'invite_email_mismatch') {
msg =
'The email on your provider account does not match the invited email. ' +
'Sign in with the matching account, or ask your inviter to resend.'
} else if (code === 'invite_invalid_or_expired_or_revoked') {
msg = 'This invite is no longer valid. Ask your inviter to resend.'
} else {
msg = code
}
}
msg =
msg ||
(err instanceof Error ? err.message : 'Sign-in failed')
setError(msg)
}

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import { AcceptInvitePage } from '../AcceptInvitePage'
import { inviteApi } from '@/api/invite'
import {
__resetAppConfigCache,
__setAppConfigCache,
} from '@/hooks/useAppConfig'
vi.mock('@/api/invite', () => ({
inviteApi: {
lookupAccountInvite: vi.fn(),
validateCode: vi.fn(),
},
}))
vi.mock('@/store/authStore', () => ({
useAuthStore: () => ({
register: vi.fn().mockResolvedValue(undefined),
isLoading: false,
error: null,
clearError: vi.fn(),
}),
}))
function renderPage(initialPath: string) {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={[initialPath]}>
<AcceptInvitePage />
</MemoryRouter>
</HelmetProvider>,
)
}
describe('AcceptInvitePage', () => {
beforeEach(() => {
__resetAppConfigCache()
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: ['google', 'microsoft'],
})
vi.clearAllMocks()
})
it('shows account name + locked email + accept buttons for a valid code', async () => {
vi.mocked(inviteApi.lookupAccountInvite).mockResolvedValue({
account_name: 'Acme MSP',
inviter_name: 'Alice Owner',
invited_email: 'bob@acme.example',
role: 'engineer',
})
renderPage('/accept-invite?code=VALIDINVITECODE0011223344556677')
// Inviter context (also confirms the lookup completed and rendered)
await waitFor(() => {
expect(
screen.getByText(/Alice Owner invited you as engineer/),
).toBeInTheDocument()
})
// Account name surfaces in the heading line.
expect(
screen.getByText((_content, node) => {
return (
node?.tagName.toLowerCase() === 'span' &&
/Acme MSP/.test(node.textContent || '')
)
}),
).toBeInTheDocument()
// Locked email — not an editable input
const emailDisplay = screen.getByTestId('invited-email')
expect(emailDisplay.tagName.toLowerCase()).not.toBe('input')
expect(emailDisplay).toHaveTextContent('bob@acme.example')
expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument()
// OAuth buttons + password submit all rendered
expect(screen.getByTestId('oauth-google')).toBeInTheDocument()
expect(screen.getByTestId('oauth-microsoft')).toBeInTheDocument()
expect(screen.getByTestId('accept-submit')).toBeInTheDocument()
expect(screen.getByTestId('accept-submit')).toHaveTextContent(/Join Acme MSP/)
expect(inviteApi.lookupAccountInvite).toHaveBeenCalledWith(
'VALIDINVITECODE0011223344556677',
)
})
it('shows resend message + mailto link for an invalid invite code', async () => {
vi.mocked(inviteApi.lookupAccountInvite).mockRejectedValue(
Object.assign(new Error('not found'), {
response: {
status: 404,
data: { detail: { error: 'invite_invalid_or_expired_or_revoked' } },
},
}),
)
renderPage('/accept-invite?code=BADCODE')
await waitFor(() => {
expect(
screen.getByText(/This invite is no longer valid/i),
).toBeInTheDocument()
})
expect(
screen.getByText(/Ask the person who invited you to resend it/i),
).toBeInTheDocument()
const resendLink = screen.getByRole('link', { name: /Email your inviter/i })
expect(resendLink).toHaveAttribute(
'href',
expect.stringMatching(/^mailto:/),
)
// No accept form rendered when invite is invalid.
expect(screen.queryByTestId('accept-submit')).not.toBeInTheDocument()
expect(screen.queryByTestId('oauth-google')).not.toBeInTheDocument()
})
})

View File

@@ -26,6 +26,7 @@ const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage'))
// Standalone auth pages
const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage'))
const OAuthCallbackPage = lazyWithRetry(() => import('@/pages/OAuthCallbackPage'))
const AcceptInvitePage = lazyWithRetry(() => import('@/pages/AcceptInvitePage'))
const ChangePasswordPage = lazyWithRetry(() => import('@/pages/ChangePasswordPage'))
const ForgotPasswordPage = lazyWithRetry(() => import('@/pages/ForgotPasswordPage'))
const ResetPasswordPage = lazyWithRetry(() => import('@/pages/ResetPasswordPage'))
@@ -150,6 +151,11 @@ export const router = sentryCreateBrowserRouter([
element: page(VerifyEmailPage),
errorElement: <RouteError />,
},
{
path: '/accept-invite',
element: page(AcceptInvitePage),
errorElement: <RouteError />,
},
{
path: '/auth/google/callback',
element: page(OAuthCallbackPage),

View File

@@ -26,6 +26,8 @@ export interface UserCreate {
name: string
role?: UserRole
invite_code?: string
/** Account invite code to join an existing account (issued via /accounts/me/invites). */
account_invite_code?: string
}
export interface UserLogin {