diff --git a/backend/app/api/endpoints/account_invite_lookup.py b/backend/app/api/endpoints/account_invite_lookup.py new file mode 100644 index 00000000..a0623b8e --- /dev/null +++ b/backend/app/api/endpoints/account_invite_lookup.py @@ -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, + ) diff --git a/backend/app/api/endpoints/oauth.py b/backend/app/api/endpoints/oauth.py index cbc5aedf..dcf49263 100644 --- a/backend/app/api/endpoints/oauth.py +++ b/backend/app/api/endpoints/oauth.py @@ -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)}), diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 155fa304..2839d49c 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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 diff --git a/backend/app/schemas/oauth.py b/backend/app/schemas/oauth.py index 47ddf9ca..da30a913 100644 --- a/backend/app/schemas/oauth.py +++ b/backend/app/schemas/oauth.py @@ -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 diff --git a/backend/tests/test_account_invite_lookup.py b/backend/tests/test_account_invite_lookup.py new file mode 100644 index 00000000..bb9847e9 --- /dev/null +++ b/backend/tests/test_account_invite_lookup.py @@ -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" diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 7382679d..a5762fe0 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -79,18 +79,32 @@ export const authApi = { await apiClient.post('/auth/email/verify', { token }) }, - async googleCallback(code: string): Promise { + async googleCallback( + code: string, + options?: { accountInviteCode?: string; invitedEmail?: string }, + ): Promise { const response = await apiClient.post( '/auth/google/callback', - { code }, + { + code, + account_invite_code: options?.accountInviteCode, + invited_email: options?.invitedEmail, + }, ) return response.data }, - async microsoftCallback(code: string): Promise { + async microsoftCallback( + code: string, + options?: { accountInviteCode?: string; invitedEmail?: string }, + ): Promise { const response = await apiClient.post( '/auth/microsoft/callback', - { code }, + { + code, + account_invite_code: options?.accountInviteCode, + invited_email: options?.invitedEmail, + }, ) return response.data }, diff --git a/frontend/src/api/invite.ts b/frontend/src/api/invite.ts index f548321e..92e71548 100644 --- a/frontend/src/api/invite.ts +++ b/frontend/src/api/invite.ts @@ -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 { const response = await apiClient.get(`/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 { + const response = await apiClient.get( + `/accounts/invites/${encodeURIComponent(code)}/lookup`, + ) + return response.data + }, } export default inviteApi diff --git a/frontend/src/lib/oauthState.test.ts b/frontend/src/lib/oauthState.test.ts new file mode 100644 index 00000000..2b5604ef --- /dev/null +++ b/frontend/src/lib/oauthState.test.ts @@ -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() + }) +}) diff --git a/frontend/src/lib/oauthState.ts b/frontend/src/lib/oauthState.ts new file mode 100644 index 00000000..843a6d3d --- /dev/null +++ b/frontend/src/lib/oauthState.ts @@ -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 + 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 + } +} diff --git a/frontend/src/pages/AcceptInvitePage.tsx b/frontend/src/pages/AcceptInvitePage.tsx new file mode 100644 index 00000000..4cd25a54 --- /dev/null +++ b/frontend/src/pages/AcceptInvitePage.tsx @@ -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( + 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 ( + <> + +
+
+ +
+
+
+ +
+

+ ResolutionFlow +

+
+ + {lookup.status === 'loading' && ( +
+

Loading invite…

+
+ )} + + {(lookup.status === 'invalid' || lookup.status === 'missing-code') && ( +
+

+ This invite is no longer valid +

+

+ {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. +

+ + Email your inviter + +

+ Already have an account?{' '} + + Sign in + +

+
+ )} + + {lookup.status === 'ok' && ( + <> +
+

+ Join {lookup.data.account_name} on + ResolutionFlow +

+

+ {lookup.data.inviter_name} invited you as {lookup.data.role}. +

+
+ +
+ {(error || localError) && ( +
+ {localError || error} +
+ )} + +
+

+ Joining as +

+

+ {lookup.data.invited_email} +

+

+ The invite is locked to this email address. +

+
+ + {(googleAvailable || microsoftAvailable) && ( +
+ {googleAvailable && ( + + )} + {microsoftAvailable && ( + + )} + +
+
+
+
+
+ + or set a password + +
+
+
+ )} + +
+
+ + 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" + /> +
+ +
+ + 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="••••••••••" + /> +

+ Must be at least 10 characters +

+
+ +
+ + 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="••••••••••" + /> +
+ + +
+
+ + )} + +

+ Already have an account?{' '} + + Sign in + +

+
+
+ + ) +} + +export default AcceptInvitePage diff --git a/frontend/src/pages/OAuthCallbackPage.tsx b/frontend/src/pages/OAuthCallbackPage.tsx index 5e0b8a1d..19ec82fc 100644 --- a/frontend/src/pages/OAuthCallbackPage.tsx +++ b/frontend/src/pages/OAuthCallbackPage.tsx @@ -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) + ) { + 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) } diff --git a/frontend/src/pages/__tests__/AcceptInvitePage.test.tsx b/frontend/src/pages/__tests__/AcceptInvitePage.test.tsx new file mode 100644 index 00000000..3f8ebf76 --- /dev/null +++ b/frontend/src/pages/__tests__/AcceptInvitePage.test.tsx @@ -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( + + + + + , + ) +} + +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() + }) +}) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index f0b3e50b..401b4969 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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: , }, + { + path: '/accept-invite', + element: page(AcceptInvitePage), + errorElement: , + }, { path: '/auth/google/callback', element: page(OAuthCallbackPage), diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 471957e9..8f65b34f 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -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 {