From 2f8ec3775e1e58b694f60776870b336c6b27fab3 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 19:55:42 -0400 Subject: [PATCH 01/29] feat(billing): add BillingService.open_customer_portal + GET endpoint Authed users can now request a Stripe-hosted Customer Portal URL for card updates and cancellation via GET /api/v1/billing/portal-session. The path is already in both _SUBSCRIPTION_GUARD_ALLOWLIST and _EMAIL_VERIFICATION_ALLOWLIST so canceled or unverified-past-grace users can still update billing. - Returns 503 with {"error": "stripe_not_configured"} when STRIPE_SECRET_KEY unset. - Returns 400 with {"error": "no_stripe_customer"} when account has no stripe_customer_id (must complete checkout first). Co-Authored-By: Claude Opus 4.7 --- backend/app/api/endpoints/billing.py | 26 ++++++++- backend/app/schemas/billing.py | 4 ++ backend/app/services/billing.py | 19 +++++++ backend/tests/test_billing_portal.py | 83 ++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_billing_portal.py diff --git a/backend/app/api/endpoints/billing.py b/backend/app/api/endpoints/billing.py index 23d067d4..7fa8694e 100644 --- a/backend/app/api/endpoints/billing.py +++ b/backend/app/api/endpoints/billing.py @@ -1,6 +1,6 @@ from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -10,6 +10,7 @@ from app.core.config import settings from app.models.account import Account from app.models.user import User from app.schemas.billing import ( + BillingPortalSessionResponse, BillingStateResponse, CheckoutSessionCreate, CheckoutSessionResponse, @@ -50,3 +51,26 @@ async def get_billing_state( )).scalar_one() state = await BillingService.get_billing_state(db, account) return BillingStateResponse(**state) + + +@router.get("/portal-session", response_model=BillingPortalSessionResponse) +async def get_billing_portal_session( + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> BillingPortalSessionResponse: + """Return a Stripe-hosted Customer Portal URL for the account so the user + can update card / cancel. Allowlisted from the subscription + email-verify + guards (a canceled or unverified-past-grace user must still be able to + update billing).""" + if not settings.stripe_enabled: + raise HTTPException(status_code=503, detail={"error": "stripe_not_configured"}) + + account = (await db.execute( + select(Account).where(Account.id == current_user.account_id) + )).scalar_one() + + try: + url = await BillingService.open_customer_portal(account) + except ValueError: + raise HTTPException(status_code=400, detail={"error": "no_stripe_customer"}) + return BillingPortalSessionResponse(url=url) diff --git a/backend/app/schemas/billing.py b/backend/app/schemas/billing.py index ebe9ab9d..0a1bcf98 100644 --- a/backend/app/schemas/billing.py +++ b/backend/app/schemas/billing.py @@ -13,6 +13,10 @@ class CheckoutSessionResponse(BaseModel): url: str +class BillingPortalSessionResponse(BaseModel): + url: str + + class SubscriptionState(BaseModel): status: str plan: str diff --git a/backend/app/services/billing.py b/backend/app/services/billing.py index a104a5b1..b662ed47 100644 --- a/backend/app/services/billing.py +++ b/backend/app/services/billing.py @@ -105,6 +105,25 @@ class BillingService: ) return session.url + @staticmethod + async def open_customer_portal(account: Account) -> str: + """Create a Stripe-hosted Customer Portal session and return the URL. + + Raises RuntimeError if Stripe isn't configured (endpoint maps to 503). + Raises ValueError if the account has no stripe_customer_id yet — the + user must complete a checkout first (endpoint maps to 400). + """ + if not settings.stripe_enabled: + raise RuntimeError("Stripe not configured") + if account.stripe_customer_id is None: + raise ValueError("no_stripe_customer") + stripe.api_key = settings.STRIPE_SECRET_KEY + session = stripe.billing_portal.Session.create( + customer=account.stripe_customer_id, + return_url=f"{settings.FRONTEND_URL}/account/billing", + ) + return session.url + @staticmethod async def get_billing_state(db: AsyncSession, account): """Aggregate Subscription + PlanLimits + PlanBilling + resolved feature diff --git a/backend/tests/test_billing_portal.py b/backend/tests/test_billing_portal.py new file mode 100644 index 00000000..76841a7a --- /dev/null +++ b/backend/tests/test_billing_portal.py @@ -0,0 +1,83 @@ +import uuid +import pytest +from unittest.mock import patch, MagicMock +from sqlalchemy import select + +from app.models.account import Account + + +@pytest.mark.asyncio +async def test_billing_portal_returns_url_for_account_with_stripe_customer( + client, test_db, test_user, auth_headers, monkeypatch +): + """Happy path: account has a stripe_customer_id and Stripe is configured → + GET /billing/portal-session returns the portal URL.""" + from app.core.config import settings + monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy") + monkeypatch.setattr(settings, "FRONTEND_URL", "https://app.example.com") + + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + account = (await test_db.execute( + select(Account).where(Account.id == account_id) + )).scalar_one() + account.stripe_customer_id = "cus_test_456" + await test_db.commit() + + fake_session = MagicMock() + fake_session.url = "https://billing.stripe.com/p/session/test_abc" + + with patch( + "stripe.billing_portal.Session.create", + return_value=fake_session, + ) as portal_mock: + response = await client.get( + "/api/v1/billing/portal-session", + headers=auth_headers, + ) + + assert response.status_code == 200, response.json() + assert response.json() == {"url": "https://billing.stripe.com/p/session/test_abc"} + portal_mock.assert_called_once() + call_kwargs = portal_mock.call_args.kwargs + assert call_kwargs["customer"] == "cus_test_456" + assert call_kwargs["return_url"] == "https://app.example.com/account/billing" + + +@pytest.mark.asyncio +async def test_billing_portal_returns_503_when_stripe_not_configured( + client, test_db, test_user, auth_headers, monkeypatch +): + """STRIPE_SECRET_KEY unset → settings.stripe_enabled is False → 503.""" + from app.core.config import settings + monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", None) + + response = await client.get( + "/api/v1/billing/portal-session", + headers=auth_headers, + ) + assert response.status_code == 503 + assert response.json()["detail"]["error"] == "stripe_not_configured" + + +@pytest.mark.asyncio +async def test_billing_portal_returns_400_when_account_has_no_stripe_customer( + client, test_db, test_user, auth_headers, monkeypatch +): + """Account with no stripe_customer_id (never completed checkout) → 400 + with `no_stripe_customer` error.""" + from app.core.config import settings + monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy") + + # test_user fixture seeds an account with no stripe_customer_id by default. + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + account = (await test_db.execute( + select(Account).where(Account.id == account_id) + )).scalar_one() + assert account.stripe_customer_id is None + + response = await client.get( + "/api/v1/billing/portal-session", + headers=auth_headers, + ) + assert response.status_code == 400 + assert response.json()["detail"]["error"] == "no_stripe_customer" -- 2.49.1 From 16f5e4ce051aa04a40fd66557e52f90d196001fb Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 20:04:43 -0400 Subject: [PATCH 02/29] feat(onboarding): add PATCH /users/me/onboarding-step + dismiss-rest Persists welcome-wizard Step 1/2/3 progress for self-serve signup Phase 2. PATCH validates step cannot decrease, ignores `data` on action="skip", and is idempotent on re-PATCH of the same step. POST /users/me/onboarding-dismiss-rest backs the wizard's "Skip the rest" button. Both routes added to _EMAIL_VERIFICATION_ALLOWLIST and _SUBSCRIPTION_GUARD_ALLOWLIST so the wizard runs before email verification and during the trial. 4 integration tests cover field writes, skip semantics, decrease guard, and dismiss-rest. Co-Authored-By: Claude Opus 4.7 --- backend/app/api/deps.py | 3 + backend/app/api/endpoints/onboarding.py | 104 ++++++++++++++++- backend/app/schemas/onboarding.py | 41 ++++++- backend/tests/test_onboarding_step.py | 149 ++++++++++++++++++++++++ 4 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_onboarding_step.py diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 9fbd5815..32ada630 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -235,6 +235,7 @@ _SUBSCRIPTION_GUARD_ALLOWLIST = { "/api/v1/billing/portal-session", "/api/v1/users/me", "/api/v1/users/me/onboarding-step", + "/api/v1/users/me/onboarding-dismiss-rest", } @@ -298,6 +299,8 @@ _EMAIL_VERIFICATION_ALLOWLIST = { "/api/v1/auth/email/verify", "/api/v1/auth/password/change", "/api/v1/users/me", + "/api/v1/users/me/onboarding-step", + "/api/v1/users/me/onboarding-dismiss-rest", "/api/v1/billing/state", "/api/v1/billing/checkout-session", "/api/v1/billing/portal-session", diff --git a/backend/app/api/endpoints/onboarding.py b/backend/app/api/endpoints/onboarding.py index 534f58a6..4cecf091 100644 --- a/backend/app/api/endpoints/onboarding.py +++ b/backend/app/api/endpoints/onboarding.py @@ -2,19 +2,24 @@ from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_active_user from app.core.database import get_db from app.core.admin_database import get_admin_db +from app.models.account import Account from app.models.assistant_chat import AssistantChat from app.models.psa_connection import PsaConnection from app.models.session import Session from app.models.tree import Tree from app.models.user import User -from app.schemas.onboarding import OnboardingStatus +from app.schemas.onboarding import ( + OnboardingStatus, + OnboardingStepRequest, + OnboardingStepResponse, +) router = APIRouter(prefix="/users", tags=["onboarding"]) @@ -109,3 +114,98 @@ async def dismiss_onboarding( # Return updated status (reuse the GET logic) return await get_onboarding_status(db=db, current_user=current_user) + + +# --------------------------------------------------------------------------- +# Welcome wizard endpoints (Phase 2) +# +# These persist Step 1/2/3 progress for the post-signup welcome wizard. +# Mounted on /users/me/* (the parent router prefix is /users) so the wizard +# can run before email verification and during trial. +# --------------------------------------------------------------------------- + + +@router.patch("/me/onboarding-step", response_model=OnboardingStepResponse) +async def patch_onboarding_step( + body: OnboardingStepRequest, + db: Annotated[AsyncSession, Depends(get_admin_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> OnboardingStepResponse: + """Persist welcome-wizard progress for the current user. + + Contract: + - step=1 + complete writes accounts.name, accounts.team_size_bucket, + users.role_at_signup, then sets users.onboarding_step_completed=1. + - step=2 + complete writes accounts.primary_psa, then sets + users.onboarding_step_completed=2. + - step=3 + complete just sets users.onboarding_step_completed=3 + (invites are POSTed separately). + - action="skip" ignores `data` entirely and only advances the step. + - The new step must be >= current onboarding_step_completed (None=>0); + otherwise 400. Idempotent re-PATCH of the same step succeeds. + """ + current_step = current_user.onboarding_step_completed or 0 + if body.step < current_step: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "step_cannot_decrease", + "current_step": current_step, + "requested_step": body.step, + }, + ) + + if body.action == "complete" and body.data is not None and body.step in (1, 2): + # Load the user's account for field writes. Step 3 has no data writes. + account_result = await db.execute( + select(Account).where(Account.id == current_user.account_id) + ) + account = account_result.scalar_one_or_none() + if account is None: + # Should never happen — user is required to have an account_id. + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="account_not_found", + ) + + if body.step == 1: + data = body.data + if data.company_name is not None: + account.name = data.company_name + if data.team_size_bucket is not None: + account.team_size_bucket = data.team_size_bucket + if data.role_at_signup is not None: + current_user.role_at_signup = data.role_at_signup + elif body.step == 2: + data = body.data + if data.primary_psa is not None: + account.primary_psa = data.primary_psa + + current_user.onboarding_step_completed = body.step + await db.commit() + await db.refresh(current_user) + + return OnboardingStepResponse( + onboarding_step_completed=current_user.onboarding_step_completed, + onboarding_dismissed=current_user.onboarding_dismissed, + ) + + +@router.post("/me/onboarding-dismiss-rest", response_model=OnboardingStepResponse) +async def dismiss_onboarding_rest( + db: Annotated[AsyncSession, Depends(get_admin_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> OnboardingStepResponse: + """Set users.onboarding_dismissed=TRUE — backs the wizard's "Skip the rest" button. + + Returns the same shape as the step PATCH so the frontend can update its + local store from a single response. + """ + current_user.onboarding_dismissed = True + await db.commit() + await db.refresh(current_user) + + return OnboardingStepResponse( + onboarding_step_completed=current_user.onboarding_step_completed, + onboarding_dismissed=current_user.onboarding_dismissed, + ) diff --git a/backend/app/schemas/onboarding.py b/backend/app/schemas/onboarding.py index d21647b5..303e1ceb 100644 --- a/backend/app/schemas/onboarding.py +++ b/backend/app/schemas/onboarding.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel +from typing import Literal, Optional + +from pydantic import BaseModel, Field class OnboardingStatus(BaseModel): @@ -10,3 +12,40 @@ class OnboardingStatus(BaseModel): connected_psa: bool is_team_user: bool dismissed: bool + + +# --- Welcome wizard (Phase 2) ---------------------------------------------- + + +TeamSizeBucket = Literal["1-2", "3-5", "6-10", "11-25", "26+"] +RoleAtSignup = Literal["owner", "lead_tech", "tech", "other"] +PrimaryPsa = Literal["connectwise", "autotask", "halopsa", "none"] +WizardStep = Literal[1, 2, 3] +WizardAction = Literal["complete", "skip"] + + +class OnboardingStepData(BaseModel): + """Optional payload carried with `action="complete"` for steps 1 and 2. + + Step 1 fields: company_name, team_size_bucket, role_at_signup + Step 2 fields: primary_psa + Step 3 has no data (invitations posted separately). + """ + + # Step 1 + company_name: Optional[str] = Field(default=None, max_length=255) + team_size_bucket: Optional[TeamSizeBucket] = None + role_at_signup: Optional[RoleAtSignup] = None + # Step 2 + primary_psa: Optional[PrimaryPsa] = None + + +class OnboardingStepRequest(BaseModel): + step: WizardStep + action: WizardAction + data: Optional[OnboardingStepData] = None + + +class OnboardingStepResponse(BaseModel): + onboarding_step_completed: Optional[int] + onboarding_dismissed: bool diff --git a/backend/tests/test_onboarding_step.py b/backend/tests/test_onboarding_step.py new file mode 100644 index 00000000..eaf9a2a9 --- /dev/null +++ b/backend/tests/test_onboarding_step.py @@ -0,0 +1,149 @@ +"""Tests for welcome-wizard onboarding-step endpoints (Phase 2).""" + +import pytest +from sqlalchemy import select + +from app.models.account import Account +from app.models.user import User + + +@pytest.mark.asyncio +async def test_onboarding_step1_complete_writes_account_name_and_team_size_and_role( + client, auth_headers, test_db, test_user +): + """Step 1 + complete writes account.name + team_size_bucket + user.role_at_signup + and advances onboarding_step_completed to 1.""" + response = await client.patch( + "/api/v1/users/me/onboarding-step", + headers=auth_headers, + json={ + "step": 1, + "action": "complete", + "data": { + "company_name": "Acme MSP", + "team_size_bucket": "3-5", + "role_at_signup": "owner", + }, + }, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["onboarding_step_completed"] == 1 + assert data["onboarding_dismissed"] is False + + # Verify persisted writes + account_id = test_user["user_data"]["account_id"] + user_email = test_user["email"] + + acct = ( + await test_db.execute(select(Account).where(Account.id == account_id)) + ).scalar_one() + assert acct.name == "Acme MSP" + assert acct.team_size_bucket == "3-5" + + user = ( + await test_db.execute(select(User).where(User.email == user_email)) + ).scalar_one() + assert user.role_at_signup == "owner" + assert user.onboarding_step_completed == 1 + + +@pytest.mark.asyncio +async def test_onboarding_step2_skip_advances_without_psa( + client, auth_headers, test_db, test_user +): + """Step 2 + skip ignores data entirely and only advances the step counter + (no primary_psa write).""" + # Capture original account.primary_psa so we can assert it's untouched. + account_id = test_user["user_data"]["account_id"] + acct_before = ( + await test_db.execute(select(Account).where(Account.id == account_id)) + ).scalar_one() + psa_before = acct_before.primary_psa # likely None + + # Advance step 1 first so step 2 is allowed. + r1 = await client.patch( + "/api/v1/users/me/onboarding-step", + headers=auth_headers, + json={"step": 1, "action": "skip"}, + ) + assert r1.status_code == 200, r1.text + + # Skip step 2 — even if data is present it must be ignored. + r2 = await client.patch( + "/api/v1/users/me/onboarding-step", + headers=auth_headers, + json={ + "step": 2, + "action": "skip", + "data": {"primary_psa": "connectwise"}, + }, + ) + assert r2.status_code == 200, r2.text + assert r2.json()["onboarding_step_completed"] == 2 + + # Re-fetch account: primary_psa must NOT have been written. + test_db.expire_all() + acct_after = ( + await test_db.execute(select(Account).where(Account.id == account_id)) + ).scalar_one() + assert acct_after.primary_psa == psa_before + + +@pytest.mark.asyncio +async def test_onboarding_step_cannot_decrease(client, auth_headers): + """A step=2 PATCH followed by step=1 must return 400.""" + # Advance to step 2. + r1 = await client.patch( + "/api/v1/users/me/onboarding-step", + headers=auth_headers, + json={"step": 1, "action": "skip"}, + ) + assert r1.status_code == 200, r1.text + r2 = await client.patch( + "/api/v1/users/me/onboarding-step", + headers=auth_headers, + json={"step": 2, "action": "skip"}, + ) + assert r2.status_code == 200, r2.text + assert r2.json()["onboarding_step_completed"] == 2 + + # Try to go back to step 1 — must fail. + r3 = await client.patch( + "/api/v1/users/me/onboarding-step", + headers=auth_headers, + json={"step": 1, "action": "skip"}, + ) + assert r3.status_code == 400, r3.text + + # Idempotent re-PATCH of same step succeeds. + r4 = await client.patch( + "/api/v1/users/me/onboarding-step", + headers=auth_headers, + json={"step": 2, "action": "skip"}, + ) + assert r4.status_code == 200, r4.text + assert r4.json()["onboarding_step_completed"] == 2 + + +@pytest.mark.asyncio +async def test_onboarding_dismiss_rest_sets_flag( + client, auth_headers, test_db, test_user +): + """POST /users/me/onboarding-dismiss-rest sets users.onboarding_dismissed=TRUE.""" + response = await client.post( + "/api/v1/users/me/onboarding-dismiss-rest", + headers=auth_headers, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["onboarding_dismissed"] is True + # step counter is whatever it was (None for a fresh user). + assert "onboarding_step_completed" in data + + # Verify persisted. + user_email = test_user["email"] + user = ( + await test_db.execute(select(User).where(User.email == user_email)) + ).scalar_one() + assert user.onboarding_dismissed is True -- 2.49.1 From 694279f89e22d4444a5fffc3d663d47e1b3335a7 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 20:12:03 -0400 Subject: [PATCH 03/29] feat(sales): add POST /sales-leads public endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 Task 29 — public Talk-to-Sales submission endpoint. - New POST /api/v1/sales-leads (public, no auth, rate-limited 5/hour per IP). - Inserts a sales_leads row, fires best-effort notification email and PostHog server-side capture; failures are logged but never fail the request. - New EmailService.send_sales_lead_notification static method. - New SALES_LEAD_RECIPIENT_EMAIL setting (defaults to sales@resolutionflow.com). - Schemas: SalesLeadCreate / SalesLeadCreateResponse with literal source enum. - Tests: happy path (row + email), email-failure resilience, and rate-limit enforcement (re-enables the slowapi limiter for the rate-limit assertion since DEBUG=true disables it by default in tests). PostHog server-side instrumentation point is wired in but no-ops gracefully until app.core.analytics.posthog exists — turning it on is a one-line change when the backend SDK is configured. Co-Authored-By: Claude Opus 4.7 --- backend/app/api/endpoints/sales_leads.py | 114 +++++++++++++++++++ backend/app/api/router.py | 2 + backend/app/core/config.py | 1 + backend/app/core/email.py | 98 +++++++++++++++++ backend/app/schemas/sales_lead.py | 27 +++++ backend/tests/test_sales_leads.py | 134 +++++++++++++++++++++++ 6 files changed, 376 insertions(+) create mode 100644 backend/app/api/endpoints/sales_leads.py create mode 100644 backend/app/schemas/sales_lead.py create mode 100644 backend/tests/test_sales_leads.py diff --git a/backend/app/api/endpoints/sales_leads.py b/backend/app/api/endpoints/sales_leads.py new file mode 100644 index 00000000..5f786319 --- /dev/null +++ b/backend/app/api/endpoints/sales_leads.py @@ -0,0 +1,114 @@ +"""Public Talk-to-Sales endpoint — no auth required. + +POST /api/v1/sales-leads + - Inserts a sales_leads row. + - Fires (best-effort) a notification email to settings.SALES_LEAD_RECIPIENT_EMAIL. + - Emits a server-side PostHog event (best-effort). + - Rate-limited per IP (5/hour). +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.admin_database import get_admin_db +from app.core.config import settings +from app.core.email import EmailService +from app.core.rate_limit import limiter +from app.models.sales_lead import SalesLead +from app.schemas.sales_lead import SalesLeadCreate, SalesLeadCreateResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/sales-leads", tags=["sales"]) + + +async def _send_notification_email(lead: SalesLead) -> None: + """Fire-and-forget wrapper. EmailService methods never raise, but we + still wrap in a try/except to defend against future regressions.""" + try: + await EmailService.send_sales_lead_notification( + to_email=settings.SALES_LEAD_RECIPIENT_EMAIL, + lead=lead, + ) + except Exception: + logger.warning( + "Sales lead notification email failed for lead %s", + lead.id, + exc_info=True, + ) + + +def _capture_posthog_event(lead: SalesLead) -> None: + """Emit `talk_to_sales_form_submitted` server-side. Best-effort. + + Backend PostHog SDK isn't initialized in the project today; this function + is the single instrumentation point so wiring it up later is a one-line + change. The call is wrapped so any future failure can never fail the + request. + """ + try: + # Lazy import — keeps the dependency optional. When the backend + # PostHog client is wired in (likely as `app.core.analytics.posthog`), + # swap the import path here and the event will fire automatically. + try: + from app.core.analytics import posthog # type: ignore[attr-defined] + except ImportError: + logger.debug( + "PostHog server-side capture skipped — client not configured" + ) + return + + distinct_id = lead.posthog_distinct_id or f"sales_lead:{lead.id}" + posthog.capture( + distinct_id=distinct_id, + event="talk_to_sales_form_submitted", + properties={ + "source": lead.source, + "company": lead.company, + "team_size": lead.team_size, + }, + ) + except Exception: + logger.warning( + "PostHog capture failed for sales lead %s", + lead.id, + exc_info=True, + ) + + +@router.post("", response_model=SalesLeadCreateResponse, status_code=201) +@limiter.limit("5/hour") +async def create_sales_lead( + request: Request, + data: SalesLeadCreate, + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> SalesLeadCreateResponse: + """Public Talk-to-Sales submission. + + Creates a sales_leads row, fires (best-effort) a notification email and a + server-side PostHog event. Rate-limited per IP at 5/hour. + """ + lead = SalesLead( + email=str(data.email).lower(), + name=data.name, + company=data.company, + team_size=data.team_size, + message=data.message, + source=data.source, + posthog_distinct_id=data.posthog_distinct_id, + ) + db.add(lead) + await db.commit() + await db.refresh(lead) + + # Fire-and-forget: email + analytics. Failures must not fail the request. + asyncio.create_task(_send_notification_email(lead)) + _capture_posthog_event(lead) + + return SalesLeadCreateResponse(id=lead.id, status="received") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 01ce9a8a..5ef8122c 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -26,6 +26,7 @@ from app.api.endpoints import ( billing, beta_feedback, beta_signup, + sales_leads, branding, categories, copilot, @@ -88,6 +89,7 @@ api_router.include_router(billing.router) # Reachable when subscription lock api_router.include_router(shared.router) # Public share links (no auth) api_router.include_router(shares.public_router) # Public session share links (optional auth) api_router.include_router(beta_signup.router) +api_router.include_router(sales_leads.router) # Talk-to-Sales (no auth, rate-limited) 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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f2c28593..815c95db 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -84,6 +84,7 @@ class Settings(BaseSettings): RESEND_API_KEY: Optional[str] = None FROM_EMAIL: str = "ResolutionFlow " FEEDBACK_EMAIL: Optional[str] = None + SALES_LEAD_RECIPIENT_EMAIL: str = "sales@resolutionflow.com" @property def email_enabled(self) -> bool: diff --git a/backend/app/core/email.py b/backend/app/core/email.py index 313d5db0..0bb62b94 100644 --- a/backend/app/core/email.py +++ b/backend/app/core/email.py @@ -1,6 +1,11 @@ import logging +from typing import TYPE_CHECKING + from app.core.config import settings +if TYPE_CHECKING: + from app.models.sales_lead import SalesLead + logger = logging.getLogger(__name__) @@ -484,6 +489,99 @@ class EmailService: logger.exception("Failed to send beta signup notification for %s", signup_email) return False + @staticmethod + async def send_sales_lead_notification( + to_email: str, + lead: "SalesLead", + ) -> bool: + """Notify the sales recipient about a new Talk-to-Sales submission. + + Fire-and-forget. Returns False (and logs) on any failure; never raises. + """ + if not settings.email_enabled: + logger.warning( + "Sales lead email not sent — RESEND_API_KEY not configured (lead %s)", + lead.id, + ) + return False + + try: + import resend + import html as html_mod + from datetime import datetime, timezone + + resend.api_key = settings.RESEND_API_KEY + + date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + safe_email = html_mod.escape(lead.email) + safe_name = html_mod.escape(lead.name) + safe_company = html_mod.escape(lead.company) + safe_team_size = html_mod.escape(lead.team_size or "—") + safe_source = html_mod.escape(lead.source) + safe_message = html_mod.escape(lead.message or "(no message)") + subject = f"[ResolutionFlow Sales] New lead — {safe_company} ({safe_email})" + + email_html = f""" + + + + +
+ + + + + + +
+

ResolutionFlow

+

New Sales Lead

+
+

+ Source: {safe_source} +

+
+ + +
+

Name

+

{safe_name}

+

Email

+

{safe_email}

+

Company

+

{safe_company}

+

Team Size

+

{safe_team_size}

+
+
+

Message

+

{safe_message}

+
+

+ Submitted at {date_str} · Lead ID: {lead.id} +

+
+
+""" + + resend.Emails.send({ + "from": settings.FROM_EMAIL, + "to": [to_email], + "reply_to": lead.email, + "subject": subject, + "html": email_html, + }) + logger.info("Sales lead notification sent for %s (lead %s)", lead.email, lead.id) + return True + + except Exception: + logger.exception( + "Failed to send sales lead notification for %s (lead %s)", + lead.email, + lead.id, + ) + return False + @staticmethod async def send_notification_email( to_email: str, diff --git a/backend/app/schemas/sales_lead.py b/backend/app/schemas/sales_lead.py new file mode 100644 index 00000000..9247e91e --- /dev/null +++ b/backend/app/schemas/sales_lead.py @@ -0,0 +1,27 @@ +"""Pydantic schemas for Talk-to-Sales submissions.""" + +from typing import Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +SalesLeadSource = Literal["pricing_page", "register_footer", "landing_page"] + + +class SalesLeadCreate(BaseModel): + """Public Talk-to-Sales form submission.""" + + model_config = ConfigDict(str_strip_whitespace=True) + + email: EmailStr + name: str = Field(..., min_length=1, max_length=255) + company: str = Field(..., min_length=1, max_length=255) + team_size: Optional[str] = Field(default=None, max_length=20) + message: Optional[str] = Field(default=None, max_length=5000) + source: SalesLeadSource + posthog_distinct_id: Optional[str] = Field(default=None, max_length=255) + + +class SalesLeadCreateResponse(BaseModel): + id: UUID + status: Literal["received"] = "received" diff --git a/backend/tests/test_sales_leads.py b/backend/tests/test_sales_leads.py new file mode 100644 index 00000000..c3620ab8 --- /dev/null +++ b/backend/tests/test_sales_leads.py @@ -0,0 +1,134 @@ +"""Integration tests for the public Talk-to-Sales endpoint. + +POST /api/v1/sales-leads — no auth, rate-limited 5/hour per IP. +""" + +from unittest.mock import AsyncMock, patch + +import pytest +import sqlalchemy as sa + + +@pytest.mark.asyncio +async def test_sales_lead_creates_row_and_sends_notification_email(client, test_db): + """Happy path: row inserted, notification email fired, 201 returned.""" + + payload = { + "email": "buyer@acme.example", + "name": "Pat Buyer", + "company": "Acme MSP", + "team_size": "11-50", + "message": "We're evaluating ResolutionFlow for our NOC team.", + "source": "pricing_page", + "posthog_distinct_id": "ph_distinct_123", + } + + with patch( + "app.api.endpoints.sales_leads.EmailService.send_sales_lead_notification", + new=AsyncMock(return_value=True), + ) as mock_email: + response = await client.post("/api/v1/sales-leads", json=payload) + + assert response.status_code == 201, response.text + body = response.json() + assert body["status"] == "received" + assert "id" in body + + # Notification email was attempted (asyncio.create_task — give it a tick). + import asyncio + await asyncio.sleep(0) + await asyncio.sleep(0) + assert mock_email.await_count == 1 + kwargs = mock_email.await_args.kwargs + assert kwargs["to_email"] # default placeholder until cutover + assert kwargs["lead"].email == "buyer@acme.example" + assert kwargs["lead"].source == "pricing_page" + + # Row was inserted with normalized email + all fields preserved. + result = await test_db.execute( + sa.text("SELECT email, name, company, team_size, message, source, posthog_distinct_id, status FROM sales_leads") + ) + rows = result.all() + assert len(rows) == 1 + row = rows[0] + assert row.email == "buyer@acme.example" + assert row.name == "Pat Buyer" + assert row.company == "Acme MSP" + assert row.team_size == "11-50" + assert row.message == "We're evaluating ResolutionFlow for our NOC team." + assert row.source == "pricing_page" + assert row.posthog_distinct_id == "ph_distinct_123" + assert row.status == "new" + + +@pytest.mark.asyncio +async def test_sales_lead_email_failure_does_not_fail_request(client, test_db): + """If the email send raises, the API still returns 201 and the row persists.""" + + payload = { + "email": "buyer2@acme.example", + "name": "Sam Lead", + "company": "Acme MSP", + "source": "register_footer", + } + + with patch( + "app.api.endpoints.sales_leads.EmailService.send_sales_lead_notification", + new=AsyncMock(side_effect=RuntimeError("resend exploded")), + ): + response = await client.post("/api/v1/sales-leads", json=payload) + + assert response.status_code == 201, response.text + + # Row must still be persisted even though email failed. + import asyncio + await asyncio.sleep(0) + result = await test_db.execute( + sa.text("SELECT count(*) FROM sales_leads WHERE email = 'buyer2@acme.example'") + ) + assert result.scalar() == 1 + + +@pytest.mark.asyncio +async def test_sales_lead_rate_limited_after_5_per_hour(client): + """The 6th submission within an hour from the same IP returns 429. + + The default `limiter` is disabled in tests (DEBUG=true). We re-enable it + for this test, then reset its state on teardown so other tests aren't + affected. + """ + from app.core.rate_limit import limiter + + was_enabled = limiter.enabled + limiter.enabled = True + try: + limiter.reset() + + with patch( + "app.api.endpoints.sales_leads.EmailService.send_sales_lead_notification", + new=AsyncMock(return_value=True), + ): + for i in range(5): + payload = { + "email": f"lead{i}@acme.example", + "name": f"Lead {i}", + "company": "Acme MSP", + "source": "landing_page", + } + resp = await client.post("/api/v1/sales-leads", json=payload) + assert resp.status_code == 201, f"submission {i}: {resp.text}" + + # 6th should be rate-limited. + resp = await client.post( + "/api/v1/sales-leads", + json={ + "email": "lead6@acme.example", + "name": "Lead 6", + "company": "Acme MSP", + "source": "landing_page", + }, + ) + assert resp.status_code == 429, resp.text + finally: + limiter.reset() + limiter.enabled = was_enabled -- 2.49.1 From d05b475a411439a0183f6c481f0b3891566f8775 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 20:32:09 -0400 Subject: [PATCH 04/29] feat(admin): extend /admin/plan-limits to manage plan_billing fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 30 of self-serve signup Phase 2. Super-admins can now manage Stripe IDs, display names, prices, and public/archived flags via the existing admin plan-limits endpoints. - GET /admin/plan-limits now outer-joins plan_billing and returns merged PlanLimitWithBillingResponse rows. Plans without a plan_billing row return None for the billing fields. - PUT /admin/plan-limits accepts the new optional billing fields and upserts plan_billing in the same transaction. If no plan_billing row exists for the plan and the body includes any billing field, a row is created (display_name defaults to plan.capitalize() when omitted; display_name is never NULLed out on an existing row). - After commit, the handler queries account_ids on the affected plan and calls BillingService.invalidate_billing_cache(account_ids). This is a no-op stub today (logs only) — there's no in-process billing cache yet. TODO comment marks the wire-up point. - 3 new integration tests cover GET-with-billing-present, PUT creating a plan_billing row, and the invalidation hook being awaited with a list of account_ids. Co-Authored-By: Claude Opus 4.7 --- .../app/api/endpoints/admin_plan_limits.py | 125 ++++++++++- backend/app/schemas/admin.py | 28 +++ backend/app/services/billing.py | 25 +++ backend/tests/test_admin_plan_limits.py | 206 ++++++++++++++++++ 4 files changed, 375 insertions(+), 9 deletions(-) diff --git a/backend/app/api/endpoints/admin_plan_limits.py b/backend/app/api/endpoints/admin_plan_limits.py index 387081f5..52ea09b4 100644 --- a/backend/app/api/endpoints/admin_plan_limits.py +++ b/backend/app/api/endpoints/admin_plan_limits.py @@ -8,34 +8,101 @@ from app.core.database import get_db from app.core.audit import log_audit from app.models.user import User from app.models.plan_limits import PlanLimits +from app.models.plan_billing import PlanBilling from app.models.account import Account from app.models.account_limit_override import AccountLimitOverride +from app.models.subscription import Subscription from app.schemas.admin import ( - PlanLimitResponse, PlanLimitUpdate, + PlanLimitResponse, PlanLimitUpdate, PlanLimitWithBillingResponse, AccountOverrideCreate, AccountOverrideUpdate, AccountOverrideResponse, ) from app.api.deps import require_admin +from app.services.billing import BillingService router = APIRouter(prefix="/admin", tags=["admin-plan-limits"]) -@router.get("/plan-limits", response_model=list[PlanLimitResponse]) +# Fields on PlanLimitUpdate that map to plan_billing (not plan_limits). +_PLAN_BILLING_FIELDS = ( + "display_name", + "description", + "monthly_price_cents", + "annual_price_cents", + "stripe_product_id", + "stripe_monthly_price_id", + "stripe_annual_price_id", + "is_public", + "is_archived", + "sort_order", +) + +# Subset of _PLAN_BILLING_FIELDS that are NOT NULL on the PlanBilling model. +# These are Optional[...] on PlanLimitUpdate, so a caller sending an explicit +# null for any of them would otherwise trigger a NOT NULL violation at commit. +_PLAN_BILLING_NOT_NULL_FIELDS = frozenset({ + "display_name", + "is_public", + "is_archived", + "sort_order", +}) + + +def _merge_plan_with_billing( + plan: PlanLimits, billing: PlanBilling | None +) -> PlanLimitWithBillingResponse: + """Build a merged response. Billing fields are None when no plan_billing row + exists for the plan.""" + payload = { + "plan": plan.plan, + "max_trees": plan.max_trees, + "max_sessions_per_month": plan.max_sessions_per_month, + "max_users": plan.max_users, + "custom_branding": plan.custom_branding, + "priority_support": plan.priority_support, + "export_formats": plan.export_formats or [], + } + if billing is not None: + payload.update({ + "display_name": billing.display_name, + "description": billing.description, + "monthly_price_cents": billing.monthly_price_cents, + "annual_price_cents": billing.annual_price_cents, + "stripe_product_id": billing.stripe_product_id, + "stripe_monthly_price_id": billing.stripe_monthly_price_id, + "stripe_annual_price_id": billing.stripe_annual_price_id, + "is_public": billing.is_public, + "is_archived": billing.is_archived, + "sort_order": billing.sort_order, + }) + return PlanLimitWithBillingResponse(**payload) + + +@router.get("/plan-limits", response_model=list[PlanLimitWithBillingResponse]) async def list_plan_limits( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_admin)], ): - """List all plan limit configurations.""" - result = await db.execute(select(PlanLimits)) - return result.scalars().all() + """List all plan limit configurations, merged with plan_billing fields + where present. Plans without a plan_billing row return None for the + billing fields.""" + rows = (await db.execute( + select(PlanLimits, PlanBilling) + .outerjoin(PlanBilling, PlanLimits.plan == PlanBilling.plan) + )).all() + return [_merge_plan_with_billing(pl, pb) for pl, pb in rows] -@router.put("/plan-limits", response_model=PlanLimitResponse) +@router.put("/plan-limits", response_model=PlanLimitWithBillingResponse) async def update_plan_limits( data: PlanLimitUpdate, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_admin)], ): - """Update a plan's limits.""" + """Update a plan's limits and (if any plan_billing field is included) + upsert the matching plan_billing row in the same transaction. After + commit, invalidates the in-process billing cache for accounts on this + plan (currently a no-op — see BillingService.invalidate_billing_cache). + """ result = await db.execute(select(PlanLimits).where(PlanLimits.plan == data.plan)) plan = result.scalar_one_or_none() if not plan: @@ -48,10 +115,50 @@ async def update_plan_limits( plan.priority_support = data.priority_support plan.export_formats = data.export_formats - await log_audit(db, current_user.id, "plan_limits.update", "plan_limits", details={"plan": data.plan}) + # Did the request include any plan_billing field? (Pydantic gives us + # `model_fields_set` to distinguish "user passed null" from "field omitted".) + billing_fields_set = data.model_fields_set & set(_PLAN_BILLING_FIELDS) + billing: PlanBilling | None = None + if billing_fields_set: + billing = (await db.execute( + select(PlanBilling).where(PlanBilling.plan == data.plan) + )).scalar_one_or_none() + + if billing is None: + # Create. display_name is required on the model — derive from the + # plan name when the caller didn't supply one (e.g. "pro" → "Pro"). + display_name = data.display_name or data.plan.capitalize() + billing = PlanBilling(plan=data.plan, display_name=display_name) + db.add(billing) + + # Apply only the fields the caller actually included. Allows partial + # updates without clobbering existing values. + for field in billing_fields_set: + value = getattr(data, field) + if value is None and field in _PLAN_BILLING_NOT_NULL_FIELDS: + # Don't NULL out a NOT NULL column on update. + continue + setattr(billing, field, value) + + await log_audit( + db, current_user.id, "plan_limits.update", "plan_limits", + details={"plan": data.plan, "updated_billing": bool(billing_fields_set)}, + ) await db.commit() await db.refresh(plan) - return plan + if billing is not None: + await db.refresh(billing) + + # Invalidate any in-process billing cache for accounts on this plan. + # TODO: invalidate app.state.billing_cache when added. + account_ids = [ + row[0] for row in (await db.execute( + select(Subscription.account_id).where(Subscription.plan == data.plan) + )).all() + ] + await BillingService.invalidate_billing_cache(account_ids) + + return _merge_plan_with_billing(plan, billing) @router.get("/account-overrides", response_model=list[AccountOverrideResponse]) diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index 72c63d43..a223d994 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -172,6 +172,21 @@ class PlanLimitResponse(BaseModel): from_attributes = True +class PlanLimitWithBillingResponse(PlanLimitResponse): + """PlanLimits + plan_billing fields merged. Billing fields are None when no + plan_billing row exists for the plan yet.""" + display_name: Optional[str] = None + description: Optional[str] = None + monthly_price_cents: Optional[int] = None + annual_price_cents: Optional[int] = None + stripe_product_id: Optional[str] = None + stripe_monthly_price_id: Optional[str] = None + stripe_annual_price_id: Optional[str] = None + is_public: Optional[bool] = None + is_archived: Optional[bool] = None + sort_order: Optional[int] = None + + class PlanLimitUpdate(BaseModel): plan: str max_trees: Optional[int] = None @@ -180,6 +195,19 @@ class PlanLimitUpdate(BaseModel): custom_branding: bool = False priority_support: bool = False export_formats: list = Field(default_factory=lambda: ["markdown", "text"]) + # plan_billing fields — all optional, partial-update semantics. If any are + # set in the body, the admin endpoint upserts the plan_billing row in the + # same transaction. + display_name: Optional[str] = None + description: Optional[str] = None + monthly_price_cents: Optional[int] = None + annual_price_cents: Optional[int] = None + stripe_product_id: Optional[str] = None + stripe_monthly_price_id: Optional[str] = None + stripe_annual_price_id: Optional[str] = None + is_public: Optional[bool] = None + is_archived: Optional[bool] = None + sort_order: Optional[int] = None class AccountOverrideCreate(BaseModel): diff --git a/backend/app/services/billing.py b/backend/app/services/billing.py index b662ed47..1ae0a999 100644 --- a/backend/app/services/billing.py +++ b/backend/app/services/billing.py @@ -1,6 +1,7 @@ """Single billing service module. Stripe is the only impl — no provider abstraction. Account row is canonical local state; Stripe is canonical remote state; the webhook handler bridges the two.""" +import logging from datetime import datetime, timezone, timedelta import stripe @@ -17,8 +18,32 @@ from app.models.subscription import Subscription TRIAL_DAYS = 14 +logger = logging.getLogger(__name__) + class BillingService: + @staticmethod + async def invalidate_billing_cache(account_ids) -> None: + """No-op stub for future in-process billing cache invalidation. + + Today there is no `app.state.billing_cache` — `BillingService.get_billing_state` + always reads fresh from the DB. Call sites that mutate plan/feature data + invoke this hook so that wiring is in place when an in-process cache is + added later. Until then, this just logs. + + TODO: when an in-process billing cache (e.g. `app.state.billing_cache`) + is introduced, evict entries for the given account_ids here. + """ + try: + count = len(list(account_ids)) + except TypeError: + count = -1 + logger.debug( + "BillingService.invalidate_billing_cache called for %d account(s) " + "(no-op stub — wire to app.state.billing_cache when added)", + count, + ) + @staticmethod async def start_trial(db: AsyncSession, account_id) -> Subscription: """Idempotent. Creates a trialing Subscription on Pro for the account if diff --git a/backend/tests/test_admin_plan_limits.py b/backend/tests/test_admin_plan_limits.py index 7e701b16..8eb22d45 100644 --- a/backend/tests/test_admin_plan_limits.py +++ b/backend/tests/test_admin_plan_limits.py @@ -1,7 +1,12 @@ """Integration tests for admin plan limits and account override endpoints.""" +from unittest.mock import AsyncMock, patch + import pytest from httpx import AsyncClient +from sqlalchemy import select + +from app.models.plan_billing import PlanBilling class TestAdminPlanLimits: @@ -56,3 +61,204 @@ class TestAdminPlanLimits: """Non-admin gets 403.""" response = await client.get("/api/v1/admin/plan-limits", headers=auth_headers) assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_admin_plan_limits_get_includes_plan_billing_fields_when_present( + self, client: AsyncClient, admin_auth_headers: dict, test_db + ): + """GET /admin/plan-limits returns plan_billing fields when a row exists, + and None for plans that don't have one yet.""" + # Seed a plan_billing row for "pro". + existing = (await test_db.execute( + select(PlanBilling).where(PlanBilling.plan == "pro") + )).scalar_one_or_none() + if existing is None: + test_db.add(PlanBilling( + plan="pro", + display_name="Pro", + description="For working teams", + monthly_price_cents=4900, + annual_price_cents=49000, + stripe_product_id="prod_seed", + stripe_monthly_price_id="price_seed_m", + stripe_annual_price_id="price_seed_a", + is_public=True, + is_archived=False, + sort_order=10, + )) + await test_db.commit() + + response = await client.get( + "/api/v1/admin/plan-limits", headers=admin_auth_headers + ) + assert response.status_code == 200 + plans_by_name = {p["plan"]: p for p in response.json()} + + assert "pro" in plans_by_name + pro = plans_by_name["pro"] + assert pro["display_name"] == "Pro" + assert pro["monthly_price_cents"] == 4900 + assert pro["stripe_monthly_price_id"] == "price_seed_m" + assert pro["is_public"] is True + assert pro["is_archived"] is False + assert pro["sort_order"] == 10 + + # A plan without a plan_billing row should still return, with None + # billing fields. + if "free" in plans_by_name: + free = plans_by_name["free"] + # free has no plan_billing row in the seed → fields are None. + no_billing_row = (await test_db.execute( + select(PlanBilling).where(PlanBilling.plan == "free") + )).scalar_one_or_none() is None + if no_billing_row: + assert free["display_name"] is None + assert free["monthly_price_cents"] is None + assert free["stripe_product_id"] is None + + @pytest.mark.asyncio + async def test_admin_plan_limits_put_creates_plan_billing_row( + self, client: AsyncClient, admin_auth_headers: dict, test_db + ): + """PUT /admin/plan-limits upserts a plan_billing row when billing + fields are included in the body.""" + # Ensure no plan_billing row exists for "team" yet. + existing = (await test_db.execute( + select(PlanBilling).where(PlanBilling.plan == "team") + )).scalar_one_or_none() + if existing is not None: + await test_db.delete(existing) + await test_db.commit() + + response = await client.put( + "/api/v1/admin/plan-limits", + json={ + "plan": "team", + "max_trees": None, + "max_sessions_per_month": None, + "max_users": None, + "custom_branding": True, + "priority_support": True, + "export_formats": ["markdown", "text", "pdf"], + "display_name": "Team", + "description": "For growing shops", + "monthly_price_cents": 9900, + "annual_price_cents": 99000, + "stripe_product_id": "prod_team_test", + "stripe_monthly_price_id": "price_team_m", + "stripe_annual_price_id": "price_team_a", + "is_public": True, + "is_archived": False, + "sort_order": 20, + }, + headers=admin_auth_headers, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["display_name"] == "Team" + assert body["monthly_price_cents"] == 9900 + assert body["stripe_product_id"] == "prod_team_test" + assert body["sort_order"] == 20 + + # Confirm the row was actually persisted. + await test_db.commit() # ensure session sees other-session writes + pb = (await test_db.execute( + select(PlanBilling).where(PlanBilling.plan == "team") + )).scalar_one_or_none() + assert pb is not None + assert pb.display_name == "Team" + assert pb.monthly_price_cents == 9900 + assert pb.stripe_monthly_price_id == "price_team_m" + assert pb.is_public is True + + @pytest.mark.asyncio + async def test_admin_plan_limits_put_does_not_null_out_required_fields( + self, client: AsyncClient, admin_auth_headers: dict, test_db + ): + """PUT /admin/plan-limits must not NULL out NOT NULL columns on the + plan_billing row when the caller passes explicit nulls. The set of + guarded fields is {display_name, is_public, is_archived, sort_order}. + """ + # Seed a plan_billing row for "team" with non-default values for every + # NOT NULL field so we can detect any clobbering. + existing = (await test_db.execute( + select(PlanBilling).where(PlanBilling.plan == "team") + )).scalar_one_or_none() + if existing is not None: + await test_db.delete(existing) + await test_db.commit() + + seeded = PlanBilling( + plan="team", + display_name="Team Seeded", + is_public=False, + is_archived=True, + sort_order=5, + ) + test_db.add(seeded) + await test_db.commit() + + response = await client.put( + "/api/v1/admin/plan-limits", + json={ + "plan": "team", + "max_trees": None, + "max_sessions_per_month": None, + "max_users": None, + "custom_branding": True, + "priority_support": True, + "export_formats": ["markdown", "text"], + # Explicit nulls for every NOT NULL plan_billing field. + "display_name": None, + "is_public": None, + "is_archived": None, + "sort_order": None, + }, + headers=admin_auth_headers, + ) + assert response.status_code == 200, response.text + + # Confirm the seeded NOT NULL values were preserved. + await test_db.commit() # ensure session sees writes from the request + pb = (await test_db.execute( + select(PlanBilling).where(PlanBilling.plan == "team") + )).scalar_one_or_none() + assert pb is not None + assert pb.display_name == "Team Seeded" + assert pb.is_public is False + assert pb.is_archived is True + assert pb.sort_order == 5 + + @pytest.mark.asyncio + async def test_admin_plan_limits_put_invalidates_billing_cache( + self, client: AsyncClient, admin_auth_headers: dict + ): + """PUT /admin/plan-limits calls BillingService.invalidate_billing_cache + with the account_ids on the affected plan.""" + # Patch the staticmethod on the class. The endpoint imports + # BillingService at module load, so patch the symbol on the class + # itself — both the import and the dotted reference resolve to it. + with patch( + "app.api.endpoints.admin_plan_limits.BillingService.invalidate_billing_cache", + new_callable=AsyncMock, + ) as spy: + response = await client.put( + "/api/v1/admin/plan-limits", + json={ + "plan": "pro", + "max_trees": 25, + "max_sessions_per_month": 500, + "max_users": 10, + "custom_branding": True, + "priority_support": True, + "export_formats": ["markdown", "text"], + }, + headers=admin_auth_headers, + ) + assert response.status_code == 200, response.text + spy.assert_awaited_once() + (account_ids_arg,) = spy.await_args.args + # admin fixture seeds an active Pro Subscription, so we expect at + # least one account_id in the invalidation list. + assert isinstance(account_ids_arg, list) + assert len(account_ids_arg) >= 1 -- 2.49.1 From 80baf89b003eef4a34f8fc99a611fda9a787431e Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 20:38:50 -0400 Subject: [PATCH 05/29] feat(config): add SELF_SERVE_ENABLED flag + GET /config/public Phase 2 Task 31. Single flag now controls whether the public-facing self-serve flow is exposed. - New public endpoint GET /api/v1/config/public returns {self_serve_enabled, oauth_providers}. oauth_providers includes "google" if GOOGLE_CLIENT_ID is set and "microsoft" if MS_CLIENT_ID is set. No auth required; consumed once by the frontend at load. - POST /auth/register: when SELF_SERVE_ENABLED=true the platform invite-code requirement is bypassed even with REQUIRE_INVITE_CODE=true. invite_code stays in the schema for backward compat and still applies when supplied. With the flag off, the gate behaves exactly as before. - Adds backend/app/schemas/config.py with PublicConfigResponse and registers the new router in the public/unauthenticated section. - Adds 3 integration tests in tests/test_config_public.py covering the flag round-trip, the regression case (flag off keeps the 400), and the new behavior (flag on bypasses the gate, creates user + Pro trial). Co-Authored-By: Claude Opus 4.7 --- backend/app/api/endpoints/auth.py | 10 ++- backend/app/api/endpoints/config.py | 40 +++++++++++ backend/app/api/router.py | 2 + backend/app/schemas/config.py | 18 +++++ backend/tests/test_config_public.py | 100 ++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/endpoints/config.py create mode 100644 backend/app/schemas/config.py create mode 100644 backend/tests/test_config_public.py diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 44507328..7f5e017f 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -136,7 +136,15 @@ async def register( # Validate platform invite code (skip if account invite was provided) invite_code_record = None if not account_invite_record: - if settings.REQUIRE_INVITE_CODE and not user_data.invite_code: + # When SELF_SERVE_ENABLED is on, the platform invite gate is bypassed + # entirely — public self-serve signup is the whole point. The + # invite_code field stays in the schema for backward compatibility + # and so paid/trial-bearing codes still apply when supplied. + if ( + settings.REQUIRE_INVITE_CODE + and not settings.SELF_SERVE_ENABLED + and not user_data.invite_code + ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required" diff --git a/backend/app/api/endpoints/config.py b/backend/app/api/endpoints/config.py new file mode 100644 index 00000000..a621e738 --- /dev/null +++ b/backend/app/api/endpoints/config.py @@ -0,0 +1,40 @@ +"""Public runtime configuration endpoint. + +GET /api/v1/config/public + Returns the small set of runtime flags the frontend needs at app load + to decide whether to render the self-serve signup flow and which OAuth + buttons to show. No authentication required. + +The response model lives in `app.schemas.config` so it can be reused by +frontend codegen and other call sites if needed. +""" + +from __future__ import annotations + +from fastapi import APIRouter + +from app.core.config import settings +from app.schemas.config import PublicConfigResponse + +router = APIRouter(prefix="/config", tags=["config"]) + + +@router.get("/public", response_model=PublicConfigResponse) +async def get_public_config() -> PublicConfigResponse: + """Return public-safe runtime config. + + `oauth_providers` reflects which OAuth client IDs are configured server + side; the frontend uses it to render only buttons that will actually + succeed. `self_serve_enabled` is the master switch for the new public + self-serve signup flow. + """ + providers: list[str] = [] + if settings.GOOGLE_CLIENT_ID: + providers.append("google") + if settings.MS_CLIENT_ID: + providers.append("microsoft") + + return PublicConfigResponse( + self_serve_enabled=settings.SELF_SERVE_ENABLED, + oauth_providers=providers, + ) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 5ef8122c..155fa304 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -29,6 +29,7 @@ from app.api.endpoints import ( sales_leads, branding, categories, + config as config_endpoints, copilot, device_types, draft_templates, @@ -93,6 +94,7 @@ api_router.include_router(sales_leads.router) # Talk-to-Sales (no auth, rate-li 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 # --------------------------------------------------------------------------- # Admin endpoints — super_admin only diff --git a/backend/app/schemas/config.py b/backend/app/schemas/config.py new file mode 100644 index 00000000..c9937d8a --- /dev/null +++ b/backend/app/schemas/config.py @@ -0,0 +1,18 @@ +"""Pydantic schemas for public runtime configuration.""" + +from __future__ import annotations + +from typing import List + +from pydantic import BaseModel + + +class PublicConfigResponse(BaseModel): + """Runtime feature flags + OAuth provider list exposed to anonymous clients. + + Read once by the frontend at app load to decide whether to render the + self-serve signup flow and which OAuth buttons to show. + """ + + self_serve_enabled: bool + oauth_providers: List[str] diff --git a/backend/tests/test_config_public.py b/backend/tests/test_config_public.py new file mode 100644 index 00000000..c68738a3 --- /dev/null +++ b/backend/tests/test_config_public.py @@ -0,0 +1,100 @@ +"""Integration tests for the public runtime config endpoint. + +Covers GET /api/v1/config/public and the SELF_SERVE_ENABLED interaction +with the existing /auth/register invite-code gate. +""" + +from __future__ import annotations + +import pytest +from httpx import AsyncClient + +from app.core.config import settings + + +class TestConfigPublic: + """GET /api/v1/config/public — anonymous, no auth.""" + + @pytest.mark.asyncio + async def test_get_config_public_returns_self_serve_flag( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + """Endpoint reflects the current SELF_SERVE_ENABLED setting and the + configured OAuth providers, with no auth required.""" + # Default-off: SELF_SERVE_ENABLED is False unless explicitly set. + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None) + monkeypatch.setattr(settings, "MS_CLIENT_ID", None) + + response = await client.get("/api/v1/config/public") + assert response.status_code == 200 + body = response.json() + assert body == {"self_serve_enabled": False, "oauth_providers": []} + + # Flip it on, with both OAuth providers configured. + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", True) + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "google-test-id") + monkeypatch.setattr(settings, "MS_CLIENT_ID", "ms-test-id") + + response = await client.get("/api/v1/config/public") + assert response.status_code == 200 + body = response.json() + assert body["self_serve_enabled"] is True + assert body["oauth_providers"] == ["google", "microsoft"] + + # Only Microsoft configured. + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None) + monkeypatch.setattr(settings, "MS_CLIENT_ID", "ms-test-id") + response = await client.get("/api/v1/config/public") + assert response.status_code == 200 + assert response.json()["oauth_providers"] == ["microsoft"] + + +class TestRegisterInviteCodeGate: + """Regression + new-behavior tests for /auth/register vs SELF_SERVE_ENABLED.""" + + @pytest.mark.asyncio + async def test_register_invite_code_required_when_self_serve_disabled( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + """Pre-self-serve behavior: REQUIRE_INVITE_CODE=True without an + invite code (and no account-invite) must still 400.""" + monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True) + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + + response = await client.post( + "/api/v1/auth/register", + json={ + "email": "no-invite@example.com", + "password": "SecurePass123!", + "name": "No Invite", + }, + ) + + assert response.status_code == 400 + assert "invite code is required" in response.json()["detail"].lower() + + @pytest.mark.asyncio + async def test_register_invite_code_optional_when_self_serve_enabled( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + """Self-serve on: registration succeeds with no invite code even + when REQUIRE_INVITE_CODE is True. The user, personal account, and + a Pro trial subscription are all created.""" + monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True) + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", True) + + response = await client.post( + "/api/v1/auth/register", + json={ + "email": "self-serve@example.com", + "password": "SecurePass123!", + "name": "Self Serve", + }, + ) + + assert response.status_code == 201, response.text + body = response.json() + assert body["email"] == "self-serve@example.com" + assert body["account_role"] == "owner" + assert "account_id" in body -- 2.49.1 From 7a9cb4b03b4dd11c0cdd55d28a2460e9853a8935 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 20:44:20 -0400 Subject: [PATCH 06/29] feat(billing): add useBillingStore and /billing/state integration T32: Single frontend source of truth for subscription / plan / feature state. New Zustand `useBillingStore` fetches `/billing/state` (auto-fetch on login via authStore, reset on logout), exposes `refetch` for post-Checkout refresh, and is supported by a `useBillingPoll` hook that re-fetches every 60s while authenticated. The new `billingApi` client transforms the snake_case backend payload to camelCase at a single boundary so the rest of the frontend never sees `plan_billing` or `enabled_features`. Co-Authored-By: Claude Opus 4.7 --- frontend/src/api/billing.ts | 27 +++++ frontend/src/api/index.ts | 1 + frontend/src/components/layout/AppLayout.tsx | 4 + frontend/src/hooks/useBillingPoll.ts | 32 +++++ frontend/src/store/authStore.ts | 7 ++ frontend/src/store/billingStore.test.ts | 118 +++++++++++++++++++ frontend/src/store/billingStore.ts | 82 +++++++++++++ frontend/src/types/billing.ts | 51 ++++++++ frontend/src/types/index.ts | 8 ++ 9 files changed, 330 insertions(+) create mode 100644 frontend/src/api/billing.ts create mode 100644 frontend/src/hooks/useBillingPoll.ts create mode 100644 frontend/src/store/billingStore.test.ts create mode 100644 frontend/src/store/billingStore.ts create mode 100644 frontend/src/types/billing.ts diff --git a/frontend/src/api/billing.ts b/frontend/src/api/billing.ts new file mode 100644 index 00000000..2ba56173 --- /dev/null +++ b/frontend/src/api/billing.ts @@ -0,0 +1,27 @@ +import apiClient from './client' +import type { BillingStateApiResponse, BillingStatePayload } from '@/types' + +/** + * Single boundary where the snake_case backend payload is transformed + * into the camelCase shape used by the rest of the frontend. + * + * Keeping the transform here means the store, hooks, and components + * never see snake_case keys. + */ +function transformBillingState(raw: BillingStateApiResponse): BillingStatePayload { + return { + subscription: raw.subscription ?? null, + planBilling: raw.plan_billing ?? null, + planLimits: raw.plan_limits ?? {}, + enabledFeatures: raw.enabled_features ?? {}, + } +} + +export const billingApi = { + async getState(): Promise { + const response = await apiClient.get('/billing/state') + return transformBillingState(response.data) + }, +} + +export default billingApi diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 50440df8..5084a2aa 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -9,6 +9,7 @@ export { default as foldersApi } from './folders' export { default as stepsApi } from './steps' export { default as stepCategoriesApi } from './stepCategories' export { default as accountsApi } from './accounts' +export { default as billingApi } from './billing' export { default as adminApi } from './admin' export { treeMarkdownApi } from './treeMarkdown' export { default as analyticsApi } from './analytics' diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 4a4b08a6..ab4722d2 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -4,6 +4,7 @@ import { Menu, X, LayoutGrid, Clock, AlertTriangle, GitBranch, Wand2, BarChart3, import { useAuthStore } from '@/store/authStore' import { usePermissions } from '@/hooks/usePermissions' import { useUserPreferencesStore } from '@/store/userPreferencesStore' +import { useBillingPoll } from '@/hooks/useBillingPoll' import { BrandLogo } from '@/components/common/BrandLogo' import { TopBar } from './TopBar' import { Sidebar } from './Sidebar' @@ -13,6 +14,9 @@ import { FeedbackWidget } from '@/components/common/FeedbackWidget' import { cn } from '@/lib/utils' export function AppLayout() { + // Poll /billing/state every 60s while authenticated. Hook no-ops when logged out. + useBillingPoll() + const location = useLocation() const navigate = useNavigate() const { user, logout } = useAuthStore() diff --git a/frontend/src/hooks/useBillingPoll.ts b/frontend/src/hooks/useBillingPoll.ts new file mode 100644 index 00000000..cfc397fd --- /dev/null +++ b/frontend/src/hooks/useBillingPoll.ts @@ -0,0 +1,32 @@ +import { useEffect } from 'react' +import { useAuthStore } from '@/store/authStore' +import { useBillingStore } from '@/store/billingStore' + +const POLL_INTERVAL_MS = 60_000 + +/** + * Re-fetches billing state every 60s while a user is logged in. + * + * Mount once at the top of the authenticated dashboard tree. Polling + * automatically pauses when the auth store reports no logged-in user. + * + * Note: this is a v1 simple-interval implementation; a later task may + * swap to SSE / visibility-aware polling. + */ +export function useBillingPoll(): void { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + + useEffect(() => { + if (!isAuthenticated) return + + const id = window.setInterval(() => { + void useBillingStore.getState().refetch() + }, POLL_INTERVAL_MS) + + return () => { + window.clearInterval(id) + } + }, [isAuthenticated]) +} + +export default useBillingPoll diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 3465b626..68e40459 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -6,6 +6,7 @@ import { authApi } from '@/api/auth' import { identifyUser, resetAnalytics, analytics } from '@/lib/analytics' import { apiClient } from '@/api/client' import { clearCachedQuota } from '@/hooks/useCachedQuota' +import { useBillingStore } from '@/store/billingStore' interface AuthState { user: User | null @@ -85,6 +86,7 @@ export const useAuthStore = create()( localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') clearCachedQuota() + useBillingStore.getState().reset() Sentry.setUser(null) resetAnalytics() set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null }) @@ -117,6 +119,11 @@ export const useAuthStore = create()( identifyUser({ id: user.id, email: user.email, role: user.role, is_super_admin: user.is_super_admin, account_id: account?.id }) set({ user, account, subscription, isLoading: false }) + + // Kick off billing-state fetch alongside auth — fire-and-forget so + // a billing error never breaks login. The billing store records + // its own error state. + void useBillingStore.getState().fetch() } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Failed to fetch user' set({ error: message, isLoading: false }) diff --git a/frontend/src/store/billingStore.test.ts b/frontend/src/store/billingStore.test.ts new file mode 100644 index 00000000..e3977b70 --- /dev/null +++ b/frontend/src/store/billingStore.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useBillingStore } from './billingStore' +import { billingApi } from '@/api/billing' +import type { BillingStatePayload } from '@/types' + +vi.mock('@/api/billing', () => ({ + billingApi: { + getState: vi.fn(), + }, + default: { + getState: vi.fn(), + }, +})) + +const mockGetState = billingApi.getState as ReturnType + +const INITIAL_PAYLOAD: BillingStatePayload = { + subscription: { + status: 'trialing', + plan: 'pro', + current_period_start: '2026-05-01T00:00:00Z', + current_period_end: '2026-05-15T00:00:00Z', + cancel_at_period_end: false, + seat_limit: 5, + has_pro_entitlement: true, + is_paid: false, + }, + planBilling: { + display_name: 'Pro', + description: 'Pro plan', + monthly_price_cents: 4900, + annual_price_cents: 49000, + }, + planLimits: { seats: 5 }, + enabledFeatures: { ai_assistant: true }, +} + +describe('useBillingStore', () => { + beforeEach(() => { + vi.clearAllMocks() + // Reset store to empty initial state. + useBillingStore.setState({ + subscription: null, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) + }) + + it('useBillingStore fetches on login and populates subscription', async () => { + mockGetState.mockResolvedValueOnce(INITIAL_PAYLOAD) + + // Sanity: starts empty. + expect(useBillingStore.getState().subscription).toBeNull() + + await useBillingStore.getState().fetch() + + const state = useBillingStore.getState() + expect(mockGetState).toHaveBeenCalledOnce() + expect(state.subscription).toEqual(INITIAL_PAYLOAD.subscription) + expect(state.planBilling).toEqual(INITIAL_PAYLOAD.planBilling) + expect(state.planLimits).toEqual(INITIAL_PAYLOAD.planLimits) + expect(state.enabledFeatures).toEqual(INITIAL_PAYLOAD.enabledFeatures) + expect(state.isLoading).toBe(false) + expect(state.error).toBeNull() + }) + + it('useBillingStore resets on logout', async () => { + mockGetState.mockResolvedValueOnce(INITIAL_PAYLOAD) + await useBillingStore.getState().fetch() + expect(useBillingStore.getState().subscription).not.toBeNull() + + useBillingStore.getState().reset() + + const state = useBillingStore.getState() + expect(state.subscription).toBeNull() + expect(state.planBilling).toBeNull() + expect(state.planLimits).toEqual({}) + expect(state.enabledFeatures).toEqual({}) + expect(state.isLoading).toBe(false) + expect(state.error).toBeNull() + }) + + it('useBillingStore refetch overwrites stale data', async () => { + mockGetState.mockResolvedValueOnce(INITIAL_PAYLOAD) + await useBillingStore.getState().fetch() + expect(useBillingStore.getState().subscription?.status).toBe('trialing') + + const updatedPayload: BillingStatePayload = { + ...INITIAL_PAYLOAD, + subscription: { + ...INITIAL_PAYLOAD.subscription!, + status: 'active', + is_paid: true, + }, + enabledFeatures: { ai_assistant: true, advanced_reports: true }, + } + // Hold the refetch promise open so we can observe mid-flight isLoading=true. + let resolveSecond: (value: BillingStatePayload) => void = () => {} + mockGetState.mockImplementationOnce( + () => new Promise((resolve) => { resolveSecond = resolve }) + ) + + const refetchPromise = useBillingStore.getState().refetch() + expect(useBillingStore.getState().isLoading).toBe(true) + resolveSecond(updatedPayload) + await refetchPromise + + const state = useBillingStore.getState() + expect(mockGetState).toHaveBeenCalledTimes(2) + expect(state.subscription?.status).toBe('active') + expect(state.subscription?.is_paid).toBe(true) + expect(state.enabledFeatures).toEqual({ ai_assistant: true, advanced_reports: true }) + expect(state.isLoading).toBe(false) + }) +}) diff --git a/frontend/src/store/billingStore.ts b/frontend/src/store/billingStore.ts new file mode 100644 index 00000000..594a7a9e --- /dev/null +++ b/frontend/src/store/billingStore.ts @@ -0,0 +1,82 @@ +import { create } from 'zustand' +import { billingApi } from '@/api/billing' +import type { + BillingSubscriptionState, + PlanBillingState, +} from '@/types' + +interface BillingState { + subscription: BillingSubscriptionState | null + planBilling: PlanBillingState | null + planLimits: Record + enabledFeatures: Record + isLoading: boolean + error: string | null +} + +interface BillingActions { + /** Fetch billing state. Sets `isLoading` while in flight. */ + fetch: () => Promise + /** Same as `fetch` but intended for explicit refresh after Stripe Checkout. */ + refetch: () => Promise + /** Reset to empty initial state — call on logout. */ + reset: () => void +} + +export type BillingStore = BillingState & BillingActions + +const INITIAL_STATE: BillingState = { + subscription: null, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, +} + +export const useBillingStore = create((set) => ({ + ...INITIAL_STATE, + + fetch: async () => { + set({ isLoading: true, error: null }) + try { + const data = await billingApi.getState() + set({ + subscription: data.subscription, + planBilling: data.planBilling, + planLimits: data.planLimits, + enabledFeatures: data.enabledFeatures, + isLoading: false, + error: null, + }) + } catch (error: unknown) { + // 401s are handled globally by the apiClient response interceptor + // (token-refresh + logout), so we just record any other error here. + const message = error instanceof Error ? error.message : 'Failed to load billing state' + set({ isLoading: false, error: message }) + } + }, + + refetch: async () => { + // Same semantics as fetch — separate name documents intent at the call site. + set({ isLoading: true, error: null }) + try { + const data = await billingApi.getState() + set({ + subscription: data.subscription, + planBilling: data.planBilling, + planLimits: data.planLimits, + enabledFeatures: data.enabledFeatures, + isLoading: false, + error: null, + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to load billing state' + set({ isLoading: false, error: message }) + } + }, + + reset: () => set({ ...INITIAL_STATE }), +})) + +export default useBillingStore diff --git a/frontend/src/types/billing.ts b/frontend/src/types/billing.ts new file mode 100644 index 00000000..f0654038 --- /dev/null +++ b/frontend/src/types/billing.ts @@ -0,0 +1,51 @@ +/** + * Billing state types for the unified `/billing/state` endpoint. + * + * The backend returns snake_case keys (`plan_billing`, `enabled_features`); + * the API client (`frontend/src/api/billing.ts`) transforms the payload to + * camelCase before it reaches the rest of the frontend. + */ + +export type SubscriptionStatus = + | 'trialing' + | 'active' + | 'past_due' + | 'canceled' + | 'incomplete' + | 'complimentary' + +export interface SubscriptionState { + status: SubscriptionStatus + plan: string + /** ISO 8601 string or null */ + current_period_start: string | null + /** ISO 8601 string or null */ + current_period_end: string | null + cancel_at_period_end: boolean + seat_limit: number | null + has_pro_entitlement: boolean + is_paid: boolean +} + +export interface PlanBillingState { + display_name: string + description: string | null + monthly_price_cents: number | null + annual_price_cents: number | null +} + +/** Camel-cased billing-state payload, post-transform. */ +export interface BillingStatePayload { + subscription: SubscriptionState | null + planBilling: PlanBillingState | null + planLimits: Record + enabledFeatures: Record +} + +/** Raw snake_case payload returned by the backend. */ +export interface BillingStateApiResponse { + subscription: SubscriptionState | null + plan_billing: PlanBillingState | null + plan_limits: Record + enabled_features: Record +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bfd9e759..5dc98ebb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -93,6 +93,14 @@ export type { KBQuotaResponse, } from './kbAccelerator' +export type { + SubscriptionStatus, + SubscriptionState as BillingSubscriptionState, + PlanBillingState, + BillingStatePayload, + BillingStateApiResponse, +} from './billing' + export * from './scripts' export * from './script-builder' export * from './integrations' -- 2.49.1 From 0b5ed9aa104eda09148fc161a99544cd0b7aaf2d Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 20:52:18 -0400 Subject: [PATCH 07/29] feat(billing): add useFeature, useFeatureLimit, useTrialBanner hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 Task 33. Components can now ask "is this feature on?", "how many sessions left?", and "what stage is the trial in?" without re-implementing the read against useBillingStore. - useFeature(flagKey): boolean — reads enabledFeatures from store - useFeatureLimit(field): { used, limit, percentage, isAtLimit, isLoading } with non-blocking 60s module-level cache and graceful 404 degradation - useTrialBanner(): derives stage from subscription status + trial countdown, returns null on initial load to prevent flicker - usageApi.getCount(field) — calls /api/v1/usage/{field}; backend endpoint is not yet implemented (planned), so the hook degrades to used=0 Co-Authored-By: Claude Opus 4.7 --- frontend/src/api/index.ts | 1 + frontend/src/api/usage.ts | 23 ++++ frontend/src/hooks/useFeature.test.ts | 44 +++++++ frontend/src/hooks/useFeature.ts | 16 +++ frontend/src/hooks/useFeatureLimit.test.ts | 112 ++++++++++++++++++ frontend/src/hooks/useFeatureLimit.ts | 125 ++++++++++++++++++++ frontend/src/hooks/useTrialBanner.test.ts | 131 +++++++++++++++++++++ frontend/src/hooks/useTrialBanner.ts | 86 ++++++++++++++ 8 files changed, 538 insertions(+) create mode 100644 frontend/src/api/usage.ts create mode 100644 frontend/src/hooks/useFeature.test.ts create mode 100644 frontend/src/hooks/useFeature.ts create mode 100644 frontend/src/hooks/useFeatureLimit.test.ts create mode 100644 frontend/src/hooks/useFeatureLimit.ts create mode 100644 frontend/src/hooks/useTrialBanner.test.ts create mode 100644 frontend/src/hooks/useTrialBanner.ts diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 5084a2aa..1d0ca2f4 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -10,6 +10,7 @@ export { default as stepsApi } from './steps' export { default as stepCategoriesApi } from './stepCategories' export { default as accountsApi } from './accounts' export { default as billingApi } from './billing' +export { default as usageApi } from './usage' export { default as adminApi } from './admin' export { treeMarkdownApi } from './treeMarkdown' export { default as analyticsApi } from './analytics' diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts new file mode 100644 index 00000000..f08f7f44 --- /dev/null +++ b/frontend/src/api/usage.ts @@ -0,0 +1,23 @@ +import apiClient from './client' + +/** + * Usage counters API. + * + * TODO: backend `/usage/{field}` endpoint not yet implemented (planned). + * Tracked under self-serve signup Phase 2 — Task 33 calls this lazily; today + * it 404s and the consuming hook (`useFeatureLimit`) cleanly degrades to + * `used = 0`. + */ +export const usageApi = { + /** + * Fetch the current count for a usage field (e.g. `active_users`, + * `flowpilot_sessions_this_month`). The field name is the same key used in + * `BillingState.planLimits`. + */ + async getCount(field: string): Promise<{ used: number }> { + const response = await apiClient.get<{ used: number }>(`/usage/${field}`) + return response.data + }, +} + +export default usageApi diff --git a/frontend/src/hooks/useFeature.test.ts b/frontend/src/hooks/useFeature.test.ts new file mode 100644 index 00000000..38f35d29 --- /dev/null +++ b/frontend/src/hooks/useFeature.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useFeature } from './useFeature' +import { useBillingStore } from '@/store/billingStore' + +describe('useFeature', () => { + beforeEach(() => { + useBillingStore.setState({ + subscription: null, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) + }) + + it('returns false when flag absent', () => { + const { result } = renderHook(() => useFeature('does_not_exist')) + expect(result.current).toBe(false) + }) + + it('returns true when flag is enabled', () => { + useBillingStore.setState({ enabledFeatures: { ai_builder: true } }) + const { result } = renderHook(() => useFeature('ai_builder')) + expect(result.current).toBe(true) + }) + + it('returns false when flag is explicitly disabled', () => { + useBillingStore.setState({ enabledFeatures: { ai_builder: false } }) + const { result } = renderHook(() => useFeature('ai_builder')) + expect(result.current).toBe(false) + }) + + it('updates when store changes (subscribes to store)', () => { + const { result } = renderHook(() => useFeature('foo')) + expect(result.current).toBe(false) + + act(() => { + useBillingStore.setState({ enabledFeatures: { foo: true } }) + }) + expect(result.current).toBe(true) + }) +}) diff --git a/frontend/src/hooks/useFeature.ts b/frontend/src/hooks/useFeature.ts new file mode 100644 index 00000000..12f971d7 --- /dev/null +++ b/frontend/src/hooks/useFeature.ts @@ -0,0 +1,16 @@ +import { useBillingStore } from '@/store/billingStore' + +/** + * Returns whether a feature flag is enabled for the current account. + * + * Reads from `useBillingStore.enabledFeatures`, which is populated by + * `GET /billing/state`. Returns `false` when the flag is absent (closed-by-default). + * + * The hook subscribes to the store so updates from `refetch()` propagate + * without manual refetch in the component. + */ +export function useFeature(flagKey: string): boolean { + return useBillingStore((state) => Boolean(state.enabledFeatures[flagKey])) +} + +export default useFeature diff --git a/frontend/src/hooks/useFeatureLimit.test.ts b/frontend/src/hooks/useFeatureLimit.test.ts new file mode 100644 index 00000000..8561463d --- /dev/null +++ b/frontend/src/hooks/useFeatureLimit.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { useFeatureLimit, clearUsageCache } from './useFeatureLimit' +import { useBillingStore } from '@/store/billingStore' + +vi.mock('@/api/usage', () => ({ + usageApi: { + getCount: vi.fn(), + }, +})) + +import { usageApi } from '@/api/usage' + +const mockedGetCount = vi.mocked(usageApi.getCount) + +describe('useFeatureLimit', () => { + beforeEach(() => { + clearUsageCache() + mockedGetCount.mockReset() + useBillingStore.setState({ + subscription: null, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) + }) + + it('transitions isLoading -> loaded', async () => { + useBillingStore.setState({ planLimits: { active_users: 10 } }) + mockedGetCount.mockResolvedValueOnce({ used: 4 }) + + const { result } = renderHook(() => useFeatureLimit('active_users')) + + // Non-blocking initial state. + expect(result.current.isLoading).toBe(true) + expect(result.current.used).toBe(0) + expect(result.current.limit).toBe(10) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(result.current.used).toBe(4) + expect(result.current.limit).toBe(10) + expect(result.current.percentage).toBe(40) + expect(result.current.isAtLimit).toBe(false) + }) + + it('flags isAtLimit when used >= limit', async () => { + useBillingStore.setState({ planLimits: { seats: 3 } }) + mockedGetCount.mockResolvedValueOnce({ used: 3 }) + + const { result } = renderHook(() => useFeatureLimit('seats')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.isAtLimit).toBe(true) + expect(result.current.percentage).toBe(100) + }) + + it('returns null percentage when limit is null (unlimited)', async () => { + useBillingStore.setState({ planLimits: { sessions: null } }) + mockedGetCount.mockResolvedValueOnce({ used: 7 }) + + const { result } = renderHook(() => useFeatureLimit('sessions')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.limit).toBe(null) + expect(result.current.percentage).toBe(null) + expect(result.current.isAtLimit).toBe(false) + }) + + it('resets isLoading=true synchronously when `field` prop changes', async () => { + useBillingStore.setState({ planLimits: { max_trees: 5, max_users: 10 } }) + mockedGetCount.mockResolvedValueOnce({ used: 2 }) // for max_trees + mockedGetCount.mockResolvedValueOnce({ used: 3 }) // for max_users (slow) + + const { result, rerender } = renderHook( + ({ field }: { field: string }) => useFeatureLimit(field), + { initialProps: { field: 'max_trees' } }, + ) + + // First field resolves. + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.used).toBe(2) + expect(result.current.limit).toBe(5) + + // Switch field. Next render must report isLoading=true (no stale data + // bleed-through) before the new fetch resolves. + rerender({ field: 'max_users' }) + expect(result.current.isLoading).toBe(true) + expect(result.current.used).toBe(0) + expect(result.current.limit).toBe(10) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.used).toBe(3) + expect(result.current.limit).toBe(10) + }) + + it('degrades to used=0 on fetch error (404 from missing endpoint)', async () => { + useBillingStore.setState({ planLimits: { active_users: 5 } }) + mockedGetCount.mockRejectedValueOnce(new Error('Request failed with status 404')) + + const { result } = renderHook(() => useFeatureLimit('active_users')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.used).toBe(0) + expect(result.current.limit).toBe(5) + expect(result.current.percentage).toBe(0) + }) +}) diff --git a/frontend/src/hooks/useFeatureLimit.ts b/frontend/src/hooks/useFeatureLimit.ts new file mode 100644 index 00000000..4d6b05a9 --- /dev/null +++ b/frontend/src/hooks/useFeatureLimit.ts @@ -0,0 +1,125 @@ +import { useEffect, useRef, useState } from 'react' +import { useBillingStore } from '@/store/billingStore' +import { usageApi } from '@/api/usage' + +const CACHE_TTL_MS = 60 * 1000 + +interface CacheEntry { + used: number + timestamp: number +} + +const cache = new Map() + +/** Clear the usage cache (call on logout to prevent stale data across users). */ +export function clearUsageCache() { + cache.clear() +} + +export interface FeatureLimitResult { + used: number + limit: number | null + /** null when limit is null (unlimited) or unknown */ + percentage: number | null + isAtLimit: boolean + isLoading: boolean +} + +function coerceLimit(raw: unknown): number | null { + if (typeof raw === 'number' && Number.isFinite(raw)) return raw + if (raw === null || raw === undefined) return null + // The store types planLimits as Record; the backend + // currently returns numbers, but defensively handle string ints too. + if (typeof raw === 'string') { + const n = Number(raw) + return Number.isFinite(n) ? n : null + } + return null +} + +/** + * Returns progress against a quantitative plan limit. + * + * `limit` comes from `useBillingStore.planLimits[field]`, which is read + * synchronously from the store. `used` is fetched lazily from + * `GET /api/v1/usage/{field}` on mount and cached for 60s in a module-level + * map keyed by field. + * + * Render is non-blocking: the hook returns `isLoading=true` (with `used=0`) + * until the usage fetch resolves. On 404 or any error the hook degrades to + * `used=0` with `isLoading=false` rather than surfacing the error — the + * `/usage/{field}` endpoint is not yet implemented on the backend (planned). + */ +export function useFeatureLimit(field: string): FeatureLimitResult { + const limit = useBillingStore((state) => coerceLimit(state.planLimits[field])) + + // Initialize from cache on first mount only; subsequent `field` changes + // are handled inside the effect below so the render-phase result reflects + // the new field synchronously (no stale `used`/`isLoading` for one tick). + const initialCached = useRef(undefined) + if (initialCached.current === undefined) { + initialCached.current = cache.get(field) + } + const initialFresh = + initialCached.current && Date.now() - initialCached.current.timestamp < CACHE_TTL_MS + const [used, setUsed] = useState(initialFresh ? initialCached.current!.used : 0) + const [isLoading, setIsLoading] = useState(!initialFresh) + + // Track the field that the current `used`/`isLoading` state describes. + // When `field` changes, we synchronously reset state in render so callers + // never see stale data for the previous field. + const stateField = useRef(field) + if (stateField.current !== field) { + stateField.current = field + const existing = cache.get(field) + const freshNow = existing && Date.now() - existing.timestamp < CACHE_TTL_MS + if (freshNow) { + setUsed(existing!.used) + setIsLoading(false) + } else { + setUsed(0) + setIsLoading(true) + } + } + + useEffect(() => { + const existing = cache.get(field) + if (existing && Date.now() - existing.timestamp < CACHE_TTL_MS) { + setUsed(existing.used) + setIsLoading(false) + return + } + + let cancelled = false + setIsLoading(true) + usageApi + .getCount(field) + .then((result) => { + if (cancelled) return + cache.set(field, { used: result.used, timestamp: Date.now() }) + setUsed(result.used) + }) + .catch(() => { + // TODO: backend /usage/{field} endpoint not yet implemented (planned). + // 404s and other errors degrade to used=0 silently — no toast. + if (cancelled) return + setUsed(0) + }) + .finally(() => { + if (cancelled) return + setIsLoading(false) + }) + + return () => { + cancelled = true + } + }, [field]) + + const percentage = + limit === null || limit <= 0 ? null : Math.min(100, Math.round((used / limit) * 100)) + const isAtLimit = limit !== null && used >= limit + + return { used, limit, percentage, isAtLimit, isLoading } +} + +export default useFeatureLimit diff --git a/frontend/src/hooks/useTrialBanner.test.ts b/frontend/src/hooks/useTrialBanner.test.ts new file mode 100644 index 00000000..93cc2843 --- /dev/null +++ b/frontend/src/hooks/useTrialBanner.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useTrialBanner } from './useTrialBanner' +import { useBillingStore } from '@/store/billingStore' +import type { SubscriptionState } from '@/types/billing' + +const FROZEN_NOW = new Date('2026-05-06T00:00:00Z') + +function makeSub(overrides: Partial): SubscriptionState { + return { + status: 'trialing', + plan: 'starter', + current_period_start: '2026-05-01T00:00:00Z', + current_period_end: null, + cancel_at_period_end: false, + seat_limit: null, + has_pro_entitlement: false, + is_paid: false, + ...overrides, + } +} + +function setSubscription(overrides: Partial) { + useBillingStore.setState({ subscription: makeSub(overrides) }) +} + +describe('useTrialBanner', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(FROZEN_NOW) + useBillingStore.setState({ + subscription: null, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('stage matches subscription state matrix', () => { + it('returns null when subscription is null (no flicker on initial load)', () => { + const { result } = renderHook(() => useTrialBanner()) + expect(result.current.stage).toBe(null) + expect(result.current.daysRemaining).toBe(null) + }) + + it('complimentary status -> complimentary stage', () => { + setSubscription({ status: 'complimentary' }) + const { result } = renderHook(() => useTrialBanner()) + expect(result.current.stage).toBe('complimentary') + }) + + it('active status -> paid stage', () => { + setSubscription({ status: 'active' }) + const { result } = renderHook(() => useTrialBanner()) + expect(result.current.stage).toBe('paid') + }) + + it('past_due status -> past_due stage', () => { + setSubscription({ status: 'past_due' }) + const { result } = renderHook(() => useTrialBanner()) + expect(result.current.stage).toBe('past_due') + }) + + it('canceled status -> canceled stage', () => { + setSubscription({ status: 'canceled' }) + const { result } = renderHook(() => useTrialBanner()) + expect(result.current.stage).toBe('canceled') + }) + + it('trialing >3 days remaining -> pristine', () => { + // 7 days from frozen now. + setSubscription({ + status: 'trialing', + current_period_end: '2026-05-13T00:00:00Z', + }) + const { result } = renderHook(() => useTrialBanner()) + expect(result.current.stage).toBe('pristine') + expect(result.current.daysRemaining).toBe(7) + }) + + it('trialing 1-3 days remaining -> warning', () => { + // 2 days from frozen now. + setSubscription({ + status: 'trialing', + current_period_end: '2026-05-08T00:00:00Z', + }) + const { result } = renderHook(() => useTrialBanner()) + expect(result.current.stage).toBe('warning') + expect(result.current.daysRemaining).toBe(2) + }) + + it('trialing exactly 24 hours remaining -> warning (boundary, not urgent)', () => { + // Exactly 1.0 fractional day from frozen now — must sit on the warning + // side per spec (1–3 days inclusive of 1). + setSubscription({ + status: 'trialing', + current_period_end: '2026-05-07T00:00:00Z', + }) + const { result } = renderHook(() => useTrialBanner()) + expect(result.current.stage).toBe('warning') + expect(result.current.daysRemaining).toBe(1) + }) + + it('trialing <1 day remaining -> urgent', () => { + // 12 hours from frozen now -> Math.ceil(0.5) = 1 day. + setSubscription({ + status: 'trialing', + current_period_end: '2026-05-06T12:00:00Z', + }) + const { result } = renderHook(() => useTrialBanner()) + expect(result.current.stage).toBe('urgent') + expect(result.current.daysRemaining).toBe(1) + }) + + it('trialing past period_end -> expired', () => { + setSubscription({ + status: 'trialing', + current_period_end: '2026-05-01T00:00:00Z', + }) + const { result } = renderHook(() => useTrialBanner()) + expect(result.current.stage).toBe('expired') + expect(result.current.daysRemaining).toBe(0) + }) + }) +}) diff --git a/frontend/src/hooks/useTrialBanner.ts b/frontend/src/hooks/useTrialBanner.ts new file mode 100644 index 00000000..cc3ebe47 --- /dev/null +++ b/frontend/src/hooks/useTrialBanner.ts @@ -0,0 +1,86 @@ +import { useBillingStore } from '@/store/billingStore' + +export type TrialBannerStage = + | 'pristine' + | 'warning' + | 'urgent' + | 'expired' + | 'complimentary' + | 'paid' + | 'past_due' + | 'canceled' + +export interface TrialBannerResult { + stage: TrialBannerStage | null + daysRemaining: number | null +} + +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +/** + * Derives the trial-banner display stage from the current subscription. + * + * Returns `{ stage: null, daysRemaining: null }` when subscription data is + * not yet loaded — this prevents the banner flickering on initial render. + * + * Subscribes to `useBillingStore` so updates from `refetch()` after a Stripe + * checkout propagate automatically. + */ +export function useTrialBanner(): TrialBannerResult { + const subscription = useBillingStore((state) => state.subscription) + + if (!subscription) { + return { stage: null, daysRemaining: null } + } + + switch (subscription.status) { + case 'complimentary': + return { stage: 'complimentary', daysRemaining: null } + case 'active': + return { stage: 'paid', daysRemaining: null } + case 'past_due': + return { stage: 'past_due', daysRemaining: null } + case 'canceled': + return { stage: 'canceled', daysRemaining: null } + case 'trialing': { + const end = subscription.current_period_end + ? new Date(subscription.current_period_end).getTime() + : null + if (end === null || Number.isNaN(end)) { + // Trialing without a period end is malformed; treat as expired so the + // upgrade prompt still surfaces rather than silently swallowing it. + return { stage: 'expired', daysRemaining: null } + } + const now = Date.now() + if (end <= now) { + return { stage: 'expired', daysRemaining: 0 } + } + const msRemaining = end - now + // Use fractional days for stage thresholds so exactly 24h remaining + // sits on the warning side (1.0), not urgent. The displayed integer + // countdown still uses Math.ceil so "0.5 days" renders as "1 day". + const fractionalDays = msRemaining / MS_PER_DAY + const daysRemaining = Math.ceil(fractionalDays) + // Spec thresholds: + // >3 days remaining → pristine + // 1–3 days → warning (inclusive of exactly 1) + // <1 day → urgent + let stage: TrialBannerStage = 'pristine' + if (fractionalDays < 1) stage = 'urgent' + else if (fractionalDays <= 3) stage = 'warning' + return { stage, daysRemaining } + } + case 'incomplete': + // Not in the spec's matrix; surface as null so the banner stays hidden + // until checkout actually resolves. + return { stage: null, daysRemaining: null } + default: { + // Defensive fallthrough for unknown statuses — keep the banner hidden. + const _exhaustive: never = subscription.status as never + void _exhaustive + return { stage: null, daysRemaining: null } + } + } +} + +export default useTrialBanner -- 2.49.1 From ece82225f202f9a903cb525481145e97bb8a3b51 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 21:01:53 -0400 Subject: [PATCH 08/29] feat(billing): add FeatureGate, UpgradePrompt, EmailVerificationGate components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three drop-in gating components for the self-serve signup flow. - FeatureGate reads useFeature(flag) and renders children when enabled, else a fallback (default UpgradePrompt). UX-only — security boundary remains require_feature on the backend. - UpgradePrompt resolves a feature key to display name + required plan via an inline catalog and links to /account/billing/select-plan. - EmailVerificationGate gates protected content behind a 6-day grace period; renders a minimal EmailVerificationWall (resend + sign out) on Day 7+ unverified. Wall design will be refined in Task 37. Co-Authored-By: Claude Opus 4.7 --- .../common/EmailVerificationGate.tsx | 56 ++++++++ .../common/EmailVerificationWall.tsx | 88 +++++++++++++ .../src/components/common/FeatureGate.tsx | 42 ++++++ .../src/components/common/UpgradePrompt.tsx | 111 ++++++++++++++++ .../__tests__/EmailVerificationGate.test.tsx | 123 ++++++++++++++++++ .../common/__tests__/FeatureGate.test.tsx | 67 ++++++++++ .../common/__tests__/UpgradePrompt.test.tsx | 30 +++++ 7 files changed, 517 insertions(+) create mode 100644 frontend/src/components/common/EmailVerificationGate.tsx create mode 100644 frontend/src/components/common/EmailVerificationWall.tsx create mode 100644 frontend/src/components/common/FeatureGate.tsx create mode 100644 frontend/src/components/common/UpgradePrompt.tsx create mode 100644 frontend/src/components/common/__tests__/EmailVerificationGate.test.tsx create mode 100644 frontend/src/components/common/__tests__/FeatureGate.test.tsx create mode 100644 frontend/src/components/common/__tests__/UpgradePrompt.test.tsx diff --git a/frontend/src/components/common/EmailVerificationGate.tsx b/frontend/src/components/common/EmailVerificationGate.tsx new file mode 100644 index 00000000..cc17cdac --- /dev/null +++ b/frontend/src/components/common/EmailVerificationGate.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from 'react' +import { useAuthStore } from '@/store/authStore' +import { EmailVerificationWall } from './EmailVerificationWall' + +interface EmailVerificationGateProps { + children: ReactNode + /** + * Override the grace period (in days). Day `gracePeriodDays + 1` and beyond + * trigger the wall. Defaults to 6 — the spec says Day 1–6 unverified renders + * children and Day 7+ renders the wall. + */ + gracePeriodDays?: number +} + +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +/** Whole days elapsed between two ISO timestamps (floored). */ +function daysSince(iso: string, now: number = Date.now()): number { + const created = Date.parse(iso) + if (Number.isNaN(created)) { + // Defensive: bad timestamp — treat as just-signed-up so we don't + // accidentally lock anyone out. + return 0 + } + return Math.floor((now - created) / MS_PER_DAY) +} + +/** + * Wraps protected content. While the current user is past the grace period + * without having verified their email, renders `` + * instead of children. + * + * Behavior: + * - No user (signed out): renders children (let route guards handle auth). + * - User has `email_verified_at`: renders children. + * - Day 1–6 unverified: renders children (banner is shown elsewhere). + * - Day 7+ unverified: renders the wall. + */ +export function EmailVerificationGate({ + children, + gracePeriodDays = 6, +}: EmailVerificationGateProps) { + const user = useAuthStore((s) => s.user) + + if (!user) return <>{children} + if (user.email_verified_at) return <>{children} + + const elapsed = daysSince(user.created_at) + if (elapsed > gracePeriodDays) { + return + } + + return <>{children} +} + +export default EmailVerificationGate diff --git a/frontend/src/components/common/EmailVerificationWall.tsx b/frontend/src/components/common/EmailVerificationWall.tsx new file mode 100644 index 00000000..abb95e62 --- /dev/null +++ b/frontend/src/components/common/EmailVerificationWall.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react' +import { Loader2, MailCheck } from 'lucide-react' +import { authApi } from '@/api/auth' +import { useAuthStore } from '@/store/authStore' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' + +interface EmailVerificationWallProps { + className?: string +} + +/** + * Hard wall shown after the email-verification grace period expires. + * + * Minimal v1 — Task 37 will refine copy, layout, and add the + * `/verify-email?token=...` route handling. Until then this gives + * Day 7+ unverified users a way to re-send the verification email + * or sign out. + */ +export function EmailVerificationWall({ className }: EmailVerificationWallProps) { + const user = useAuthStore((s) => s.user) + const logout = useAuthStore((s) => s.logout) + const [isSending, setIsSending] = useState(false) + + const handleResend = async () => { + setIsSending(true) + try { + await authApi.sendVerificationEmail() + toast.success('Verification email sent') + } catch { + toast.error('Failed to send verification email') + } finally { + setIsSending(false) + } + } + + const handleLogout = async () => { + try { + await logout() + } catch { + // logout swallows API errors internally + } + } + + return ( +
+
+
+
+

+ Verify your email to continue +

+

+ {user?.email + ? `We sent a verification link to ${user.email}. Click it to unlock your account.` + : 'Check your inbox for the verification link we sent when you signed up.'} +

+
+ + +
+
+
+ ) +} + +export default EmailVerificationWall diff --git a/frontend/src/components/common/FeatureGate.tsx b/frontend/src/components/common/FeatureGate.tsx new file mode 100644 index 00000000..e27237d4 --- /dev/null +++ b/frontend/src/components/common/FeatureGate.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react' +import { useFeature } from '@/hooks/useFeature' +import { UpgradePrompt } from './UpgradePrompt' + +interface FeatureGateProps { + /** Feature flag key (e.g. `psa_integration`). Must match a backend `feature_flags.flag_key`. */ + feature: string + /** + * Rendered when the feature is enabled for the current account. + */ + children: ReactNode + /** + * Rendered when the feature is disabled. Defaults to ``. + * Pass `null` to render nothing. + */ + fallback?: ReactNode +} + +/** + * Conditionally renders `children` based on whether `feature` is enabled + * for the current account. + * + * This is a UX affordance — the security boundary is the backend + * `require_feature` dependency. Never trust this gate for authorization. + */ +export function FeatureGate({ feature, children, fallback }: FeatureGateProps) { + const enabled = useFeature(feature) + + if (enabled) { + return <>{children} + } + + // Use explicit fallback when provided, otherwise render the standard prompt. + // `null` is a valid fallback (renders nothing). + if (fallback !== undefined) { + return <>{fallback} + } + + return +} + +export default FeatureGate diff --git a/frontend/src/components/common/UpgradePrompt.tsx b/frontend/src/components/common/UpgradePrompt.tsx new file mode 100644 index 00000000..7780d717 --- /dev/null +++ b/frontend/src/components/common/UpgradePrompt.tsx @@ -0,0 +1,111 @@ +import { Lock, Sparkles } from 'lucide-react' +import { Link } from 'react-router-dom' +import { cn } from '@/lib/utils' + +interface UpgradePromptProps { + feature: string + className?: string +} + +interface FeatureMeta { + /** Display name shown in the prompt heading. */ + displayName: string + /** Plan that unlocks this feature. */ + requiredPlan: string + /** Optional one-line value pitch. */ + description?: string +} + +/** + * Mapping from feature flag key to display metadata. + * + * v1: small inline table maintained here. If this grows, lift to + * `frontend/src/lib/featureCatalog.ts` and source from a backend endpoint. + * + * Keys must match `feature_flags.flag_key` on the backend. + */ +const FEATURE_CATALOG: Record = { + psa_integration: { + displayName: 'PSA Integration', + requiredPlan: 'Pro', + description: 'Sync tickets and assets with your PSA in real time.', + }, + kb_accelerator: { + displayName: 'Knowledge Base Accelerator', + requiredPlan: 'Pro', + description: 'Auto-generate troubleshooting flows from your existing KB.', + }, + ai_builder: { + displayName: 'AI Builder', + requiredPlan: 'Pro', + description: 'Generate decision trees from natural-language prompts.', + }, + branching_logic: { + displayName: 'Branching Logic', + requiredPlan: 'Pro', + }, + custom_branding: { + displayName: 'Custom Branding', + requiredPlan: 'Pro', + }, + api_access: { + displayName: 'API Access', + requiredPlan: 'Pro', + }, + sso: { + displayName: 'Single Sign-On', + requiredPlan: 'Enterprise', + }, +} + +/** Humanize an unknown feature key for the fallback display name. */ +function humanizeFeatureKey(key: string): string { + return key + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +/** + * Standardized "this feature is on Pro" affordance. + * + * Renders a locked panel with a CTA that routes to the plan-selection page. + * The actual gating is enforced server-side via `require_feature` — this is UX. + */ +export function UpgradePrompt({ feature, className }: UpgradePromptProps) { + const meta = FEATURE_CATALOG[feature] + const displayName = meta?.displayName ?? humanizeFeatureKey(feature) + const requiredPlan = meta?.requiredPlan ?? 'Pro' + const description = meta?.description + + return ( +
+
+
+
+

+ {displayName} is available on {requiredPlan} +

+ {description && ( +

{description}

+ )} +
+ +
+ ) +} + +export default UpgradePrompt diff --git a/frontend/src/components/common/__tests__/EmailVerificationGate.test.tsx b/frontend/src/components/common/__tests__/EmailVerificationGate.test.tsx new file mode 100644 index 00000000..617f66a0 --- /dev/null +++ b/frontend/src/components/common/__tests__/EmailVerificationGate.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { EmailVerificationGate } from '../EmailVerificationGate' +import { useAuthStore } from '@/store/authStore' +import type { User } from '@/types' + +function makeUser(overrides: Partial = {}): User { + return { + id: 'user-1', + email: 'test@example.com', + name: 'Test User', + role: 'engineer', + is_super_admin: false, + is_active: true, + must_change_password: false, + account_id: 'acct-1', + account_role: 'engineer', + team_id: null, + created_at: '2026-05-01T00:00:00Z', + last_login: null, + phone: null, + job_title: null, + timezone: 'UTC', + avatar_url: null, + email_verified_at: null, + ...overrides, + } +} + +const FROZEN_NOW = new Date('2026-05-06T00:00:00Z') + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}) +} + +describe('EmailVerificationGate', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(FROZEN_NOW) + useAuthStore.setState({ user: null, token: null, isAuthenticated: false }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders children when no user is signed in', () => { + renderWithRouter( + +
protected
+
, + ) + expect(screen.getByText('protected')).toBeInTheDocument() + }) + + it('renders children when user has verified email', () => { + useAuthStore.setState({ + user: makeUser({ email_verified_at: '2026-04-01T00:00:00Z' }), + }) + renderWithRouter( + +
protected
+
, + ) + expect(screen.getByText('protected')).toBeInTheDocument() + }) + + it('renders children on day 1 unverified (within grace)', () => { + // created 1 day before frozen now. + useAuthStore.setState({ + user: makeUser({ created_at: '2026-05-05T00:00:00Z' }), + }) + renderWithRouter( + +
protected
+
, + ) + expect(screen.getByText('protected')).toBeInTheDocument() + }) + + it('renders children on day 6 unverified (last day of grace)', () => { + // created 6 days before frozen now. + useAuthStore.setState({ + user: makeUser({ created_at: '2026-04-30T00:00:00Z' }), + }) + renderWithRouter( + +
protected
+
, + ) + expect(screen.getByText('protected')).toBeInTheDocument() + }) + + it('renders wall on day 7 unverified user', () => { + // created 7 days before frozen now -> elapsed=7, > grace=6 -> wall. + useAuthStore.setState({ + user: makeUser({ created_at: '2026-04-29T00:00:00Z' }), + }) + renderWithRouter( + +
protected
+
, + ) + expect(screen.queryByText('protected')).not.toBeInTheDocument() + expect(screen.getByTestId('email-verification-wall')).toBeInTheDocument() + expect(screen.getByText(/Verify your email to continue/i)).toBeInTheDocument() + }) + + it('renders wall on day 8 unverified user', () => { + // created 8 days before frozen now. + useAuthStore.setState({ + user: makeUser({ created_at: '2026-04-28T00:00:00Z' }), + }) + renderWithRouter( + +
protected
+
, + ) + expect(screen.queryByText('protected')).not.toBeInTheDocument() + expect(screen.getByTestId('email-verification-wall')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/common/__tests__/FeatureGate.test.tsx b/frontend/src/components/common/__tests__/FeatureGate.test.tsx new file mode 100644 index 00000000..8df732f9 --- /dev/null +++ b/frontend/src/components/common/__tests__/FeatureGate.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { FeatureGate } from '../FeatureGate' +import { useBillingStore } from '@/store/billingStore' + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}) +} + +describe('FeatureGate', () => { + beforeEach(() => { + useBillingStore.setState({ + subscription: null, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) + }) + + it('renders children when flag enabled, fallback when disabled', () => { + // Disabled by default — renders default UpgradePrompt fallback. + const { unmount } = renderWithRouter( + +
protected content
+
, + ) + expect(screen.queryByText('protected content')).not.toBeInTheDocument() + expect(screen.getByTestId('upgrade-prompt')).toBeInTheDocument() + unmount() + + // Enabled — renders children. + useBillingStore.setState({ enabledFeatures: { psa_integration: true } }) + renderWithRouter( + +
protected content
+
, + ) + expect(screen.getByText('protected content')).toBeInTheDocument() + expect(screen.queryByTestId('upgrade-prompt')).not.toBeInTheDocument() + }) + + it('renders custom fallback when disabled', () => { + renderWithRouter( + custom fallback} + > +
protected
+
, + ) + expect(screen.getByText('custom fallback')).toBeInTheDocument() + expect(screen.queryByText('protected')).not.toBeInTheDocument() + }) + + it('renders nothing when fallback is null and feature disabled', () => { + const { container } = renderWithRouter( + +
protected
+
, + ) + expect(screen.queryByText('protected')).not.toBeInTheDocument() + expect(container.textContent).toBe('') + }) +}) diff --git a/frontend/src/components/common/__tests__/UpgradePrompt.test.tsx b/frontend/src/components/common/__tests__/UpgradePrompt.test.tsx new file mode 100644 index 00000000..45d730a4 --- /dev/null +++ b/frontend/src/components/common/__tests__/UpgradePrompt.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { UpgradePrompt } from '../UpgradePrompt' + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}) +} + +describe('UpgradePrompt', () => { + it('renders display name and required plan from catalog', () => { + renderWithRouter() + expect( + screen.getByText(/PSA Integration is available on Pro/i), + ).toBeInTheDocument() + }) + + it('CTA navigates to /account/billing/select-plan', () => { + renderWithRouter() + const cta = screen.getByRole('link', { name: /Upgrade to Pro/i }) + expect(cta).toHaveAttribute('href', '/account/billing/select-plan') + }) + + it('humanizes unknown feature keys and falls back to Pro', () => { + renderWithRouter() + expect( + screen.getByText(/Some New Feature is available on Pro/i), + ).toBeInTheDocument() + }) +}) -- 2.49.1 From 70ab1f34d4d941e81526b03e30f4568361dcd115 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 21:11:09 -0400 Subject: [PATCH 09/29] feat(auth): redesign /register with OAuth buttons; hide invite-code under flag Phase 2 Task 35. Adds OAuth Google/Microsoft sign-in to the register flow, gated on the public SELF_SERVE_ENABLED flag, and hides the legacy invite-code field when self-serve is on. - New `useAppConfig` hook + `configApi`. One-shot module-cached fetch of `GET /api/v1/config/public`; falls back to `VITE_SELF_SERVE_ENABLED` env var (default false) if the endpoint is unreachable. - New `OAuthCallbackPage` mounted at `/auth/google/callback` and `/auth/microsoft/callback` (public, NOT inside ProtectedRoute). Posts the authorization code to the backend, persists tokens, hydrates the auth store via fetchUser, and redirects to `/welcome` (new) or `/` (returning). - `RegisterPage` now renders OAuth buttons + email/password divider when `self_serve_enabled` is true and only emits buttons for providers the backend reports as configured. Invite-code field hidden in that mode. Captures `?plan=pro` into `localStorage.rf-intended-plan` on mount. - `authApi` gains `googleCallback(code)` / `microsoftCallback(code)`. - `frontend/.env.example` + `frontend/Dockerfile` document and bake the three new VITE_* build-time variables (Lesson 60: Vite needs ARG+ENV). - Vitest coverage for the three required cases plus the plan-param capture. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/.env.example | 18 + frontend/Dockerfile | 10 + frontend/src/api/auth.ts | 23 + frontend/src/api/config.ts | 15 + frontend/src/hooks/useAppConfig.ts | 99 ++++ frontend/src/pages/OAuthCallbackPage.tsx | 141 +++++ frontend/src/pages/RegisterPage.tsx | 486 ++++++++++++------ .../__tests__/OAuthCallbackPage.test.tsx | 81 +++ .../src/pages/__tests__/RegisterPage.test.tsx | 121 +++++ frontend/src/router.tsx | 11 + 10 files changed, 845 insertions(+), 160 deletions(-) create mode 100644 frontend/src/api/config.ts create mode 100644 frontend/src/hooks/useAppConfig.ts create mode 100644 frontend/src/pages/OAuthCallbackPage.tsx create mode 100644 frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx create mode 100644 frontend/src/pages/__tests__/RegisterPage.test.tsx diff --git a/frontend/.env.example b/frontend/.env.example index 62e5edc5..574a1872 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,3 +3,21 @@ VITE_API_URL=http://localhost:8000 # Sentry error monitoring (optional in dev, required in production) VITE_SENTRY_DSN= + +# Stripe publishable key (same pk_test_/pk_live_ value as backend STRIPE_PUBLISHABLE_KEY). +# Vite bakes this at build time, so prod requires ARG+ENV in frontend/Dockerfile (Lesson 60). +VITE_STRIPE_PUBLISHABLE_KEY=pk_test_ + +# OAuth client IDs — must match backend GOOGLE_CLIENT_ID / MS_CLIENT_ID. +# Public values; Vite bakes at build time so prod requires ARG+ENV in frontend/Dockerfile. +VITE_GOOGLE_CLIENT_ID= +VITE_MS_CLIENT_ID= + +# Origin used to build OAuth redirect_uri (e.g. http://localhost:5173 or https://app.example.com). +# Must equal backend OAUTH_REDIRECT_BASE so callback paths align. If unset, the +# frontend falls back to window.location.origin at click time. +VITE_OAUTH_REDIRECT_BASE= + +# Self-serve signup safety fallback used by useAppConfig when GET /config/public +# is unreachable. Authoritative value comes from backend SELF_SERVE_ENABLED. +VITE_SELF_SERVE_ENABLED=false diff --git a/frontend/Dockerfile b/frontend/Dockerfile index d539bd53..6bcdc0c8 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -17,10 +17,20 @@ ARG VITE_API_URL ARG VITE_SENTRY_DSN ARG VITE_PUBLIC_POSTHOG_KEY ARG VITE_PUBLIC_POSTHOG_HOST +ARG VITE_STRIPE_PUBLISHABLE_KEY +ARG VITE_GOOGLE_CLIENT_ID +ARG VITE_MS_CLIENT_ID +ARG VITE_OAUTH_REDIRECT_BASE +ARG VITE_SELF_SERVE_ENABLED ENV VITE_API_URL=$VITE_API_URL ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY ENV VITE_PUBLIC_POSTHOG_HOST=$VITE_PUBLIC_POSTHOG_HOST +ENV VITE_STRIPE_PUBLISHABLE_KEY=$VITE_STRIPE_PUBLISHABLE_KEY +ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID +ENV VITE_MS_CLIENT_ID=$VITE_MS_CLIENT_ID +ENV VITE_OAUTH_REDIRECT_BASE=$VITE_OAUTH_REDIRECT_BASE +ENV VITE_SELF_SERVE_ENABLED=$VITE_SELF_SERVE_ENABLED # Build the application RUN npm run build diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index afc3fec3..7382679d 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,6 +1,13 @@ import apiClient from './client' import type { Token, User, UserCreate, UserLogin, UserUpdate } from '@/types' +export interface OAuthCallbackResponse { + access_token: string + refresh_token: string + token_type: string + is_new_user: boolean +} + export const authApi = { async register(data: UserCreate): Promise { const response = await apiClient.post('/auth/register', data) @@ -71,6 +78,22 @@ export const authApi = { async verifyEmail(token: string): Promise { await apiClient.post('/auth/email/verify', { token }) }, + + async googleCallback(code: string): Promise { + const response = await apiClient.post( + '/auth/google/callback', + { code }, + ) + return response.data + }, + + async microsoftCallback(code: string): Promise { + const response = await apiClient.post( + '/auth/microsoft/callback', + { code }, + ) + return response.data + }, } export default authApi diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts new file mode 100644 index 00000000..c3337f2c --- /dev/null +++ b/frontend/src/api/config.ts @@ -0,0 +1,15 @@ +import apiClient from './client' + +export interface PublicConfig { + self_serve_enabled: boolean + oauth_providers: string[] +} + +export const configApi = { + async getPublic(): Promise { + const response = await apiClient.get('/config/public') + return response.data + }, +} + +export default configApi diff --git a/frontend/src/hooks/useAppConfig.ts b/frontend/src/hooks/useAppConfig.ts new file mode 100644 index 00000000..49348c73 --- /dev/null +++ b/frontend/src/hooks/useAppConfig.ts @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' +import { configApi, type PublicConfig } from '@/api/config' + +/** + * Module-scope cache: the public config endpoint is fetched at most once + * per page load. Subsequent hook mounts return the cached value synchronously + * (after the initial state update). + */ +let cached: PublicConfig | null = null +let inFlight: Promise | null = null +const subscribers = new Set<(c: PublicConfig) => void>() + +function envFallback(): PublicConfig { + // Falls back to build-time flag when the public config endpoint is + // unreachable. Defaults to the legacy invite-only behavior so that + // a backend hiccup never opens public signup. + const selfServe = + String(import.meta.env.VITE_SELF_SERVE_ENABLED ?? '').toLowerCase() === 'true' + return { + self_serve_enabled: selfServe, + oauth_providers: [], + } +} + +async function loadConfig(): Promise { + if (cached) return cached + if (inFlight) return inFlight + inFlight = configApi + .getPublic() + .then((c) => { + cached = c + subscribers.forEach((cb) => cb(c)) + return c + }) + .catch(() => { + const fallback = envFallback() + cached = fallback + subscribers.forEach((cb) => cb(fallback)) + return fallback + }) + .finally(() => { + inFlight = null + }) + return inFlight +} + +/** Test-only: clear the module-scope cache between tests. */ +export function __resetAppConfigCache() { + cached = null + inFlight = null + subscribers.clear() +} + +/** Test-only: prime the module-scope cache so hook returns synchronously. */ +export function __setAppConfigCache(c: PublicConfig) { + cached = c +} + +export interface UseAppConfigResult { + self_serve_enabled: boolean + oauth_providers: string[] + isLoading: boolean +} + +export function useAppConfig(): UseAppConfigResult { + const [config, setConfig] = useState(cached) + + useEffect(() => { + if (cached) { + setConfig(cached) + return + } + let active = true + const handler = (c: PublicConfig) => { + if (active) setConfig(c) + } + subscribers.add(handler) + void loadConfig() + return () => { + active = false + subscribers.delete(handler) + } + }, []) + + if (config) { + return { + self_serve_enabled: config.self_serve_enabled, + oauth_providers: config.oauth_providers, + isLoading: false, + } + } + return { + self_serve_enabled: false, + oauth_providers: [], + isLoading: true, + } +} + +export default useAppConfig diff --git a/frontend/src/pages/OAuthCallbackPage.tsx b/frontend/src/pages/OAuthCallbackPage.tsx new file mode 100644 index 00000000..5e0b8a1d --- /dev/null +++ b/frontend/src/pages/OAuthCallbackPage.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { authApi } from '@/api/auth' +import { useAuthStore } from '@/store/authStore' +import { BrandLogo } from '@/components/common/BrandLogo' +import { PageMeta } from '@/components/common/PageMeta' + +type Provider = 'google' | 'microsoft' + +/** + * Handles the OAuth redirect leg of the full-page Google / Microsoft sign-in + * flow. Mounted at /auth/google/callback and /auth/microsoft/callback as + * 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). + */ +export function OAuthCallbackPage() { + const navigate = useNavigate() + const location = useLocation() + const { setTokens, fetchUser } = useAuthStore() + const [error, setError] = useState(null) + + // Derive provider purely from URL pathname — routes are static + // (/auth/google/callback and /auth/microsoft/callback), so there is + // no `:provider` route param to read. + const provider: Provider = location.pathname.includes('/microsoft/') + ? 'microsoft' + : 'google' + + useEffect(() => { + const search = new URLSearchParams(location.search) + const code = search.get('code') + 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. + let storedState: string | null = null + try { + storedState = sessionStorage.getItem('rf-oauth-state') + sessionStorage.removeItem('rf-oauth-state') + } catch { + // sessionStorage may be unavailable (private mode, etc.) — treat as missing. + storedState = null + } + + if (oauthError) { + setError(`OAuth error: ${oauthError}`) + return + } + if (!storedState || returnedState !== storedState) { + setError('Invalid OAuth state — possible CSRF. Please try again.') + return + } + if (!code) { + setError('Missing authorization code') + return + } + + let cancelled = false + void (async () => { + try { + const result = + provider === 'microsoft' + ? await authApi.microsoftCallback(code) + : await authApi.googleCallback(code) + if (cancelled) return + + // Persist tokens for apiClient interceptor + zustand store. + localStorage.setItem('access_token', result.access_token) + localStorage.setItem('refresh_token', result.refresh_token) + setTokens({ + access_token: result.access_token, + refresh_token: result.refresh_token, + token_type: result.token_type || 'bearer', + }) + // Hydrate user / account / subscription. + await fetchUser() + if (cancelled) return + + const dest = result.is_new_user ? '/welcome' : '/' + navigate(dest, { replace: true }) + } catch (err: unknown) { + if (cancelled) return + const axiosErr = err as { + response?: { data?: { detail?: unknown } } + } + const detail = axiosErr.response?.data?.detail + const msg = + (typeof detail === 'string' ? detail : null) || + (err instanceof Error ? err.message : 'Sign-in failed') + setError(msg) + } + })() + + return () => { + cancelled = true + } + }, [location.search, provider, setTokens, fetchUser, navigate]) + + return ( + <> + +
+
+
+ +
+ {error ? ( + <> +

+ Sign-in failed +

+

{error}

+ + + ) : ( + <> +

+ Signing you in… +

+

+ Finishing up the {provider === 'microsoft' ? 'Microsoft' : 'Google'} sign-in flow. +

+ + )} +
+
+ + ) +} + +export default OAuthCallbackPage diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index 73cdea2e..791cc5af 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -1,18 +1,77 @@ -import { useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { useEffect, useState } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' import { useAuthStore } from '@/store/authStore' import { inviteApi } from '@/api/invite' +import { useAppConfig } from '@/hooks/useAppConfig' import { BrandLogo } from '@/components/common/BrandLogo' import { PasswordInput } from '@/components/common/PasswordInput' import { PageMeta } from '@/components/common/PageMeta' import { cn } from '@/lib/utils' +const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' +const MICROSOFT_AUTH_URL = + 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize' + +function getRedirectBase(): string { + const fromEnv = import.meta.env.VITE_OAUTH_REDIRECT_BASE + if (fromEnv) return fromEnv as string + // Falls back to current origin in dev so feature works without explicit env. + if (typeof window !== 'undefined') return window.location.origin + return '' +} + +function randomState(): string { + // Lightweight random state — used only to harden against CSRF on the OAuth + // round-trip. Not a security boundary; backend independently authenticates + // via the authorization code exchange. + 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('') +} + +/** Build provider authorize URL. Exported for tests. */ +export function buildOAuthAuthorizeUrl( + provider: 'google' | 'microsoft', + state: string, +): string { + const redirectUri = `${getRedirectBase()}/auth/${provider}/callback` + if (provider === 'google') { + const params = new URLSearchParams({ + client_id: (import.meta.env.VITE_GOOGLE_CLIENT_ID as string) || '', + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile', + access_type: 'offline', + prompt: 'consent', + state, + }) + return `${GOOGLE_AUTH_URL}?${params.toString()}` + } + const params = new URLSearchParams({ + client_id: (import.meta.env.VITE_MS_CLIENT_ID as string) || '', + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile offline_access', + response_mode: 'query', + state, + }) + return `${MICROSOFT_AUTH_URL}?${params.toString()}` +} + export function RegisterPage() { const navigate = useNavigate() + const location = useLocation() const { register, isLoading, error, clearError } = useAuthStore() + const appConfig = useAppConfig() const [inviteCode, setInviteCode] = useState('') - const [inviteCodeStatus, setInviteCodeStatus] = useState<'idle' | 'checking' | 'valid' | 'invalid'>('idle') + const [inviteCodeStatus, setInviteCodeStatus] = useState< + 'idle' | 'checking' | 'valid' | 'invalid' + >('idle') const [inviteCodeMessage, setInviteCodeMessage] = useState('') const [name, setName] = useState('') const [email, setEmail] = useState('') @@ -20,6 +79,32 @@ export function RegisterPage() { const [confirmPassword, setConfirmPassword] = useState('') const [localError, setLocalError] = useState('') + // Capture ?plan=pro into localStorage so the in-app flow / start_trial + // can later read it. One-shot on mount. + useEffect(() => { + const params = new URLSearchParams(location.search) + const plan = params.get('plan') + if (plan) localStorage.setItem('rf-intended-plan', plan) + }, [location.search]) + + const showOAuthButtons = appConfig.self_serve_enabled + const showInviteCode = !appConfig.self_serve_enabled + const googleAvailable = + showOAuthButtons && appConfig.oauth_providers.includes('google') + const microsoftAvailable = + showOAuthButtons && appConfig.oauth_providers.includes('microsoft') + + const handleOAuth = (provider: 'google' | 'microsoft') => { + const state = randomState() + try { + sessionStorage.setItem('rf-oauth-state', state) + } catch { + // ignore — non-fatal + } + const url = buildOAuthAuthorizeUrl(provider, state) + window.location.href = url + } + const validateInviteCode = async (code: string) => { if (!code.trim()) { setInviteCodeStatus('idle') @@ -43,8 +128,8 @@ export function RegisterPage() { setLocalError('') clearError() - // Only validate invite code if one was entered - if (inviteCode.trim() && inviteCodeStatus === 'invalid') { + // Only validate invite code when the field is shown (legacy invite flow). + if (showInviteCode && inviteCode.trim() && inviteCodeStatus === 'invalid') { setLocalError('Please enter a valid invite code') return } @@ -65,12 +150,15 @@ export function RegisterPage() { } try { - // Only include invite_code if provided - const userData = inviteCode.trim() - ? { email, password, name, invite_code: inviteCode.trim() } - : { email, password, name } + const userData = + showInviteCode && inviteCode.trim() + ? { email, password, name, invite_code: inviteCode.trim() } + : { email, password, name } await register(userData) - navigate('/', { replace: true }) + // New users land on the welcome wizard. The /welcome route is + // materialized by Task 38; until that lands, this redirect falls + // through to the catch-all 404 — acceptable per spec. + navigate('/welcome', { replace: true }) } catch { // Error is set in the store } @@ -78,28 +166,30 @@ export function RegisterPage() { return ( <> - -
- {/* Subtle radial overlay */} -
+ +
+ {/* Subtle radial overlay */} +
-
-
-
- +
+
+
+ +
+

+ ResolutionFlow +

+

+ AI-Powered Troubleshooting for MSPs +

+

+ Create your account +

-

- ResolutionFlow -

-

- AI-Powered Troubleshooting for MSPs -

-

- Create your account -

-
-
{(error || localError) && (
@@ -107,140 +197,217 @@ export function RegisterPage() {
)} -
- - { - setInviteCode(e.target.value.toUpperCase()) - setInviteCodeStatus('idle') - }} - onBlur={(e) => validateInviteCode(e.target.value)} - className={cn( - 'mt-1 block w-full rounded-xl border bg-card px-3 py-2 font-mono tracking-wider', - 'text-foreground placeholder:text-muted-foreground', - 'focus:outline-hidden focus:ring-1', - inviteCodeStatus === 'valid' && 'border-emerald-400/50 focus:border-emerald-400 focus:ring-emerald-400/30', - inviteCodeStatus === 'invalid' && 'border-red-400/50 focus:border-red-400 focus:ring-red-400/30', - inviteCodeStatus === 'idle' && 'border-border focus:border-primary focus:ring-primary/20', - inviteCodeStatus === 'checking' && 'border-border focus:border-primary focus:ring-primary/20' + {showOAuthButtons && (googleAvailable || microsoftAvailable) && ( +
+ {googleAvailable && ( + )} - placeholder="ABCD1234" - /> - {inviteCodeStatus === 'checking' && ( -

Validating...

+ {microsoftAvailable && ( + + )} + +
+
+
+
+
+ + or sign up with email + +
+
+
+ )} + + + {showInviteCode && ( +
+ + { + setInviteCode(e.target.value.toUpperCase()) + setInviteCodeStatus('idle') + }} + onBlur={(e) => validateInviteCode(e.target.value)} + className={cn( + 'mt-1 block w-full rounded-xl border bg-card px-3 py-2 font-mono tracking-wider', + 'text-foreground placeholder:text-muted-foreground', + 'focus:outline-hidden focus:ring-1', + inviteCodeStatus === 'valid' && + 'border-emerald-400/50 focus:border-emerald-400 focus:ring-emerald-400/30', + inviteCodeStatus === 'invalid' && + 'border-red-400/50 focus:border-red-400 focus:ring-red-400/30', + inviteCodeStatus === 'idle' && + 'border-border focus:border-primary focus:ring-primary/20', + inviteCodeStatus === 'checking' && + 'border-border focus:border-primary focus:ring-primary/20', + )} + placeholder="ABCD1234" + /> + {inviteCodeStatus === 'checking' && ( +

+ Validating... +

+ )} + {inviteCodeStatus === 'valid' && ( +

+ {inviteCodeMessage} +

+ )} + {inviteCodeStatus === 'invalid' && ( +

+ {inviteCodeMessage} +

+ )} +
)} - {inviteCodeStatus === 'valid' && ( -

{inviteCodeMessage}

- )} - {inviteCodeStatus === 'invalid' && ( -

{inviteCodeMessage}

- )} -
-
- - setName(e.target.value)} +
+ + 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="John Smith" + /> +
+ +
+ + setEmail(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="you@example.com" + /> +
+ +
+ + 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="••••••••••" + /> +
+ +
- -
- - setEmail(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="you@example.com" - /> -
- -
- - 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="••••••••••" - /> -
- - + > + {isLoading ? 'Creating account...' : 'Create account'} + +

@@ -249,9 +416,8 @@ export function RegisterPage() { Sign in

- +
-
) } diff --git a/frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx b/frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx new file mode 100644 index 00000000..392e9403 --- /dev/null +++ b/frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { HelmetProvider } from 'react-helmet-async' + +import { OAuthCallbackPage } from '../OAuthCallbackPage' +import { authApi } from '@/api/auth' + +vi.mock('@/api/auth', () => ({ + authApi: { + googleCallback: vi.fn(), + microsoftCallback: vi.fn(), + }, +})) + +vi.mock('@/store/authStore', () => ({ + useAuthStore: () => ({ + setTokens: vi.fn(), + fetchUser: vi.fn().mockResolvedValue(undefined), + }), +})) + +function renderAt(path: string) { + return render( + + + + } + /> + } + /> + + + , + ) +} + +describe('OAuthCallbackPage CSRF state validation', () => { + beforeEach(() => { + sessionStorage.clear() + vi.clearAllMocks() + }) + + afterEach(() => { + sessionStorage.clear() + }) + + it('shows error and does NOT call googleCallback when state in URL does not match sessionStorage', async () => { + sessionStorage.setItem('rf-oauth-state', 'expected-state-value') + + renderAt('/auth/google/callback?code=auth-code-123&state=attacker-state') + + await waitFor(() => { + expect( + screen.getByText(/Invalid OAuth state/i), + ).toBeInTheDocument() + }) + + expect(authApi.googleCallback).not.toHaveBeenCalled() + expect(authApi.microsoftCallback).not.toHaveBeenCalled() + // Stored value must be cleared regardless of outcome. + expect(sessionStorage.getItem('rf-oauth-state')).toBeNull() + }) + + it('shows error and does NOT call googleCallback when stored state is missing', async () => { + // No sessionStorage entry set. + renderAt('/auth/google/callback?code=auth-code-123&state=any-state') + + await waitFor(() => { + expect( + screen.getByText(/Invalid OAuth state/i), + ).toBeInTheDocument() + }) + + expect(authApi.googleCallback).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/pages/__tests__/RegisterPage.test.tsx b/frontend/src/pages/__tests__/RegisterPage.test.tsx new file mode 100644 index 00000000..dc9393cd --- /dev/null +++ b/frontend/src/pages/__tests__/RegisterPage.test.tsx @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { HelmetProvider } from 'react-helmet-async' + +import { RegisterPage } from '../RegisterPage' +import { + __resetAppConfigCache, + __setAppConfigCache, +} from '@/hooks/useAppConfig' + +function renderPage(initialPath = '/register') { + return render( + + + + + , + ) +} + +describe('RegisterPage', () => { + beforeEach(() => { + __resetAppConfigCache() + // Provide mock env values so authorize URL build is deterministic. + vi.stubEnv('VITE_GOOGLE_CLIENT_ID', 'test-google-client') + vi.stubEnv('VITE_MS_CLIENT_ID', 'test-ms-client') + vi.stubEnv('VITE_OAUTH_REDIRECT_BASE', 'http://localhost:5173') + }) + + it('hides OAuth + shows invite-code field when self_serve_enabled is false', () => { + __setAppConfigCache({ + self_serve_enabled: false, + oauth_providers: ['google', 'microsoft'], + }) + + renderPage() + + expect(screen.getByLabelText(/invite code/i)).toBeInTheDocument() + expect(screen.queryByTestId('oauth-google')).not.toBeInTheDocument() + expect(screen.queryByTestId('oauth-microsoft')).not.toBeInTheDocument() + expect( + screen.queryByText(/or sign up with email/i), + ).not.toBeInTheDocument() + }) + + it('hides invite-code + shows OAuth buttons when self_serve_enabled is true', () => { + __setAppConfigCache({ + self_serve_enabled: true, + oauth_providers: ['google', 'microsoft'], + }) + + renderPage() + + expect(screen.queryByLabelText(/invite code/i)).not.toBeInTheDocument() + expect(screen.getByTestId('oauth-google')).toBeInTheDocument() + expect(screen.getByTestId('oauth-microsoft')).toBeInTheDocument() + expect(screen.getByText(/or sign up with email/i)).toBeInTheDocument() + }) + + it('clicking Continue with Google opens OAuth flow with correct URL', () => { + __setAppConfigCache({ + self_serve_enabled: true, + oauth_providers: ['google'], + }) + + // Stub window.location.href assignment. + const originalLocation = window.location + const hrefSetter = vi.fn() + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + origin: 'http://localhost:5173', + set href(value: string) { + hrefSetter(value) + }, + get href() { + return originalLocation.href + }, + }, + }) + + try { + renderPage() + const button = screen.getByTestId('oauth-google') + fireEvent.click(button) + + expect(hrefSetter).toHaveBeenCalledTimes(1) + const url = hrefSetter.mock.calls[0][0] as string + expect(url).toMatch( + /^https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?/, + ) + const search = new URL(url).searchParams + expect(search.get('client_id')).toBe('test-google-client') + expect(search.get('redirect_uri')).toBe( + 'http://localhost:5173/auth/google/callback', + ) + expect(search.get('response_type')).toBe('code') + expect(search.get('scope')).toContain('openid') + expect(search.get('state')).toBeTruthy() + } finally { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }) + } + }) + + it('captures ?plan=pro into localStorage on mount', () => { + __setAppConfigCache({ + self_serve_enabled: true, + oauth_providers: [], + }) + localStorage.removeItem('rf-intended-plan') + + renderPage('/register?plan=pro') + + expect(localStorage.getItem('rf-intended-plan')).toBe('pro') + }) +}) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 44e4fa30..f0b3e50b 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -25,6 +25,7 @@ const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage')) // Standalone auth pages const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage')) +const OAuthCallbackPage = lazyWithRetry(() => import('@/pages/OAuthCallbackPage')) const ChangePasswordPage = lazyWithRetry(() => import('@/pages/ChangePasswordPage')) const ForgotPasswordPage = lazyWithRetry(() => import('@/pages/ForgotPasswordPage')) const ResetPasswordPage = lazyWithRetry(() => import('@/pages/ResetPasswordPage')) @@ -149,6 +150,16 @@ export const router = sentryCreateBrowserRouter([ element: page(VerifyEmailPage), errorElement: , }, + { + path: '/auth/google/callback', + element: page(OAuthCallbackPage), + errorElement: , + }, + { + path: '/auth/microsoft/callback', + element: page(OAuthCallbackPage), + errorElement: , + }, { path: '/survey', element: page(SurveyPage), -- 2.49.1 From 39e85c9770f64bcf61dcc00ac371ee1fad080f7b Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 21:26:51 -0400 Subject: [PATCH 10/29] feat(auth): add /accept-invite page + lookup endpoint 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) --- .../api/endpoints/account_invite_lookup.py | 54 +++ backend/app/api/endpoints/oauth.py | 143 +++++-- backend/app/api/router.py | 2 + backend/app/schemas/oauth.py | 19 + backend/tests/test_account_invite_lookup.py | 290 ++++++++++++++ frontend/src/api/auth.ts | 22 +- frontend/src/api/invite.ts | 19 + frontend/src/lib/oauthState.test.ts | 53 +++ frontend/src/lib/oauthState.ts | 61 +++ frontend/src/pages/AcceptInvitePage.tsx | 371 ++++++++++++++++++ frontend/src/pages/OAuthCallbackPage.tsx | 76 +++- .../pages/__tests__/AcceptInvitePage.test.tsx | 123 ++++++ frontend/src/router.tsx | 6 + frontend/src/types/user.ts | 2 + 14 files changed, 1201 insertions(+), 40 deletions(-) create mode 100644 backend/app/api/endpoints/account_invite_lookup.py create mode 100644 backend/tests/test_account_invite_lookup.py create mode 100644 frontend/src/lib/oauthState.test.ts create mode 100644 frontend/src/lib/oauthState.ts create mode 100644 frontend/src/pages/AcceptInvitePage.tsx create mode 100644 frontend/src/pages/__tests__/AcceptInvitePage.test.tsx 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 { -- 2.49.1 From 7d939a4acfd64310824ccbd2c71582ed8d8f1b7c Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 21:41:30 -0400 Subject: [PATCH 11/29] feat(auth): add email verification banner, wall, /verify-email page Wires up the soft 7-day email-verification grace period UX. - EmailVerificationBanner now uses the design-system warning tokens (bg-warning-dim / text-warning) and hides itself once the grace period expires, so the wall takes over without double-messaging. - EmailVerificationWall picks up data-testids on the resend and sign-out CTAs. - VerifyEmailPage gains a single-fire useRef guard (so React 19 strict-mode double-invoke doesn't burn the token), an already-verified short-circuit that skips the API call, success state with auth-store refresh + redirect to /?verified=1, and an error state with a resend CTA. Tests: banner hides past day-7, banner resend triggers API call, verify success refreshes + redirects, verify short-circuits when already verified, single-fire guard holds across remount. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../common/EmailVerificationWall.tsx | 2 + frontend/src/components/layout/AppLayout.tsx | 5 +- .../layout/EmailVerificationBanner.tsx | 54 +++- .../layout/__tests__/AppLayout.test.tsx | 123 +++++++++ .../EmailVerificationBanner.test.tsx | 119 ++++++++ frontend/src/pages/VerifyEmailPage.tsx | 256 ++++++++++++++---- .../pages/__tests__/VerifyEmailPage.test.tsx | 174 ++++++++++++ 7 files changed, 673 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/layout/__tests__/AppLayout.test.tsx create mode 100644 frontend/src/components/layout/__tests__/EmailVerificationBanner.test.tsx create mode 100644 frontend/src/pages/__tests__/VerifyEmailPage.test.tsx diff --git a/frontend/src/components/common/EmailVerificationWall.tsx b/frontend/src/components/common/EmailVerificationWall.tsx index abb95e62..8eb5cab2 100644 --- a/frontend/src/components/common/EmailVerificationWall.tsx +++ b/frontend/src/components/common/EmailVerificationWall.tsx @@ -67,6 +67,7 @@ export function EmailVerificationWall({ className }: EmailVerificationWallProps) type="button" onClick={handleResend} disabled={isSending} + data-testid="resend-button" className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" > {isSending && } @@ -75,6 +76,7 @@ export function EmailVerificationWall({ className }: EmailVerificationWallProps)
diff --git a/frontend/src/components/layout/EmailVerificationBanner.tsx b/frontend/src/components/layout/EmailVerificationBanner.tsx index e65bdb2f..91ecd0c9 100644 --- a/frontend/src/components/layout/EmailVerificationBanner.tsx +++ b/frontend/src/components/layout/EmailVerificationBanner.tsx @@ -5,7 +5,39 @@ import { useAuthStore } from '@/store/authStore' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' -export function EmailVerificationBanner() { +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +/** + * Whole days elapsed between an ISO timestamp and now (floored). + * + * Mirrors the helper in `EmailVerificationGate` — keep the two in sync so the + * banner hides on the same day the wall appears (Day 7+ unverified). Defensive + * on bad timestamps: treats unparseable input as "just signed up" so we never + * accidentally hide the banner on a real unverified user. + */ +function daysSince(iso: string, now: number = Date.now()): number { + const created = Date.parse(iso) + if (Number.isNaN(created)) return 0 + return Math.floor((now - created) / MS_PER_DAY) +} + +interface EmailVerificationBannerProps { + /** + * Override the grace period (in days). Day `gracePeriodDays + 1` and beyond + * suppress the banner — `EmailVerificationGate` shows the wall instead. + * Defaults to 6 (matches the gate). + */ + gracePeriodDays?: number +} + +/** + * Top-of-dashboard bar shown to users who signed up but haven't verified their + * email yet. Hides itself once the grace period expires (the wall takes over) + * and once the user dismisses it for the session. + */ +export function EmailVerificationBanner({ + gracePeriodDays = 6, +}: EmailVerificationBannerProps = {}) { const user = useAuthStore((s) => s.user) const [dismissed, setDismissed] = useState(false) const [isSending, setIsSending] = useState(false) @@ -19,6 +51,11 @@ export function EmailVerificationBanner() { if (!user || user.email_verified_at || dismissed || !verificationEnabled) return null + // Past grace period: the wall takes over inside . + // Keep the banner out of the way so we don't double-show messaging. + const elapsed = daysSince(user.created_at) + if (elapsed > gracePeriodDays) return null + const handleResend = async () => { setIsSending(true) try { @@ -32,22 +69,29 @@ export function EmailVerificationBanner() { } return ( -
- - +
+ + Your email is not verified. + + Go to dashboard + +
+ + )} + + {status === 'no-token' && ( + <> + +

+ Missing verification token +

+

+ The link you used doesn't include a verification token. + Try the link in your verification email again. +

+ + Go to dashboard + + + )} +
-
) } diff --git a/frontend/src/pages/__tests__/VerifyEmailPage.test.tsx b/frontend/src/pages/__tests__/VerifyEmailPage.test.tsx new file mode 100644 index 00000000..1c42076c --- /dev/null +++ b/frontend/src/pages/__tests__/VerifyEmailPage.test.tsx @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { HelmetProvider } from 'react-helmet-async' + +import { VerifyEmailPage } from '../VerifyEmailPage' +import { authApi } from '@/api/auth' +import { useAuthStore } from '@/store/authStore' +import type { User } from '@/types' + +vi.mock('@/api/auth', () => ({ + authApi: { + verifyEmail: vi.fn(), + sendVerificationEmail: vi.fn(), + me: vi.fn(), + }, +})) + +vi.mock('@/lib/toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +function makeUser(overrides: Partial = {}): User { + return { + id: 'user-1', + email: 'test@example.com', + name: 'Test User', + role: 'engineer', + is_super_admin: false, + is_active: true, + must_change_password: false, + account_id: 'acct-1', + account_role: 'engineer', + team_id: null, + created_at: '2026-05-01T00:00:00Z', + last_login: null, + phone: null, + job_title: null, + timezone: 'UTC', + avatar_url: null, + email_verified_at: null, + ...overrides, + } +} + +function renderPage(initialPath: string) { + return render( + + + + } /> + dashboard
} /> + + + , + ) +} + +describe('VerifyEmailPage', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + useAuthStore.setState({ + user: null, + token: null, + isAuthenticated: false, + }) + vi.mocked(authApi.me).mockResolvedValue( + makeUser({ email_verified_at: '2026-05-06T00:00:00Z' }), + ) + }) + + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + }) + + it('shows success and redirects on valid token', async () => { + useAuthStore.setState({ user: makeUser() }) + // Override fetchUser to avoid hitting axios/XHR in jsdom — the page calls + // it after a successful verify to refresh `email_verified_at`. + useAuthStore.setState({ fetchUser: vi.fn().mockResolvedValue(undefined) }) + vi.mocked(authApi.verifyEmail).mockResolvedValue(undefined) + + renderPage('/verify-email?token=valid-token') + + await waitFor(() => { + expect(authApi.verifyEmail).toHaveBeenCalledWith('valid-token') + }) + + await waitFor(() => { + expect(screen.getByText(/Email verified/i)).toBeInTheDocument() + }) + + // Advance past the redirect delay. + vi.advanceTimersByTime(2000) + + await waitFor(() => { + expect(screen.getByText('dashboard')).toBeInTheDocument() + }) + }) + + it('shows already-verified state when user is already verified', async () => { + useAuthStore.setState({ + user: makeUser({ email_verified_at: '2026-05-05T00:00:00Z' }), + }) + + renderPage('/verify-email?token=any-token') + + await waitFor(() => { + expect( + screen.getByText(/already verified/i), + ).toBeInTheDocument() + }) + + // The verify endpoint must NOT have been called when the user is already + // verified — that would burn a perfectly good token for no reason. + expect(authApi.verifyEmail).not.toHaveBeenCalled() + }) + + it('only calls verifyEmail once even if the effect re-runs (strict-mode guard)', async () => { + useAuthStore.setState({ user: makeUser() }) + useAuthStore.setState({ fetchUser: vi.fn().mockResolvedValue(undefined) }) + vi.mocked(authApi.verifyEmail).mockResolvedValue(undefined) + + const { rerender } = render( + + + + } /> + dashboard
} /> + + + , + ) + + // Force a re-render to simulate React 19 strict-mode double-invoke. + rerender( + + + + } /> + dashboard
} /> + + + , + ) + + await waitFor(() => { + expect(authApi.verifyEmail).toHaveBeenCalled() + }) + + expect(authApi.verifyEmail).toHaveBeenCalledTimes(1) + }) + + it('shows an error state with a resend CTA on invalid token', async () => { + useAuthStore.setState({ user: makeUser() }) + vi.mocked(authApi.verifyEmail).mockRejectedValue( + Object.assign(new Error('boom'), { + response: { data: { detail: 'Token expired' } }, + }), + ) + + renderPage('/verify-email?token=stale-token') + + await waitFor(() => { + expect(screen.getByText(/Verification failed/i)).toBeInTheDocument() + }) + expect(screen.getByText(/Token expired/i)).toBeInTheDocument() + expect(screen.getByTestId('resend-button')).toBeInTheDocument() + }) +}) -- 2.49.1 From 9b517d3320e965d18a133fec8e0161919bc123e7 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 22:54:10 -0400 Subject: [PATCH 12/29] feat(onboarding): add welcome wizard scaffold + Step 1 (Your shop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the groundwork for the post-signup welcome wizard (Phase 2, Task 38). Authed users hitting /welcome are routed to the next incomplete step based on users.onboarding_step_completed + users.onboarding_dismissed; refresh resumes correctly because every navigation persists state server-side first. Backend: - Expose onboarding_step_completed (Optional[int]) and onboarding_dismissed (bool) on UserResponse so /auth/me drives client-side routing without a separate fetch. Frontend: - WelcomeRouter handles the /welcome decision table (dismissed → /, completed >=3 → /, else next step). - WelcomeStep1 renders the "Your shop" form (company name pre-filled from accounts.name, team size 1-2/3-5/6-10/11-25/26+, role Owner/Lead Tech/Tech/Other). Continue PATCHes /users/me/onboarding-step with action=complete; Skip-this-step PATCHes action=skip; Skip-the-rest POSTs /users/me/onboarding-dismiss-rest. Each action refreshes the auth store before navigating so the router resumes correctly on the next visit. - onboardingApi.updateStep + dismissRest (typed against backend OnboardingStepRequest/Response schemas). - Routes mounted inside AppLayout so EmailVerificationBanner persists above each step per spec. - 11 vitest cases covering the routing decision table + Continue / Skip / Skip-the-rest / persist-failure paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/schemas/user.py | 2 + frontend/src/api/onboarding.ts | 48 ++++ frontend/src/pages/welcome/WelcomeRouter.tsx | 31 +++ frontend/src/pages/welcome/WelcomeStep1.tsx | 248 ++++++++++++++++++ .../welcome/__tests__/WelcomeRouter.test.tsx | 125 +++++++++ .../welcome/__tests__/WelcomeStep1.test.tsx | 189 +++++++++++++ frontend/src/router.tsx | 7 + frontend/src/types/user.ts | 2 + 8 files changed, 652 insertions(+) create mode 100644 frontend/src/pages/welcome/WelcomeRouter.tsx create mode 100644 frontend/src/pages/welcome/WelcomeStep1.tsx create mode 100644 frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx create mode 100644 frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 0c3162fc..81d7c8b3 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -58,6 +58,8 @@ class UserResponse(UserBase): timezone: str = "UTC" avatar_url: Optional[str] = None email_verified_at: Optional[datetime] = None + onboarding_step_completed: Optional[int] = None + onboarding_dismissed: bool = False class Config: from_attributes = True diff --git a/frontend/src/api/onboarding.ts b/frontend/src/api/onboarding.ts index 4f54e687..f5caa8f3 100644 --- a/frontend/src/api/onboarding.ts +++ b/frontend/src/api/onboarding.ts @@ -19,3 +19,51 @@ export async function getOnboardingStatus(): Promise { export async function dismissOnboarding(): Promise { await apiClient.post('/users/onboarding-status/dismiss') } + +// --- Welcome wizard (Phase 2) --------------------------------------------- + +export type WizardStep = 1 | 2 | 3 +export type WizardAction = 'complete' | 'skip' +export type TeamSizeBucket = '1-2' | '3-5' | '6-10' | '11-25' | '26+' +export type RoleAtSignup = 'owner' | 'lead_tech' | 'tech' | 'other' +export type PrimaryPsa = 'connectwise' | 'autotask' | 'halopsa' | 'none' + +export interface OnboardingStepData { + // Step 1 + company_name?: string + team_size_bucket?: TeamSizeBucket + role_at_signup?: RoleAtSignup + // Step 2 + primary_psa?: PrimaryPsa +} + +export interface OnboardingStepRequest { + step: WizardStep + action: WizardAction + data?: OnboardingStepData +} + +export interface OnboardingStepResponse { + onboarding_step_completed: number | null + onboarding_dismissed: boolean +} + +export const onboardingApi = { + getStatus: getOnboardingStatus, + dismiss: dismissOnboarding, + /** Persist welcome-wizard progress for the current user. */ + async updateStep(payload: OnboardingStepRequest): Promise { + const response = await apiClient.patch( + '/users/me/onboarding-step', + payload, + ) + return response.data + }, + /** Skip the rest of the welcome wizard — sets users.onboarding_dismissed=TRUE. */ + async dismissRest(): Promise { + const response = await apiClient.post( + '/users/me/onboarding-dismiss-rest', + ) + return response.data + }, +} diff --git a/frontend/src/pages/welcome/WelcomeRouter.tsx b/frontend/src/pages/welcome/WelcomeRouter.tsx new file mode 100644 index 00000000..024dbe97 --- /dev/null +++ b/frontend/src/pages/welcome/WelcomeRouter.tsx @@ -0,0 +1,31 @@ +import { Navigate } from 'react-router-dom' +import { useAuthStore } from '@/store/authStore' +import { PageLoader } from '@/components/common/PageLoader' + +/** + * `/welcome` index — redirect to the next incomplete step (or `/` if done / + * dismissed). Decision table: + * + * onboarding_dismissed === true → / + * onboarding_step_completed >= 3 → / + * onboarding_step_completed === null/0 → /welcome/step-1 + * onboarding_step_completed === 1 → /welcome/step-2 + * onboarding_step_completed === 2 → /welcome/step-3 + */ +export function WelcomeRouter() { + const user = useAuthStore((s) => s.user) + + // Auth gate sits above us — but if the user object is still loading, render + // the page loader rather than racing past the redirect. + if (!user) return + + if (user.onboarding_dismissed) return + + const completed = user.onboarding_step_completed ?? 0 + if (completed >= 3) return + if (completed === 2) return + if (completed === 1) return + return +} + +export default WelcomeRouter diff --git a/frontend/src/pages/welcome/WelcomeStep1.tsx b/frontend/src/pages/welcome/WelcomeStep1.tsx new file mode 100644 index 00000000..412217e9 --- /dev/null +++ b/frontend/src/pages/welcome/WelcomeStep1.tsx @@ -0,0 +1,248 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { Loader2 } from 'lucide-react' +import { useAuthStore } from '@/store/authStore' +import { + onboardingApi, + type RoleAtSignup, + type TeamSizeBucket, +} from '@/api/onboarding' +import { cn } from '@/lib/utils' + +const TEAM_SIZE_OPTIONS: { value: TeamSizeBucket; label: string }[] = [ + { value: '1-2', label: '1–2' }, + { value: '3-5', label: '3–5' }, + { value: '6-10', label: '6–10' }, + { value: '11-25', label: '11–25' }, + { value: '26+', label: '26+' }, +] + +const ROLE_OPTIONS: { value: RoleAtSignup; label: string }[] = [ + { value: 'owner', label: 'Owner' }, + { value: 'lead_tech', label: 'Lead Tech' }, + { value: 'tech', label: 'Tech' }, + { value: 'other', label: 'Other' }, +] + +/** + * `/welcome/step-1` — first step of the welcome wizard. Captures shop context + * (company name, team size, role). Persists server-side before navigating. + */ +export function WelcomeStep1() { + const navigate = useNavigate() + const account = useAuthStore((s) => s.account) + const fetchUser = useAuthStore((s) => s.fetchUser) + + const [companyName, setCompanyName] = useState(account?.name ?? '') + const [teamSize, setTeamSize] = useState('') + const [role, setRole] = useState('') + const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null) + const [error, setError] = useState(null) + + const isBusy = submitting !== null + + const handleContinue = async (e: FormEvent) => { + e.preventDefault() + if (isBusy) return + setError(null) + setSubmitting('continue') + try { + await onboardingApi.updateStep({ + step: 1, + action: 'complete', + data: { + company_name: companyName.trim() || undefined, + team_size_bucket: teamSize || undefined, + role_at_signup: role || undefined, + }, + }) + await fetchUser() + navigate('/welcome/step-2') + } catch { + setError('Could not save. Please try again.') + setSubmitting(null) + } + } + + const handleSkipStep = async () => { + if (isBusy) return + setError(null) + setSubmitting('skip') + try { + await onboardingApi.updateStep({ step: 1, action: 'skip' }) + await fetchUser() + navigate('/welcome/step-2') + } catch { + setError('Could not save. Please try again.') + setSubmitting(null) + } + } + + const handleDismissRest = async () => { + if (isBusy) return + setError(null) + setSubmitting('dismiss') + try { + await onboardingApi.dismissRest() + await fetchUser() + navigate('/') + } catch { + setError('Could not save. Please try again.') + setSubmitting(null) + } + } + + const inputClass = 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', + ) + + return ( +
+
+

+ Step 1 of 3 +

+

+ Your shop +

+

+ A couple of quick questions so we can tailor ResolutionFlow to your team. +

+
+ +
+
+ + setCompanyName(e.target.value)} + className={inputClass} + placeholder="Acme MSP" + data-testid="welcome-step-1-company-name" + /> +
+ +
+ + +
+ +
+ + +
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+ +
+ +
+
+ ) +} + +export default WelcomeStep1 diff --git a/frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx new file mode 100644 index 00000000..94c81f30 --- /dev/null +++ b/frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import { WelcomeRouter } from '../WelcomeRouter' +import { useAuthStore } from '@/store/authStore' +import type { User } from '@/types' + +function makeUser(overrides: Partial = {}): User { + return { + id: 'user-1', + email: 'test@example.com', + name: 'Test User', + role: 'engineer', + is_super_admin: false, + is_active: true, + must_change_password: false, + account_id: 'acct-1', + account_role: 'owner', + team_id: null, + created_at: '2026-05-01T00:00:00Z', + last_login: null, + phone: null, + job_title: null, + timezone: 'UTC', + avatar_url: null, + email_verified_at: null, + onboarding_step_completed: null, + onboarding_dismissed: false, + ...overrides, + } +} + +function renderRouter() { + return render( + + + } /> + step-1
} /> + step-2} /> + step-3} /> + dashboard} /> + + , + ) +} + +describe('WelcomeRouter', () => { + beforeEach(() => { + useAuthStore.setState({ + user: null, + account: null, + subscription: null, + token: null, + isAuthenticated: false, + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('redirects to step-1 on null onboarding_step_completed', async () => { + useAuthStore.setState({ + user: makeUser({ onboarding_step_completed: null }), + }) + renderRouter() + await waitFor(() => { + expect(screen.getByText('step-1')).toBeInTheDocument() + }) + }) + + it('redirects to step-1 when onboarding_step_completed is 0', async () => { + useAuthStore.setState({ + user: makeUser({ onboarding_step_completed: 0 }), + }) + renderRouter() + await waitFor(() => { + expect(screen.getByText('step-1')).toBeInTheDocument() + }) + }) + + it('redirects to step-2 when onboarding_step_completed is 1', async () => { + useAuthStore.setState({ + user: makeUser({ onboarding_step_completed: 1 }), + }) + renderRouter() + await waitFor(() => { + expect(screen.getByText('step-2')).toBeInTheDocument() + }) + }) + + it('redirects to step-3 when onboarding_step_completed is 2', async () => { + useAuthStore.setState({ + user: makeUser({ onboarding_step_completed: 2 }), + }) + renderRouter() + await waitFor(() => { + expect(screen.getByText('step-3')).toBeInTheDocument() + }) + }) + + it('redirects to / when onboarding_step_completed >= 3', async () => { + useAuthStore.setState({ + user: makeUser({ onboarding_step_completed: 3 }), + }) + renderRouter() + await waitFor(() => { + expect(screen.getByText('dashboard')).toBeInTheDocument() + }) + }) + + it('redirects to / when onboarding_dismissed is true', async () => { + useAuthStore.setState({ + user: makeUser({ + onboarding_step_completed: 1, + onboarding_dismissed: true, + }), + }) + renderRouter() + await waitFor(() => { + expect(screen.getByText('dashboard')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx new file mode 100644 index 00000000..93483add --- /dev/null +++ b/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import { WelcomeStep1 } from '../WelcomeStep1' +import { useAuthStore } from '@/store/authStore' +import { onboardingApi } from '@/api/onboarding' +import type { Account, User } from '@/types' + +vi.mock('@/api/onboarding', async () => { + const actual = await vi.importActual( + '@/api/onboarding', + ) + return { + ...actual, + onboardingApi: { + ...actual.onboardingApi, + updateStep: vi.fn(), + dismissRest: vi.fn(), + }, + } +}) + +function makeUser(overrides: Partial = {}): User { + return { + id: 'user-1', + email: 'test@example.com', + name: 'Test User', + role: 'engineer', + is_super_admin: false, + is_active: true, + must_change_password: false, + account_id: 'acct-1', + account_role: 'owner', + team_id: null, + created_at: '2026-05-01T00:00:00Z', + last_login: null, + phone: null, + job_title: null, + timezone: 'UTC', + avatar_url: null, + email_verified_at: null, + onboarding_step_completed: null, + onboarding_dismissed: false, + ...overrides, + } +} + +function makeAccount(overrides: Partial = {}): Account { + return { + id: 'acct-1', + name: 'Acme MSP', + display_code: 'ACME', + owner_id: 'user-1', + created_at: '2026-05-01T00:00:00Z', + updated_at: '2026-05-01T00:00:00Z', + ...overrides, + } +} + +function renderPage() { + return render( + + + } /> + step-2} /> + dashboard} /> + + , + ) +} + +describe('WelcomeStep1', () => { + beforeEach(() => { + useAuthStore.setState({ + user: makeUser(), + account: makeAccount(), + subscription: null, + token: null, + isAuthenticated: true, + // Stub fetchUser so it doesn't try to hit the network in jsdom. + fetchUser: vi.fn().mockResolvedValue(undefined), + }) + vi.mocked(onboardingApi.updateStep).mockResolvedValue({ + onboarding_step_completed: 1, + onboarding_dismissed: false, + }) + vi.mocked(onboardingApi.dismissRest).mockResolvedValue({ + onboarding_step_completed: null, + onboarding_dismissed: true, + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('pre-fills the company name from the auth store account', () => { + renderPage() + const input = screen.getByTestId('welcome-step-1-company-name') as HTMLInputElement + expect(input.value).toBe('Acme MSP') + }) + + it('Continue persists data and navigates to /welcome/step-2', async () => { + const user = userEvent.setup() + renderPage() + + const teamSize = screen.getByTestId('welcome-step-1-team-size') as HTMLSelectElement + await user.selectOptions(teamSize, '3-5') + const role = screen.getByTestId('welcome-step-1-role') as HTMLSelectElement + await user.selectOptions(role, 'owner') + + await user.click(screen.getByTestId('welcome-step-1-continue')) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 1, + action: 'complete', + data: { + company_name: 'Acme MSP', + team_size_bucket: '3-5', + role_at_signup: 'owner', + }, + }) + }) + + await waitFor(() => { + expect(screen.getByText('step-2')).toBeInTheDocument() + }) + }) + + it('Skip this step calls updateStep with action=skip and navigates to /welcome/step-2', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-1-skip')) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 1, + action: 'skip', + }) + }) + + await waitFor(() => { + expect(screen.getByText('step-2')).toBeInTheDocument() + }) + }) + + it('Skip-the-rest dismisses and navigates to /', async () => { + const user = userEvent.setup() + renderPage() + + const dismiss = screen.getByTestId('welcome-step-1-dismiss-rest') + // Sanity check: it's a quiet text link, not a primary button. + expect(dismiss.className).toMatch(/text-muted-foreground/) + expect(dismiss.className).toMatch(/hover:underline/) + expect(dismiss.className).toMatch(/text-xs/) + expect(dismiss.className).not.toMatch(/bg-primary/) + + await user.click(dismiss) + + await waitFor(() => { + expect(onboardingApi.dismissRest).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(screen.getByText('dashboard')).toBeInTheDocument() + }) + }) + + it('shows an error when the persist call fails and stays on the page', async () => { + vi.mocked(onboardingApi.updateStep).mockRejectedValueOnce( + new Error('boom'), + ) + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-1-continue')) + + await waitFor(() => { + expect(screen.getByTestId('welcome-step-1-error')).toBeInTheDocument() + }) + + // Should not have navigated. + expect(screen.queryByText('step-2')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 401b4969..423820a5 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -67,6 +67,9 @@ const DevBranchingPage = lazyWithRetry(() => import('@/pages/DevBranchingPage')) const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage')) const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage')) const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsPage')) +// Welcome wizard (Phase 2) +const WelcomeRouter = lazyWithRetry(() => import('@/pages/welcome/WelcomeRouter')) +const WelcomeStep1 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep1')) const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams')) const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor')) // Admin pages @@ -240,6 +243,10 @@ export const router = sentryCreateBrowserRouter([ { path: 'dev/branching', element: page(DevBranchingPage) }, { path: 'guides', element: page(GuidesHubPage) }, { path: 'guides/:slug', element: page(GuideDetailPage) }, + // Welcome wizard (Phase 2). Mounted inside AppLayout so the email- + // verification banner persists above each step. + { path: 'welcome', element: page(WelcomeRouter) }, + { path: 'welcome/step-1', element: page(WelcomeStep1) }, // Admin routes { path: 'admin', diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 8f65b34f..f708d00f 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -18,6 +18,8 @@ export interface User { timezone: string avatar_url: string | null email_verified_at: string | null + onboarding_step_completed: number | null + onboarding_dismissed: boolean } export interface UserCreate { -- 2.49.1 From 53dd5f13e54bc145ebe193af239bb34c80da6c17 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 23:02:00 -0400 Subject: [PATCH 13/29] feat(onboarding): add wizard Steps 2 (PSA) and 3 (Invite team) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 (`/welcome/step-2`): four PSA tiles (ConnectWise / Autotask / HaloPSA / No PSA yet). Selecting a real PSA reveals a quiet inline "Connect now" link to `/account/integrations` — credential entry is intentionally OUT of the wizard. Continue persists `primary_psa`, Skip advances without writing. Step 3 (`/welcome/step-3`): up to 10 email/role rows (default 3, "+ Add another" extends, role defaults to Tech / engineer with Viewer alt). "Send invites and continue" filters empty rows, POSTs `/accounts/me/invites/bulk`, then PATCHes onboarding-step `{step:3, action:"complete"}` and navigates to `/?welcome=true`. Per-row `failed[]` errors render inline next to the email and the wizard does NOT auto-advance — user can fix-and-retry or click "Continue anyway" to mark step complete. Empty + Skip / empty + Send both advance without sending. Adds `accountsApi.bulkInvite` and registers `/welcome/step-{2,3}` in the router. Vitest: 5 named tests (selecting PSA persists, Skip advances without primary_psa, valid emails create invites, partial-failure inline error, empty + Skip no-op) + 5 incidental coverage tests. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/api/accounts.ts | 28 ++ frontend/src/pages/welcome/WelcomeStep2.tsx | 208 ++++++++++ frontend/src/pages/welcome/WelcomeStep3.tsx | 374 ++++++++++++++++++ .../welcome/__tests__/WelcomeStep2.test.tsx | 174 ++++++++ .../welcome/__tests__/WelcomeStep3.test.tsx | 279 +++++++++++++ frontend/src/router.tsx | 4 + 6 files changed, 1067 insertions(+) create mode 100644 frontend/src/pages/welcome/WelcomeStep2.tsx create mode 100644 frontend/src/pages/welcome/WelcomeStep3.tsx create mode 100644 frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx create mode 100644 frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx diff --git a/frontend/src/api/accounts.ts b/frontend/src/api/accounts.ts index 44db6b4e..7311b25d 100644 --- a/frontend/src/api/accounts.ts +++ b/frontend/src/api/accounts.ts @@ -1,6 +1,22 @@ import apiClient from './client' import type { Account, SubscriptionDetails, AccountMember, AccountInvite } from '@/types' +export interface BulkInviteRow { + email: string + role: 'engineer' | 'viewer' + expires_in_days?: number +} + +export interface BulkInviteFailure { + email: string + error: string +} + +export interface BulkInviteResponse { + created: AccountInvite[] + failed: BulkInviteFailure[] +} + export const accountsApi = { async getMyAccount(): Promise { const response = await apiClient.get('/accounts/me') @@ -39,6 +55,18 @@ export const accountsApi = { return response.data }, + /** + * Create multiple invites in one call (used by the welcome wizard step 3). + * Per-row failures land in `failed[]`; successes in `created[]`. + */ + async bulkInvite(invites: BulkInviteRow[]): Promise { + const response = await apiClient.post( + '/accounts/me/invites/bulk', + { invites }, + ) + return response.data + }, + async getInvites(): Promise { const response = await apiClient.get('/accounts/me/invites') return response.data diff --git a/frontend/src/pages/welcome/WelcomeStep2.tsx b/frontend/src/pages/welcome/WelcomeStep2.tsx new file mode 100644 index 00000000..1b803894 --- /dev/null +++ b/frontend/src/pages/welcome/WelcomeStep2.tsx @@ -0,0 +1,208 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { Loader2 } from 'lucide-react' +import { useAuthStore } from '@/store/authStore' +import { onboardingApi, type PrimaryPsa } from '@/api/onboarding' +import { cn } from '@/lib/utils' + +const PSA_OPTIONS: { value: PrimaryPsa; label: string; description: string }[] = [ + { value: 'connectwise', label: 'ConnectWise', description: 'Manage / PSA' }, + { value: 'autotask', label: 'Autotask', description: 'Datto Autotask PSA' }, + { value: 'halopsa', label: 'HaloPSA', description: 'Halo Service Solutions' }, + { value: 'none', label: 'No PSA yet', description: "We'll add one later" }, +] + +/** + * `/welcome/step-2` — second step of the welcome wizard. Captures the PSA the + * shop primarily uses. Selecting a non-`none` tile reveals a quiet "Connect + * now" link that navigates out to `/account/integrations`. The wizard's + * primary action is "Continue" — credential entry is intentionally OUT of + * the wizard (per spec). + */ +export function WelcomeStep2() { + const navigate = useNavigate() + const fetchUser = useAuthStore((s) => s.fetchUser) + + const [primaryPsa, setPrimaryPsa] = useState(null) + const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null) + const [error, setError] = useState(null) + + const isBusy = submitting !== null + const showConnectNow = primaryPsa !== null && primaryPsa !== 'none' + + const handleContinue = async () => { + if (isBusy) return + setError(null) + setSubmitting('continue') + try { + await onboardingApi.updateStep({ + step: 2, + action: 'complete', + data: primaryPsa ? { primary_psa: primaryPsa } : undefined, + }) + await fetchUser() + navigate('/welcome/step-3') + } catch { + setError('Could not save. Please try again.') + setSubmitting(null) + } + } + + const handleSkipStep = async () => { + if (isBusy) return + setError(null) + setSubmitting('skip') + try { + await onboardingApi.updateStep({ step: 2, action: 'skip' }) + await fetchUser() + navigate('/welcome/step-3') + } catch { + setError('Could not save. Please try again.') + setSubmitting(null) + } + } + + const handleDismissRest = async () => { + if (isBusy) return + setError(null) + setSubmitting('dismiss') + try { + await onboardingApi.dismissRest() + await fetchUser() + navigate('/') + } catch { + setError('Could not save. Please try again.') + setSubmitting(null) + } + } + + return ( +
+
+

+ Step 2 of 3 +

+

+ Your PSA +

+

+ Pick the PSA your team uses today. We'll wire it up later — no + credentials needed yet. +

+
+ +
+
+ {PSA_OPTIONS.map((opt) => { + const selected = primaryPsa === opt.value + return ( + + ) + })} +
+ + {showConnectNow && ( +
+ + Connect now → + +
+ )} + + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+ +
+ +
+
+ ) +} + +export default WelcomeStep2 diff --git a/frontend/src/pages/welcome/WelcomeStep3.tsx b/frontend/src/pages/welcome/WelcomeStep3.tsx new file mode 100644 index 00000000..d3659037 --- /dev/null +++ b/frontend/src/pages/welcome/WelcomeStep3.tsx @@ -0,0 +1,374 @@ +import { useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Loader2, Plus, X } from 'lucide-react' +import { useAuthStore } from '@/store/authStore' +import { onboardingApi } from '@/api/onboarding' +import { accountsApi, type BulkInviteRow } from '@/api/accounts' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' + +const MAX_ROWS = 10 +const DEFAULT_ROW_COUNT = 3 + +type RowRole = 'engineer' | 'viewer' + +interface InviteRow { + email: string + role: RowRole + /** + * Server-returned per-row error (from `failed[]`). Kept on the row so + * users can fix and retry without losing the rest of their input. + */ + error?: string +} + +const ROLE_OPTIONS: { value: RowRole; label: string }[] = [ + { value: 'engineer', label: 'Tech' }, + { value: 'viewer', label: 'Viewer' }, +] + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +function makeEmptyRow(): InviteRow { + return { email: '', role: 'engineer' } +} + +/** + * `/welcome/step-3` — final step of the welcome wizard. Captures up to + * `MAX_ROWS` teammate invites. On submit: + * + * 1. POST `/accounts/me/invites/bulk` with populated rows. + * 2. PATCH `/users/me/onboarding-step` `{step: 3, action: "complete"}`. + * 3. Navigate to `/?welcome=true` and fire a "You're all set" toast. + * + * Partial-failure UX: rows in `failed[]` keep their input and show an + * inline error. The wizard does NOT auto-advance when there are failures — + * the user can edit and retry, OR click "Continue anyway" to mark step 3 + * complete and head to the dashboard. + * + * Empty rows are filtered before submit, so empty-form + "Send" is a no-op + * that just marks the step complete. (Skip does the same with `action: skip`.) + */ +export function WelcomeStep3() { + const navigate = useNavigate() + const fetchUser = useAuthStore((s) => s.fetchUser) + + const [rows, setRows] = useState(() => + Array.from({ length: DEFAULT_ROW_COUNT }, makeEmptyRow), + ) + const [submitting, setSubmitting] = useState< + 'send' | 'skip' | 'dismiss' | 'continue-anyway' | null + >(null) + const [error, setError] = useState(null) + const [hasUnresolvedFailures, setHasUnresolvedFailures] = useState(false) + + const isBusy = submitting !== null + + const updateRow = (idx: number, patch: Partial) => { + setRows((prev) => + prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)), + ) + } + + const removeRow = (idx: number) => { + setRows((prev) => { + if (prev.length <= 1) return [makeEmptyRow()] + return prev.filter((_, i) => i !== idx) + }) + } + + const addRow = () => { + setRows((prev) => + prev.length >= MAX_ROWS ? prev : [...prev, makeEmptyRow()], + ) + } + + /** + * Validate populated rows. Empty-email rows are dropped silently. + * Returns either the list of valid rows OR a per-index error map. + */ + const validatePopulated = useMemo( + () => () => { + const errs: Record = {} + const populated: { idx: number; row: BulkInviteRow }[] = [] + rows.forEach((row, idx) => { + const email = row.email.trim() + if (!email) return + if (!EMAIL_RE.test(email)) { + errs[idx] = 'Invalid email' + return + } + populated.push({ idx, row: { email, role: row.role } }) + }) + return { errs, populated } + }, + [rows], + ) + + const completeWizardAndExit = async () => { + await onboardingApi.updateStep({ step: 3, action: 'complete' }) + await fetchUser() + toast.success("You're all set!") + navigate('/?welcome=true') + } + + const handleSendInvites = async () => { + if (isBusy) return + setError(null) + + const { errs, populated } = validatePopulated() + if (Object.keys(errs).length > 0) { + // Surface client-side validation errors inline. + setRows((prev) => + prev.map((row, idx) => + errs[idx] ? { ...row, error: errs[idx] } : { ...row, error: undefined }, + ), + ) + return + } + + setSubmitting('send') + try { + let failedSet = new Map() + if (populated.length > 0) { + const result = await accountsApi.bulkInvite(populated.map((p) => p.row)) + failedSet = new Map(result.failed.map((f) => [f.email, f.error])) + } + + if (failedSet.size > 0) { + // Stamp errors on the matching rows; do NOT auto-advance. + setRows((prev) => + prev.map((row) => { + const email = row.email.trim() + const err = email ? failedSet.get(email) : undefined + return { ...row, error: err } + }), + ) + setHasUnresolvedFailures(true) + setSubmitting(null) + return + } + + // All-clear (or zero invites sent): mark step complete and exit. + await completeWizardAndExit() + } catch { + setError('Could not send invites. Please try again.') + setSubmitting(null) + } + } + + const handleContinueAnyway = async () => { + if (isBusy) return + setError(null) + setSubmitting('continue-anyway') + try { + await completeWizardAndExit() + } catch { + setError('Could not save. Please try again.') + setSubmitting(null) + } + } + + const handleSkipStep = async () => { + if (isBusy) return + setError(null) + setSubmitting('skip') + try { + await onboardingApi.updateStep({ step: 3, action: 'skip' }) + await fetchUser() + toast.success("You're all set!") + navigate('/?welcome=true') + } catch { + setError('Could not save. Please try again.') + setSubmitting(null) + } + } + + const handleDismissRest = async () => { + if (isBusy) return + setError(null) + setSubmitting('dismiss') + try { + await onboardingApi.dismissRest() + await fetchUser() + navigate('/') + } catch { + setError('Could not save. Please try again.') + setSubmitting(null) + } + } + + const inputClass = cn( + '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', + ) + + return ( +
+
+

+ Step 3 of 3 +

+

+ Invite your team +

+

+ Add up to {MAX_ROWS} teammates. They'll get an email with a link to + join. Leave blank to do this later. +

+
+ +
+
+ {rows.map((row, idx) => ( +
+
+ updateRow(idx, { email: e.target.value, error: undefined })} + placeholder="teammate@example.com" + className={cn(inputClass, 'flex-1')} + data-testid={`welcome-step-3-email-${idx}`} + disabled={isBusy} + /> + + +
+ {row.error && ( +

+ {row.error} +

+ )} +
+ ))} +
+ + + + {error && ( +

+ {error} +

+ )} + +
+ + {hasUnresolvedFailures && ( + + )} + +
+
+ +
+ +
+
+ ) +} + +export default WelcomeStep3 diff --git a/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx new file mode 100644 index 00000000..57b2a9bd --- /dev/null +++ b/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import { WelcomeStep2 } from '../WelcomeStep2' +import { useAuthStore } from '@/store/authStore' +import { onboardingApi } from '@/api/onboarding' +import type { Account, User } from '@/types' + +vi.mock('@/api/onboarding', async () => { + const actual = await vi.importActual( + '@/api/onboarding', + ) + return { + ...actual, + onboardingApi: { + ...actual.onboardingApi, + updateStep: vi.fn(), + dismissRest: vi.fn(), + }, + } +}) + +function makeUser(overrides: Partial = {}): User { + return { + id: 'user-1', + email: 'test@example.com', + name: 'Test User', + role: 'engineer', + is_super_admin: false, + is_active: true, + must_change_password: false, + account_id: 'acct-1', + account_role: 'owner', + team_id: null, + created_at: '2026-05-01T00:00:00Z', + last_login: null, + phone: null, + job_title: null, + timezone: 'UTC', + avatar_url: null, + email_verified_at: null, + onboarding_step_completed: 1, + onboarding_dismissed: false, + ...overrides, + } +} + +function makeAccount(overrides: Partial = {}): Account { + return { + id: 'acct-1', + name: 'Acme MSP', + display_code: 'ACME', + owner_id: 'user-1', + created_at: '2026-05-01T00:00:00Z', + updated_at: '2026-05-01T00:00:00Z', + ...overrides, + } +} + +function renderPage() { + return render( + + + } /> + step-3} /> + integrations} /> + dashboard} /> + + , + ) +} + +describe('WelcomeStep2', () => { + beforeEach(() => { + useAuthStore.setState({ + user: makeUser(), + account: makeAccount(), + subscription: null, + token: null, + isAuthenticated: true, + fetchUser: vi.fn().mockResolvedValue(undefined), + }) + vi.mocked(onboardingApi.updateStep).mockResolvedValue({ + onboarding_step_completed: 2, + onboarding_dismissed: false, + }) + vi.mocked(onboardingApi.dismissRest).mockResolvedValue({ + onboarding_step_completed: null, + onboarding_dismissed: true, + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('selecting PSA persists primary_psa', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-2-tile-connectwise')) + // Selecting a real PSA reveals the inline "Connect now" link. + expect(screen.getByTestId('welcome-step-2-connect-now')).toBeInTheDocument() + + await user.click(screen.getByTestId('welcome-step-2-continue')) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 2, + action: 'complete', + data: { primary_psa: 'connectwise' }, + }) + }) + + await waitFor(() => { + expect(screen.getByText('step-3')).toBeInTheDocument() + }) + }) + + it('Skip advances without writing primary_psa', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-2-skip')) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 2, + action: 'skip', + }) + }) + + // Confirm no `data` key on the call (skip doesn't persist primary_psa). + const call = vi.mocked(onboardingApi.updateStep).mock.calls[0]?.[0] + expect(call?.data).toBeUndefined() + + await waitFor(() => { + expect(screen.getByText('step-3')).toBeInTheDocument() + }) + }) + + it('"No PSA yet" tile does NOT show the Connect now link', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-2-tile-none')) + expect(screen.queryByTestId('welcome-step-2-connect-now')).not.toBeInTheDocument() + }) + + it('default action is Continue (not Connect now)', () => { + renderPage() + // Continue is rendered as a primary button. + const continueBtn = screen.getByTestId('welcome-step-2-continue') + expect(continueBtn.className).toMatch(/bg-primary/) + // Connect-now is hidden until a real PSA is picked. + expect(screen.queryByTestId('welcome-step-2-connect-now')).not.toBeInTheDocument() + }) + + it('Skip-the-rest dismisses and navigates to /', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-2-dismiss-rest')) + + await waitFor(() => { + expect(onboardingApi.dismissRest).toHaveBeenCalled() + }) + await waitFor(() => { + expect(screen.getByText('dashboard')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx new file mode 100644 index 00000000..c90be2dd --- /dev/null +++ b/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx @@ -0,0 +1,279 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import { WelcomeStep3 } from '../WelcomeStep3' +import { useAuthStore } from '@/store/authStore' +import { onboardingApi } from '@/api/onboarding' +import { accountsApi } from '@/api/accounts' +import type { Account, User } from '@/types' + +vi.mock('@/api/onboarding', async () => { + const actual = await vi.importActual( + '@/api/onboarding', + ) + return { + ...actual, + onboardingApi: { + ...actual.onboardingApi, + updateStep: vi.fn(), + dismissRest: vi.fn(), + }, + } +}) + +vi.mock('@/api/accounts', async () => { + const actual = await vi.importActual( + '@/api/accounts', + ) + return { + ...actual, + accountsApi: { + ...actual.accountsApi, + bulkInvite: vi.fn(), + }, + } +}) + +vi.mock('@/lib/toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + promise: vi.fn(), + }, +})) + +function makeUser(overrides: Partial = {}): User { + return { + id: 'user-1', + email: 'test@example.com', + name: 'Test User', + role: 'engineer', + is_super_admin: false, + is_active: true, + must_change_password: false, + account_id: 'acct-1', + account_role: 'owner', + team_id: null, + created_at: '2026-05-01T00:00:00Z', + last_login: null, + phone: null, + job_title: null, + timezone: 'UTC', + avatar_url: null, + email_verified_at: null, + onboarding_step_completed: 2, + onboarding_dismissed: false, + ...overrides, + } +} + +function makeAccount(overrides: Partial = {}): Account { + return { + id: 'acct-1', + name: 'Acme MSP', + display_code: 'ACME', + owner_id: 'user-1', + created_at: '2026-05-01T00:00:00Z', + updated_at: '2026-05-01T00:00:00Z', + ...overrides, + } +} + +function renderPage() { + return render( + + + } /> + dashboard} /> + + , + ) +} + +describe('WelcomeStep3', () => { + beforeEach(() => { + useAuthStore.setState({ + user: makeUser(), + account: makeAccount(), + subscription: null, + token: null, + isAuthenticated: true, + fetchUser: vi.fn().mockResolvedValue(undefined), + }) + vi.mocked(onboardingApi.updateStep).mockResolvedValue({ + onboarding_step_completed: 3, + onboarding_dismissed: false, + }) + vi.mocked(onboardingApi.dismissRest).mockResolvedValue({ + onboarding_step_completed: null, + onboarding_dismissed: true, + }) + vi.mocked(accountsApi.bulkInvite).mockResolvedValue({ + created: [], + failed: [], + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('valid emails create invites and complete wizard', async () => { + const user = userEvent.setup() + vi.mocked(accountsApi.bulkInvite).mockResolvedValueOnce({ + created: [ + { + id: 'inv-1', + account_id: 'acct-1', + email: 'a@example.com', + role: 'engineer', + code: 'c1', + expires_at: null, + used_at: null, + created_at: '2026-05-06T00:00:00Z', + }, + { + id: 'inv-2', + account_id: 'acct-1', + email: 'b@example.com', + role: 'viewer', + code: 'c2', + expires_at: null, + used_at: null, + created_at: '2026-05-06T00:00:00Z', + }, + ], + failed: [], + }) + renderPage() + + await user.type(screen.getByTestId('welcome-step-3-email-0'), 'a@example.com') + await user.type(screen.getByTestId('welcome-step-3-email-1'), 'b@example.com') + await user.selectOptions(screen.getByTestId('welcome-step-3-role-1'), 'viewer') + + await user.click(screen.getByTestId('welcome-step-3-send')) + + await waitFor(() => { + expect(accountsApi.bulkInvite).toHaveBeenCalledWith([ + { email: 'a@example.com', role: 'engineer' }, + { email: 'b@example.com', role: 'viewer' }, + ]) + }) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 3, + action: 'complete', + }) + }) + + await waitFor(() => { + expect(screen.getByText('dashboard')).toBeInTheDocument() + }) + }) + + it('partial-failure shows inline error per failed email', async () => { + const user = userEvent.setup() + vi.mocked(accountsApi.bulkInvite).mockResolvedValueOnce({ + created: [ + { + id: 'inv-1', + account_id: 'acct-1', + email: 'good@example.com', + role: 'engineer', + code: 'c1', + expires_at: null, + used_at: null, + created_at: '2026-05-06T00:00:00Z', + }, + ], + failed: [ + { email: 'bad@example.com', error: 'Email already invited' }, + ], + }) + renderPage() + + await user.type(screen.getByTestId('welcome-step-3-email-0'), 'good@example.com') + await user.type(screen.getByTestId('welcome-step-3-email-1'), 'bad@example.com') + + await user.click(screen.getByTestId('welcome-step-3-send')) + + await waitFor(() => { + expect(accountsApi.bulkInvite).toHaveBeenCalled() + }) + + // The bad-email row shows the error text. + await waitFor(() => { + expect(screen.getByTestId('welcome-step-3-row-error-1')).toHaveTextContent( + /already invited/i, + ) + }) + + // Wizard did NOT auto-advance — onboarding-step is unchanged. + expect(onboardingApi.updateStep).not.toHaveBeenCalled() + expect(screen.queryByText('dashboard')).not.toBeInTheDocument() + + // "Continue anyway" is offered. + expect(screen.getByTestId('welcome-step-3-continue-anyway')).toBeInTheDocument() + }) + + it('empty + Skip advances without sending invites', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-3-skip')) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 3, + action: 'skip', + }) + }) + + // No bulk-invite call. + expect(accountsApi.bulkInvite).not.toHaveBeenCalled() + + await waitFor(() => { + expect(screen.getByText('dashboard')).toBeInTheDocument() + }) + }) + + it('empty + Send is a no-op bulk call but still completes the step', async () => { + const user = userEvent.setup() + renderPage() + + // All rows blank — Send should skip the bulk call entirely and just + // mark the step complete. + await user.click(screen.getByTestId('welcome-step-3-send')) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 3, + action: 'complete', + }) + }) + expect(accountsApi.bulkInvite).not.toHaveBeenCalled() + }) + + it('+ Add another adds a row, capped at 10', async () => { + const user = userEvent.setup() + renderPage() + + // Starts with 3 default rows. + expect(screen.getByTestId('welcome-step-3-email-0')).toBeInTheDocument() + expect(screen.getByTestId('welcome-step-3-email-1')).toBeInTheDocument() + expect(screen.getByTestId('welcome-step-3-email-2')).toBeInTheDocument() + expect(screen.queryByTestId('welcome-step-3-email-3')).not.toBeInTheDocument() + + const addBtn = screen.getByTestId('welcome-step-3-add-row') + // Click 7 more times → 10 total. + for (let i = 0; i < 7; i++) await user.click(addBtn) + expect(screen.getByTestId('welcome-step-3-email-9')).toBeInTheDocument() + // Capped — button disabled at 10. + expect(addBtn).toBeDisabled() + }) +}) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 423820a5..235ef710 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -70,6 +70,8 @@ const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsP // Welcome wizard (Phase 2) const WelcomeRouter = lazyWithRetry(() => import('@/pages/welcome/WelcomeRouter')) const WelcomeStep1 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep1')) +const WelcomeStep2 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep2')) +const WelcomeStep3 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep3')) const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams')) const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor')) // Admin pages @@ -247,6 +249,8 @@ export const router = sentryCreateBrowserRouter([ // verification banner persists above each step. { path: 'welcome', element: page(WelcomeRouter) }, { path: 'welcome/step-1', element: page(WelcomeStep1) }, + { path: 'welcome/step-2', element: page(WelcomeStep2) }, + { path: 'welcome/step-3', element: page(WelcomeStep3) }, // Admin routes { path: 'admin', -- 2.49.1 From 99343ab7a9b4df14777d1927270fcf35cecda7a1 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 23:06:09 -0400 Subject: [PATCH 14/29] feat(dashboard): add TrialPill in AppLayout topbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts a billing-state pill in the topbar that reads useTrialBanner() and renders the appropriate label / tone / CTA per spec: - pristine / warning → "Pro trial · Nd" (info → warning amber as days drop) - urgent → "Pro trial · today" (warning amber, semibold) - expired → "Trial expired — pick a plan" → /account/billing/select-plan - paid → planBilling.display_name (quiet) - complimentary → "Complimentary Pro" (accent, no CTA) - past_due → "Payment failed — update card" → /account/billing - canceled → "Reactivate" → /account/billing/select-plan - null → hidden Uses existing design-system tokens only (text-info/bg-info-dim, text-warning/bg-warning-dim, text-danger/bg-danger-dim, text-accent/ bg-accent-dim, text-muted-foreground/bg-elevated). Clickable variants render as react-router-dom s and are keyboard-focusable with an accent focus-visible ring. Mobile collapses the label to a clock icon with a title attribute carrying the full text. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/layout/TopBar.tsx | 4 + frontend/src/components/layout/TrialPill.tsx | 147 +++++++++++++++++ .../layout/__tests__/TrialPill.test.tsx | 155 ++++++++++++++++++ 3 files changed, 306 insertions(+) create mode 100644 frontend/src/components/layout/TrialPill.tsx create mode 100644 frontend/src/components/layout/__tests__/TrialPill.test.tsx diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 3555b7db..42e107df 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -6,6 +6,7 @@ import { usePermissions } from '@/hooks/usePermissions' import { BrandLogo } from '@/components/common/BrandLogo' import { CommandPalette } from './CommandPalette' import { NotificationsPanel } from './NotificationsPanel' +import { TrialPill } from './TrialPill' import { cn } from '@/lib/utils' export function TopBar() { @@ -110,6 +111,9 @@ export function TopBar() { {/* Spacer - push actions to right */}
+ {/* Billing-state pill (trial countdown / paid tier / past_due / etc.) */} + + {/* Action buttons */}
`s; static variants render as ``. + * + * Mobile: when the topbar is too narrow, the label collapses to a clock icon + * with a `title` tooltip carrying the full text. + */ + +interface PillContent { + /** Full label shown on >= sm. */ + label: string + /** Short label for mobile (sm:hidden); typically a single token / icon. */ + shortLabel?: string + /** Tailwind classes applied to the pill (color tokens). */ + toneClass: string + /** When set, render as a clickable Link to this route. */ + href?: string + /** Extra emphasis (used by `urgent` to differentiate from `warning`). */ + emphasized?: boolean +} + +const BASE_CLASS = + 'trial-pill inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors whitespace-nowrap' + +export function TrialPill() { + const { stage, daysRemaining } = useTrialBanner() + const planBilling = useBillingStore((s) => s.planBilling) + + const content = resolveContent(stage, daysRemaining, planBilling?.display_name ?? null) + if (!content) return null + + const className = cn( + BASE_CLASS, + content.toneClass, + content.emphasized && 'font-semibold', + content.href && + 'cursor-pointer hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-bg-sidebar', + ) + + const inner = ( + <> + {content.label} + + + ) + + if (content.href) { + return ( + + {inner} + + ) + } + + return ( + + {inner} + + ) +} + +function resolveContent( + stage: ReturnType['stage'], + daysRemaining: number | null, + paidDisplayName: string | null, +): PillContent | null { + switch (stage) { + case null: + return null + case 'pristine': { + const days = daysRemaining ?? 0 + return { + label: `Pro trial · ${days}d`, + toneClass: 'text-info bg-info-dim', + } + } + case 'warning': { + const days = daysRemaining ?? 0 + return { + label: `Pro trial · ${days}d`, + toneClass: 'text-warning bg-warning-dim', + } + } + case 'urgent': + return { + label: 'Pro trial · today', + toneClass: 'text-warning bg-warning-dim', + emphasized: true, + } + case 'expired': + return { + label: 'Trial expired — pick a plan', + toneClass: 'text-danger bg-danger-dim', + href: '/account/billing/select-plan', + } + case 'paid': + return { + label: paidDisplayName ?? 'Pro', + toneClass: 'text-muted-foreground bg-elevated', + } + case 'complimentary': + return { + label: 'Complimentary Pro', + toneClass: 'text-accent bg-accent-dim', + } + case 'past_due': + return { + label: 'Payment failed — update card', + toneClass: 'text-warning bg-warning-dim', + href: '/account/billing', + } + case 'canceled': + return { + label: 'Reactivate', + toneClass: 'text-warning bg-warning-dim', + href: '/account/billing/select-plan', + } + default: { + const _exhaustive: never = stage + void _exhaustive + return null + } + } +} + +export default TrialPill diff --git a/frontend/src/components/layout/__tests__/TrialPill.test.tsx b/frontend/src/components/layout/__tests__/TrialPill.test.tsx new file mode 100644 index 00000000..cfc83328 --- /dev/null +++ b/frontend/src/components/layout/__tests__/TrialPill.test.tsx @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +import { TrialPill } from '../TrialPill' +import { useBillingStore } from '@/store/billingStore' +import type { SubscriptionState, PlanBillingState } from '@/types/billing' + +const FROZEN_NOW = new Date('2026-05-06T12:00:00Z') + +function renderPill() { + return render( + + + , + ) +} + +function setBilling(opts: { + subscription: SubscriptionState | null + planBilling?: PlanBillingState | null +}) { + useBillingStore.setState({ + subscription: opts.subscription, + planBilling: opts.planBilling ?? null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) +} + +function isoDaysFromNow(days: number): string { + const d = new Date(FROZEN_NOW.getTime() + days * 24 * 60 * 60 * 1000) + return d.toISOString() +} + +describe('TrialPill', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(FROZEN_NOW) + useBillingStore.setState({ + subscription: null, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders Pro trial · Nd for pristine stage', () => { + setBilling({ + subscription: { + status: 'trialing', + plan: 'pro', + current_period_start: FROZEN_NOW.toISOString(), + current_period_end: isoDaysFromNow(12), + cancel_at_period_end: false, + seat_limit: 5, + has_pro_entitlement: true, + is_paid: false, + }, + }) + + renderPill() + + const pill = screen.getByTestId('trial-pill') + expect(pill).toHaveTextContent(/Pro trial · 12d/) + // Pristine uses info tone tokens. + expect(pill.className).toContain('text-info') + expect(pill.className).toContain('bg-info-dim') + }) + + it('renders Trial expired CTA for expired stage', () => { + setBilling({ + subscription: { + status: 'trialing', + plan: 'pro', + current_period_start: isoDaysFromNow(-14), + current_period_end: isoDaysFromNow(-1), // already past + cancel_at_period_end: false, + seat_limit: 5, + has_pro_entitlement: false, + is_paid: false, + }, + }) + + renderPill() + + const pill = screen.getByTestId('trial-pill') + expect(pill).toHaveTextContent(/Trial expired — pick a plan/) + // Clickable: rendered as anchor/link. + expect(pill.tagName).toBe('A') + expect(pill.getAttribute('href')).toBe('/account/billing/select-plan') + }) + + it('renders Complimentary Pro tag for complimentary subscription', () => { + setBilling({ + subscription: { + status: 'complimentary', + plan: 'pro', + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + seat_limit: null, + has_pro_entitlement: true, + is_paid: true, + }, + }) + + renderPill() + + const pill = screen.getByTestId('trial-pill') + expect(pill).toHaveTextContent(/Complimentary Pro/) + // Friendly tag, not clickable. + expect(pill.tagName).toBe('SPAN') + expect(pill.className).toContain('text-accent') + }) + + it('is hidden when subscription is null', () => { + setBilling({ subscription: null }) + + const { container } = renderPill() + + expect(screen.queryByTestId('trial-pill')).not.toBeInTheDocument() + expect(container.firstChild).toBeNull() + }) + + it('past_due variant is clickable and links to /account/billing', () => { + setBilling({ + subscription: { + status: 'past_due', + plan: 'pro', + current_period_start: isoDaysFromNow(-30), + current_period_end: isoDaysFromNow(-2), + cancel_at_period_end: false, + seat_limit: 5, + has_pro_entitlement: false, + is_paid: true, + }, + }) + + renderPill() + + const pill = screen.getByTestId('trial-pill') + expect(pill).toHaveTextContent(/Payment failed — update card/) + expect(pill.tagName).toBe('A') + expect(pill.getAttribute('href')).toBe('/account/billing') + }) +}) -- 2.49.1 From 0c326d0616c623150bb05c447e08be4174a2e4b8 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 23:19:58 -0400 Subject: [PATCH 15/29] feat(dashboard): replace checklist with next-step card + unified list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 Task 41 — Dashboard redesign. Backend: - Extend GET /users/onboarding-status with email_verified and shop_setup_done. - tried_ai_assistant kept in payload for backward-compat during deploy. Frontend: - New NextStepCard: surfaces the highest-priority incomplete onboarding item with a primary CTA. Priority order: verify email > set up shop > run first FlowPilot session > connect PSA > invite teammate > pick a plan (gated on trial stage warning/urgent/expired). Returns null when all done OR onboarding_dismissed. - New SetupChecklist: unified single list (no SOLO/TEAM bifurcation), drops the stale tried_ai_assistant / Script Builder item, surfaces "Pick a plan" when trial stage is warning or later. - Mounted on QuickStartPage below the hero with a "Show all setup steps" toggle. The whole onboarding section auto-hides when there's nothing left to nudge on, so the dashboard goes back to clean once setup is done. - Removed the orphaned OnboardingChecklist component (was defined but never mounted). - New useOnboardingStatus hook so page + components share one fetch contract. Tests: - Backend: test_onboarding_status_includes_email_verified_and_shop_setup_done. - Frontend (Vitest): 13 new tests across NextStepCard, SetupChecklist, and QuickStartPage covering priority ordering, dismissal, the SOLO/TEAM removal, the toggle reveal, and the trial-stage gate on Pick a plan. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/endpoints/onboarding.py | 6 + backend/app/schemas/onboarding.py | 4 + backend/tests/test_onboarding.py | 41 +++++ frontend/src/api/onboarding.ts | 4 + .../src/components/dashboard/NextStepCard.tsx | 169 ++++++++++++++++++ .../dashboard/OnboardingChecklist.tsx | 160 ----------------- .../components/dashboard/SetupChecklist.tsx | 136 ++++++++++++++ .../dashboard/__tests__/NextStepCard.test.tsx | 148 +++++++++++++++ .../__tests__/SetupChecklist.test.tsx | 123 +++++++++++++ frontend/src/hooks/useOnboardingStatus.ts | 27 +++ frontend/src/pages/QuickStartPage.tsx | 39 ++++ .../pages/__tests__/QuickStartPage.test.tsx | 141 +++++++++++++++ 12 files changed, 838 insertions(+), 160 deletions(-) create mode 100644 frontend/src/components/dashboard/NextStepCard.tsx delete mode 100644 frontend/src/components/dashboard/OnboardingChecklist.tsx create mode 100644 frontend/src/components/dashboard/SetupChecklist.tsx create mode 100644 frontend/src/components/dashboard/__tests__/NextStepCard.test.tsx create mode 100644 frontend/src/components/dashboard/__tests__/SetupChecklist.test.tsx create mode 100644 frontend/src/hooks/useOnboardingStatus.ts create mode 100644 frontend/src/pages/__tests__/QuickStartPage.test.tsx diff --git a/backend/app/api/endpoints/onboarding.py b/backend/app/api/endpoints/onboarding.py index 4cecf091..d348545d 100644 --- a/backend/app/api/endpoints/onboarding.py +++ b/backend/app/api/endpoints/onboarding.py @@ -90,6 +90,10 @@ async def get_onboarding_status( ) connected_psa = (psa_q.scalar() or 0) > 0 + # New (Phase 2 — Task 41) + email_verified = current_user.email_verified_at is not None + shop_setup_done = (current_user.onboarding_step_completed or 0) >= 1 + return OnboardingStatus( created_flow=created_flow, ran_session=ran_session, @@ -99,6 +103,8 @@ async def get_onboarding_status( connected_psa=connected_psa, is_team_user=is_team_user, dismissed=current_user.onboarding_dismissed, + email_verified=email_verified, + shop_setup_done=shop_setup_done, ) diff --git a/backend/app/schemas/onboarding.py b/backend/app/schemas/onboarding.py index 303e1ceb..e6dd1329 100644 --- a/backend/app/schemas/onboarding.py +++ b/backend/app/schemas/onboarding.py @@ -7,11 +7,15 @@ class OnboardingStatus(BaseModel): created_flow: bool ran_session: bool exported_session: bool + # Kept for backward-compat during deploy; new code paths should not branch on this. tried_ai_assistant: bool invited_teammate: bool connected_psa: bool is_team_user: bool dismissed: bool + # New (Phase 2 — Task 41) — drive the unified next-step card + checklist. + email_verified: bool + shop_setup_done: bool # --- Welcome wizard (Phase 2) ---------------------------------------------- diff --git a/backend/tests/test_onboarding.py b/backend/tests/test_onboarding.py index aa4f48d8..72ea53c5 100644 --- a/backend/tests/test_onboarding.py +++ b/backend/tests/test_onboarding.py @@ -1,6 +1,11 @@ """Tests for onboarding status endpoints.""" +from datetime import datetime, timezone + import pytest +from sqlalchemy import select + +from app.models.user import User @pytest.mark.asyncio @@ -21,6 +26,42 @@ async def test_onboarding_status_fresh_user(client, auth_headers): assert data["connected_psa"] is False assert data["is_team_user"] is False assert data["dismissed"] is False + # Phase 2 fields default to false on a fresh, unverified user with no wizard progress. + assert data["email_verified"] is False + assert data["shop_setup_done"] is False + + +@pytest.mark.asyncio +async def test_onboarding_status_includes_email_verified_and_shop_setup_done( + client, auth_headers, test_user, test_db +): + """email_verified flips when email_verified_at is set; shop_setup_done flips at step >= 1.""" + # Sanity-check baseline. + response = await client.get( + "/api/v1/users/onboarding-status", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["email_verified"] is False + assert data["shop_setup_done"] is False + + # Mutate the underlying user, then re-fetch. + user_email = test_user["email"] + result = await test_db.execute(select(User).where(User.email == user_email)) + user = result.scalar_one() + user.email_verified_at = datetime.now(tz=timezone.utc) + user.onboarding_step_completed = 1 + await test_db.commit() + + response = await client.get( + "/api/v1/users/onboarding-status", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["email_verified"] is True + assert data["shop_setup_done"] is True @pytest.mark.asyncio diff --git a/frontend/src/api/onboarding.ts b/frontend/src/api/onboarding.ts index f5caa8f3..aa785bfd 100644 --- a/frontend/src/api/onboarding.ts +++ b/frontend/src/api/onboarding.ts @@ -4,11 +4,15 @@ export interface OnboardingStatus { created_flow: boolean ran_session: boolean exported_session: boolean + /** @deprecated Phase 2 — kept for backward-compat. New UI no longer branches on this. */ tried_ai_assistant: boolean invited_teammate: boolean connected_psa: boolean is_team_user: boolean dismissed: boolean + // Phase 2 (Task 41) — drive the unified next-step card + checklist. + email_verified: boolean + shop_setup_done: boolean } export async function getOnboardingStatus(): Promise { diff --git a/frontend/src/components/dashboard/NextStepCard.tsx b/frontend/src/components/dashboard/NextStepCard.tsx new file mode 100644 index 00000000..646e2865 --- /dev/null +++ b/frontend/src/components/dashboard/NextStepCard.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { ArrowRight, X } from 'lucide-react' +import { dismissOnboarding } from '@/api/onboarding' +import type { OnboardingStatus } from '@/api/onboarding' +import { useTrialBanner } from '@/hooks/useTrialBanner' +import type { TrialBannerStage } from '@/hooks/useTrialBanner' +import { useOnboardingStatus } from '@/hooks/useOnboardingStatus' + +/** + * Next-step card — surfaces the single highest-priority incomplete onboarding + * item with a primary CTA. Replaces the old multi-item `OnboardingChecklist` + * widget at the top of the dashboard. + * + * `useOnboardingStatusQuery` is exported as a tiny shared hook so the parent + * page can decide whether to render the surrounding "Show all setup steps" + * toggle without duplicating the fetch. + * + * Returns `null` when: + * - status hasn't loaded yet + * - `status.dismissed` is true + * - all items are complete + * + * Priority order (first incomplete wins): + * 1. Verify your email + * 2. Set up your shop + * 3. Run your first FlowPilot session + * 4. Connect your PSA + * 5. Invite a teammate + * 6. Pick a plan (only when trial stage is warning / urgent / expired) + */ + +export interface NextStepItem { + /** Stable id used in tests + analytics. */ + key: string + title: string + description: string + ctaLabel: string + ctaPath: string +} + +const PLAN_GATE_STAGES: ReadonlyArray = [ + 'warning', + 'urgent', + 'expired', +] + +/** + * Pure helper — picks the highest-priority incomplete item, or `null` when + * all relevant items are done. Exported for direct unit testing. + */ +export function pickNextStep( + status: OnboardingStatus, + trialStage: TrialBannerStage | null, +): NextStepItem | null { + if (!status.email_verified) { + return { + key: 'verify_email', + title: 'Verify your email', + description: 'Confirm your address to keep your account active after the grace period.', + ctaLabel: 'Verify email', + ctaPath: '/verify-email', + } + } + if (!status.shop_setup_done) { + return { + key: 'shop_setup', + title: 'Set up your shop', + description: 'Tell us a bit about your team so ResolutionFlow can tailor itself.', + ctaLabel: 'Set up shop', + ctaPath: '/welcome/step-1', + } + } + if (!status.ran_session) { + return { + key: 'ran_session', + title: 'Run your first FlowPilot session', + description: 'Paste a ticket or pick a flow to see ResolutionFlow in action.', + ctaLabel: 'Start a session', + ctaPath: '/', + } + } + if (!status.connected_psa) { + return { + key: 'connected_psa', + title: 'Connect your PSA', + description: 'Sync tickets from ConnectWise, Autotask, or HaloPSA.', + ctaLabel: 'Connect PSA', + ctaPath: '/account/integrations', + } + } + if (!status.invited_teammate) { + return { + key: 'invited_teammate', + title: 'Invite a teammate', + description: 'ResolutionFlow gets stronger when your whole team is on it.', + ctaLabel: 'Invite teammate', + ctaPath: '/account', + } + } + if (trialStage && PLAN_GATE_STAGES.includes(trialStage)) { + return { + key: 'pick_plan', + title: 'Pick a plan', + description: 'Your trial is wrapping up — pick a plan to keep using ResolutionFlow.', + ctaLabel: 'Pick a plan', + ctaPath: '/account/billing/select-plan', + } + } + return null +} + +export function NextStepCard() { + const status = useOnboardingStatus() + const [locallyDismissed, setLocallyDismissed] = useState(false) + const { stage } = useTrialBanner() + + if (!status || status.dismissed || locallyDismissed) return null + + const next = pickNextStep(status, stage) + if (!next) return null + + const handleDismiss = async () => { + setLocallyDismissed(true) + try { + await dismissOnboarding() + } catch { + // Already hidden locally — best-effort persist. + } + } + + return ( +
+
+
+

+ Next step +

+

{next.title}

+

{next.description}

+
+ +
+
+ + {next.ctaLabel} + + +
+
+ ) +} + +export default NextStepCard diff --git a/frontend/src/components/dashboard/OnboardingChecklist.tsx b/frontend/src/components/dashboard/OnboardingChecklist.tsx deleted file mode 100644 index fab062e4..00000000 --- a/frontend/src/components/dashboard/OnboardingChecklist.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useState, useEffect } from 'react' -import { useNavigate } from 'react-router-dom' -import { Check, X, ChevronRight } from 'lucide-react' -import { cn } from '@/lib/utils' -import { getOnboardingStatus, dismissOnboarding } from '@/api/onboarding' -import type { OnboardingStatus } from '@/api/onboarding' - -interface ChecklistItem { - key: keyof OnboardingStatus - label: string - path: string -} - -const SOLO_ITEMS: ChecklistItem[] = [ - { key: 'ran_session', label: 'Try troubleshooting a ticket', path: '/' }, - { key: 'exported_session', label: 'Review your session notes', path: '/sessions' }, - { key: 'created_flow', label: 'Explore guided flows', path: '/trees' }, - { key: 'tried_ai_assistant', label: 'Check out the Script Builder', path: '/script-builder' }, -] - -const TEAM_ITEMS: ChecklistItem[] = [ - { key: 'ran_session', label: 'Try troubleshooting a ticket', path: '/' }, - { key: 'exported_session', label: 'Review your session notes', path: '/sessions' }, - { key: 'invited_teammate', label: 'Invite a team member', path: '/account' }, - { key: 'created_flow', label: 'Explore guided flows', path: '/trees' }, - { key: 'connected_psa', label: 'Connect your PSA', path: '/account/integrations' }, -] - -export function OnboardingChecklist() { - const navigate = useNavigate() - const [status, setStatus] = useState(null) - const [dismissed, setDismissed] = useState(false) - const [allComplete, setAllComplete] = useState(false) - - useEffect(() => { - getOnboardingStatus() - .then(setStatus) - .catch(() => { - // Silently fail — don't show checklist if endpoint unavailable - }) - }, []) - - const items = status?.is_team_user ? TEAM_ITEMS : SOLO_ITEMS - const completedCount = status - ? items.filter((item) => status[item.key]).length - : 0 - const totalCount = items.length - const isAllDone = completedCount === totalCount && status !== null - - useEffect(() => { - if (isAllDone) { - const timer = setTimeout(() => setAllComplete(true), 2000) - return () => clearTimeout(timer) - } - }, [isAllDone]) - - // Don't render if dismissed, fully complete, or not loaded yet - if (!status || status.dismissed || dismissed || allComplete) return null - - const progressPercent = totalCount > 0 ? (completedCount / totalCount) * 100 : 0 - - const handleDismiss = async () => { - setDismissed(true) - try { - await dismissOnboarding() - } catch { - // Already hidden locally - } - } - - return ( -
- {/* Progress bar */} -
-
-
- -
- {/* Header */} -
-
-

- Getting Started -

-

- {isAllDone ? ( - You're all set! - ) : ( - - {completedCount} - {' '}of {totalCount} complete - - )} -

-
- -
- - {/* Checklist items */} -
    - {items.map((item) => { - const done = status[item.key] - return ( -
  • - -
  • - ) - })} -
-
-
- ) -} diff --git a/frontend/src/components/dashboard/SetupChecklist.tsx b/frontend/src/components/dashboard/SetupChecklist.tsx new file mode 100644 index 00000000..0f29a4e6 --- /dev/null +++ b/frontend/src/components/dashboard/SetupChecklist.tsx @@ -0,0 +1,136 @@ +import { Link } from 'react-router-dom' +import { Check, ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { OnboardingStatus } from '@/api/onboarding' +import { useTrialBanner } from '@/hooks/useTrialBanner' +import type { TrialBannerStage } from '@/hooks/useTrialBanner' +import { useOnboardingStatus } from '@/hooks/useOnboardingStatus' + +/** + * Unified setup checklist — single list (no SOLO/TEAM bifurcation). + * + * Replaces the old `OnboardingChecklist` widget. Items match `NextStepCard`'s + * priority order. The "Pick a plan" item is gated on the trial stage. + * + * Surfaced behind a "Show all setup steps" toggle on the dashboard so the + * always-visible surface is just the single next-step card. + */ + +interface ChecklistItem { + key: string + label: string + path: string + done: boolean +} + +const PLAN_GATE_STAGES: ReadonlyArray = [ + 'warning', + 'urgent', + 'expired', +] + +export function buildChecklistItems( + status: OnboardingStatus, + trialStage: TrialBannerStage | null, +): ChecklistItem[] { + const items: ChecklistItem[] = [ + { + key: 'verify_email', + label: 'Verify your email', + path: '/verify-email', + done: status.email_verified, + }, + { + key: 'shop_setup', + label: 'Set up your shop', + path: '/welcome/step-1', + done: status.shop_setup_done, + }, + { + key: 'ran_session', + label: 'Run your first FlowPilot session', + path: '/', + done: status.ran_session, + }, + { + key: 'connected_psa', + label: 'Connect your PSA', + path: '/account/integrations', + done: status.connected_psa, + }, + { + key: 'invited_teammate', + label: 'Invite a teammate', + path: '/account', + done: status.invited_teammate, + }, + ] + + if (trialStage && PLAN_GATE_STAGES.includes(trialStage)) { + items.push({ + key: 'pick_plan', + label: 'Pick a plan', + path: '/account/billing/select-plan', + done: false, + }) + } + + return items +} + +export function SetupChecklist() { + const status = useOnboardingStatus() + const { stage } = useTrialBanner() + + if (!status || status.dismissed) return null + + const items = buildChecklistItems(status, stage) + const completedCount = items.filter((i) => i.done).length + const totalCount = items.length + + return ( +
+
+

+ Setup steps · {completedCount} of {totalCount} +

+
+
    + {items.map((item) => ( +
  • + {item.done ? ( +
    + + + + + {item.label} + +
    + ) : ( + + + {item.label} + + + )} +
  • + ))} +
+
+ ) +} + +export default SetupChecklist diff --git a/frontend/src/components/dashboard/__tests__/NextStepCard.test.tsx b/frontend/src/components/dashboard/__tests__/NextStepCard.test.tsx new file mode 100644 index 00000000..c7efdfc6 --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/NextStepCard.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { NextStepCard, pickNextStep } from '../NextStepCard' +import { useBillingStore } from '@/store/billingStore' +import type { OnboardingStatus } from '@/api/onboarding' + +vi.mock('@/api/onboarding', () => { + const mockGet = vi.fn() + const mockDismiss = vi.fn() + return { + getOnboardingStatus: mockGet, + dismissOnboarding: mockDismiss, + } +}) + +import { + getOnboardingStatus as _getOnboardingStatus, +} from '@/api/onboarding' + +const getOnboardingStatus = _getOnboardingStatus as unknown as ReturnType + +function makeStatus(overrides: Partial = {}): OnboardingStatus { + return { + created_flow: false, + ran_session: false, + exported_session: false, + tried_ai_assistant: false, + invited_teammate: false, + connected_psa: false, + is_team_user: false, + dismissed: false, + email_verified: false, + shop_setup_done: false, + ...overrides, + } +} + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}) +} + +function setBillingComplimentary() { + // 'complimentary' status -> stage 'complimentary' (not in plan-gate set), so the + // "Pick a plan" item stays hidden — perfect default for unrelated tests. + useBillingStore.setState({ + subscription: { + status: 'complimentary', + plan: 'pro', + current_period_start: '2026-05-01T00:00:00Z', + current_period_end: null, + cancel_at_period_end: false, + seat_limit: null, + has_pro_entitlement: true, + is_paid: true, + }, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) +} + +describe('NextStepCard', () => { + beforeEach(() => { + getOnboardingStatus.mockReset() + setBillingComplimentary() + }) + + it('renders Verify your email when email unverified', async () => { + getOnboardingStatus.mockResolvedValue(makeStatus({ email_verified: false })) + renderWithRouter() + await waitFor(() => { + expect(screen.getByTestId('next-step-card')).toBeInTheDocument() + }) + expect(screen.getByRole('heading', { name: /Verify your email/i })).toBeInTheDocument() + }) + + it('renders Set up your shop after email verified', async () => { + getOnboardingStatus.mockResolvedValue( + makeStatus({ email_verified: true, shop_setup_done: false }), + ) + renderWithRouter() + await waitFor(() => { + expect(screen.getByRole('heading', { name: /Set up your shop/i })).toBeInTheDocument() + }) + }) + + it('renders Run your first FlowPilot session after shop setup', async () => { + getOnboardingStatus.mockResolvedValue( + makeStatus({ + email_verified: true, + shop_setup_done: true, + ran_session: false, + }), + ) + renderWithRouter() + await waitFor(() => { + expect( + screen.getByRole('heading', { name: /Run your first FlowPilot session/i }), + ).toBeInTheDocument() + }) + }) + + it('hidden when all items done', async () => { + getOnboardingStatus.mockResolvedValue( + makeStatus({ + email_verified: true, + shop_setup_done: true, + ran_session: true, + connected_psa: true, + invited_teammate: true, + }), + ) + const { container } = renderWithRouter() + // Resolve the awaited promise. + await waitFor(() => expect(getOnboardingStatus).toHaveBeenCalled()) + expect(container.querySelector('[data-testid="next-step-card"]')).toBeNull() + }) + + it('hidden when onboarding_dismissed', async () => { + getOnboardingStatus.mockResolvedValue(makeStatus({ dismissed: true })) + const { container } = renderWithRouter() + await waitFor(() => expect(getOnboardingStatus).toHaveBeenCalled()) + expect(container.querySelector('[data-testid="next-step-card"]')).toBeNull() + }) + + it('Pick a plan item appears when trial stage is warning or later', () => { + // Direct unit-test on the pure picker — easier than coordinating both the + // billing store + the network mock + a fake clock for stage='warning'. + const allDoneExceptPlan = makeStatus({ + email_verified: true, + shop_setup_done: true, + ran_session: true, + connected_psa: true, + invited_teammate: true, + }) + + expect(pickNextStep(allDoneExceptPlan, 'pristine')).toBeNull() + expect(pickNextStep(allDoneExceptPlan, 'paid')).toBeNull() + expect(pickNextStep(allDoneExceptPlan, 'complimentary')).toBeNull() + + expect(pickNextStep(allDoneExceptPlan, 'warning')?.key).toBe('pick_plan') + expect(pickNextStep(allDoneExceptPlan, 'urgent')?.key).toBe('pick_plan') + expect(pickNextStep(allDoneExceptPlan, 'expired')?.key).toBe('pick_plan') + }) +}) diff --git a/frontend/src/components/dashboard/__tests__/SetupChecklist.test.tsx b/frontend/src/components/dashboard/__tests__/SetupChecklist.test.tsx new file mode 100644 index 00000000..2534ce7f --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/SetupChecklist.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { SetupChecklist, buildChecklistItems } from '../SetupChecklist' +import { useBillingStore } from '@/store/billingStore' +import type { OnboardingStatus } from '@/api/onboarding' + +vi.mock('@/api/onboarding', () => { + const mockGet = vi.fn() + return { + getOnboardingStatus: mockGet, + dismissOnboarding: vi.fn(), + } +}) + +import { getOnboardingStatus as _getOnboardingStatus } from '@/api/onboarding' +const getOnboardingStatus = _getOnboardingStatus as unknown as ReturnType + +function makeStatus(overrides: Partial = {}): OnboardingStatus { + return { + created_flow: false, + ran_session: false, + exported_session: false, + tried_ai_assistant: false, + invited_teammate: false, + connected_psa: false, + is_team_user: false, + dismissed: false, + email_verified: false, + shop_setup_done: false, + ...overrides, + } +} + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}) +} + +function setBillingComplimentary() { + useBillingStore.setState({ + subscription: { + status: 'complimentary', + plan: 'pro', + current_period_start: '2026-05-01T00:00:00Z', + current_period_end: null, + cancel_at_period_end: false, + seat_limit: null, + has_pro_entitlement: true, + is_paid: true, + }, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) +} + +describe('SetupChecklist', () => { + beforeEach(() => { + getOnboardingStatus.mockReset() + setBillingComplimentary() + }) + + it('renders unified list with no SOLO/TEAM headers', async () => { + getOnboardingStatus.mockResolvedValue(makeStatus()) + renderWithRouter() + await waitFor(() => { + expect(screen.getByTestId('setup-checklist')).toBeInTheDocument() + }) + // Single unified list — no team/solo section dividers (the old component had + // separate SOLO_ITEMS / TEAM_ITEMS branches; the new one is one flat list). + expect(screen.queryByText(/^SOLO$/)).toBeNull() + expect(screen.queryByText(/^TEAM$/)).toBeNull() + expect(screen.queryByText(/Solo users/i)).toBeNull() + expect(screen.queryByText(/Team users/i)).toBeNull() + + // Core items present. + expect(screen.getByText(/Verify your email/i)).toBeInTheDocument() + expect(screen.getByText(/Set up your shop/i)).toBeInTheDocument() + expect(screen.getByText(/Run your first FlowPilot session/i)).toBeInTheDocument() + expect(screen.getByText(/Connect your PSA/i)).toBeInTheDocument() + expect(screen.getByText(/Invite a teammate/i)).toBeInTheDocument() + }) + + it('does NOT include the stale tried_ai_assistant / Script Builder item', async () => { + getOnboardingStatus.mockResolvedValue(makeStatus()) + renderWithRouter() + await waitFor(() => { + expect(screen.getByTestId('setup-checklist')).toBeInTheDocument() + }) + expect(screen.queryByText(/Script Builder/i)).toBeNull() + expect(screen.queryByText(/AI Assistant/i)).toBeNull() + }) + + it('hidden when onboarding_dismissed', async () => { + getOnboardingStatus.mockResolvedValue(makeStatus({ dismissed: true })) + const { container } = renderWithRouter() + await waitFor(() => expect(getOnboardingStatus).toHaveBeenCalled()) + expect(container.querySelector('[data-testid="setup-checklist"]')).toBeNull() + }) + + describe('buildChecklistItems', () => { + it('does not include "Pick a plan" when stage is pristine', () => { + const items = buildChecklistItems(makeStatus(), 'pristine') + expect(items.find((i) => i.key === 'pick_plan')).toBeUndefined() + }) + + it('includes "Pick a plan" when stage is warning', () => { + const items = buildChecklistItems(makeStatus(), 'warning') + expect(items.find((i) => i.key === 'pick_plan')).toBeDefined() + }) + + it('includes "Pick a plan" when stage is urgent or expired', () => { + expect( + buildChecklistItems(makeStatus(), 'urgent').find((i) => i.key === 'pick_plan'), + ).toBeDefined() + expect( + buildChecklistItems(makeStatus(), 'expired').find((i) => i.key === 'pick_plan'), + ).toBeDefined() + }) + }) +}) diff --git a/frontend/src/hooks/useOnboardingStatus.ts b/frontend/src/hooks/useOnboardingStatus.ts new file mode 100644 index 00000000..3b7ff9c4 --- /dev/null +++ b/frontend/src/hooks/useOnboardingStatus.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react' +import { getOnboardingStatus } from '@/api/onboarding' +import type { OnboardingStatus } from '@/api/onboarding' + +/** + * Tiny shared hook that fetches `/users/onboarding-status` once on mount. + * + * Used by `NextStepCard`, `SetupChecklist`, and `QuickStartPage` so the toggle + * row can disappear when there's nothing to show. Each consumer has its own + * state — fetches are not deduplicated. That's fine for now; if it becomes a + * problem we can lift this into a Zustand store or react-query. + */ +export function useOnboardingStatus(): OnboardingStatus | null { + const [status, setStatus] = useState(null) + + useEffect(() => { + getOnboardingStatus() + .then(setStatus) + .catch(() => { + // Silently fail — never block the dashboard if the endpoint is down. + }) + }, []) + + return status +} + +export default useOnboardingStatus diff --git a/frontend/src/pages/QuickStartPage.tsx b/frontend/src/pages/QuickStartPage.tsx index 5c911f5a..bea8e2a5 100644 --- a/frontend/src/pages/QuickStartPage.tsx +++ b/frontend/src/pages/QuickStartPage.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { PageMeta } from '@/components/common/PageMeta' import { useAuthStore } from '@/store/authStore' import { StartSessionInput } from '@/components/dashboard/StartSessionInput' @@ -7,6 +8,10 @@ import { TicketQueue } from '@/components/dashboard/TicketQueue' import { PerformanceCards } from '@/components/dashboard/PerformanceCards' import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards' import { TeamSummary } from '@/components/dashboard/TeamSummary' +import { NextStepCard, pickNextStep } from '@/components/dashboard/NextStepCard' +import { SetupChecklist } from '@/components/dashboard/SetupChecklist' +import { useOnboardingStatus } from '@/hooks/useOnboardingStatus' +import { useTrialBanner } from '@/hooks/useTrialBanner' function SectionLabel({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) { return ( @@ -22,6 +27,17 @@ function SectionLabel({ children, action }: { children: React.ReactNode; action? export function QuickStartPage() { const user = useAuthStore((s) => s.user) + const [showAllSetupSteps, setShowAllSetupSteps] = useState(false) + const onboardingStatus = useOnboardingStatus() + const { stage: trialStage } = useTrialBanner() + + // Onboarding section is visible when there's still something to nudge on. + // We check the same priority list NextStepCard uses so the toggle row + // disappears cleanly once everything is done OR the user dismissed. + const onboardingVisible = + onboardingStatus !== null && + !onboardingStatus.dismissed && + pickNextStep(onboardingStatus, trialStage) !== null const now = new Date() const greeting = now.getHours() < 12 @@ -47,6 +63,29 @@ export function QuickStartPage() {
+ {/* Next-step card — surfaces a single onboarding nudge below the hero. */} + {onboardingVisible && ( +
+ +
+ +
+ {showAllSetupSteps && ( +
+ +
+ )} +
+ )} + {/* Chat-style input */} diff --git a/frontend/src/pages/__tests__/QuickStartPage.test.tsx b/frontend/src/pages/__tests__/QuickStartPage.test.tsx new file mode 100644 index 00000000..90a93a7f --- /dev/null +++ b/frontend/src/pages/__tests__/QuickStartPage.test.tsx @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import type { OnboardingStatus } from '@/api/onboarding' +import { useAuthStore } from '@/store/authStore' +import { useBillingStore } from '@/store/billingStore' + +// Mock heavy dashboard children — they pull in axios + zustand stores we +// don't care about for this toggle test. +vi.mock('@/components/dashboard/StartSessionInput', () => ({ + StartSessionInput: () =>
, +})) +vi.mock('@/components/dashboard/PendingEscalations', () => ({ + PendingEscalations: () => null, +})) +vi.mock('@/components/dashboard/ActiveFlowPilotSessions', () => ({ + ActiveFlowPilotSessions: () => null, +})) +vi.mock('@/components/dashboard/TicketQueue', () => ({ + TicketQueue: () => null, +})) +vi.mock('@/components/dashboard/PerformanceCards', () => ({ + PerformanceCards: () => null, +})) +vi.mock('@/components/dashboard/KnowledgeBaseCards', () => ({ + KnowledgeBaseCards: () => null, +})) +vi.mock('@/components/dashboard/TeamSummary', () => ({ + TeamSummary: () => null, +})) + +vi.mock('@/api/onboarding', () => { + const mockGet = vi.fn() + return { + getOnboardingStatus: mockGet, + dismissOnboarding: vi.fn(), + } +}) + +import { QuickStartPage } from '../QuickStartPage' +import { getOnboardingStatus as _getOnboardingStatus } from '@/api/onboarding' + +const getOnboardingStatus = _getOnboardingStatus as unknown as ReturnType + +function makeStatus(overrides: Partial = {}): OnboardingStatus { + return { + created_flow: false, + ran_session: false, + exported_session: false, + tried_ai_assistant: false, + invited_teammate: false, + connected_psa: false, + is_team_user: false, + dismissed: false, + email_verified: true, // skip past verify so the next-step card is not the noisy thing here. + shop_setup_done: false, + ...overrides, + } +} + +describe('QuickStartPage', () => { + beforeEach(() => { + getOnboardingStatus.mockReset() + useAuthStore.setState({ + user: { + id: 'u-1', + email: 'test@example.com', + name: 'Test User', + role: 'engineer', + is_super_admin: false, + is_active: true, + must_change_password: false, + account_id: 'acct-1', + account_role: 'engineer', + team_id: null, + created_at: '2026-05-01T00:00:00Z', + last_login: null, + phone: null, + job_title: null, + timezone: 'UTC', + avatar_url: null, + email_verified_at: '2026-05-01T00:00:00Z', + }, + token: 'tok', + isAuthenticated: true, + }) + useBillingStore.setState({ + subscription: { + status: 'complimentary', + plan: 'pro', + current_period_start: '2026-05-01T00:00:00Z', + current_period_end: null, + cancel_at_period_end: false, + seat_limit: null, + has_pro_entitlement: true, + is_paid: true, + }, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) + }) + + it('Show all setup steps toggle reveals unified checklist with no SOLO/TEAM headers', async () => { + getOnboardingStatus.mockResolvedValue(makeStatus()) + + render( + + + , + ) + + // Wait for initial fetch. + await waitFor(() => expect(getOnboardingStatus).toHaveBeenCalled()) + + // Checklist is hidden by default. + expect(screen.queryByTestId('setup-checklist')).toBeNull() + + // Toggle visible. + const toggle = screen.getByTestId('toggle-setup-checklist') + expect(toggle).toHaveTextContent(/Show all setup steps/i) + + fireEvent.click(toggle) + + // Checklist now rendered. (`SetupChecklist` runs its own fetch — same mock.) + await waitFor(() => { + expect(screen.getByTestId('setup-checklist')).toBeInTheDocument() + }) + + // No SOLO/TEAM section headers in the unified list. + expect(screen.queryByText(/^SOLO$/)).toBeNull() + expect(screen.queryByText(/^TEAM$/)).toBeNull() + expect(screen.queryByText(/Solo users/i)).toBeNull() + expect(screen.queryByText(/Team users/i)).toBeNull() + + // Toggle label flips after clicking. + expect(toggle).toHaveTextContent(/Hide setup steps/i) + }) +}) -- 2.49.1 From 67fae910879b935bbceee4788fef3bc40217aa12 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 23:26:27 -0400 Subject: [PATCH 16/29] feat(pricing): add /pricing page (B-style) Phase 2 Task 42: public pricing page gated by SELF_SERVE_ENABLED. Backend: - New `GET /api/v1/plans/public` (no auth) returns plan_billing rows joined with plan_limits.max_users (as `max_seats`), filtered to is_public=true AND is_archived=false, ordered by sort_order ASC, plan ASC. Uses get_admin_db (cross-tenant catalog read, same pattern as /config/public). - `PublicPlanResponse` schema in app/schemas/billing.py. - Registered as PUBLIC in api router. Frontend: - `plansApi.getPublic()` client (frontend/src/api/plans.ts). - `PricingPage` at /pricing with hero / 3 plan cards (Pro recommended, Enterprise hides price) / hardcoded v1 comparison table / testimonial placeholder / soft trust strip. - Reads `useAppConfig().self_serve_enabled`; renders a 404 fallback when disabled, never calls the API in that path. - Start free trial CTAs link to /register?plan=starter|pro; Talk to sales links to /contact-sales (page wired in Task 43). Tests: - Backend: only-public-rows + sort-order ordering. - Frontend (Vitest): three plan cards with API prices, /register?plan=pro CTA, /contact-sales CTA, 404 when self_serve_enabled is false, soft trust language (no SOC2 claim). Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/endpoints/plans_public.py | 58 +++ backend/app/api/router.py | 2 + backend/app/schemas/billing.py | 20 + backend/tests/test_plans_public.py | 132 ++++++ frontend/src/api/index.ts | 2 + frontend/src/api/plans.ts | 22 + frontend/src/pages/PricingPage.tsx | 440 ++++++++++++++++++ .../src/pages/__tests__/PricingPage.test.tsx | 162 +++++++ frontend/src/router.tsx | 6 + 9 files changed, 844 insertions(+) create mode 100644 backend/app/api/endpoints/plans_public.py create mode 100644 backend/tests/test_plans_public.py create mode 100644 frontend/src/api/plans.ts create mode 100644 frontend/src/pages/PricingPage.tsx create mode 100644 frontend/src/pages/__tests__/PricingPage.test.tsx diff --git a/backend/app/api/endpoints/plans_public.py b/backend/app/api/endpoints/plans_public.py new file mode 100644 index 00000000..d5ea4de9 --- /dev/null +++ b/backend/app/api/endpoints/plans_public.py @@ -0,0 +1,58 @@ +"""Public plans endpoint — no auth required. + +GET /api/v1/plans/public + Returns the public-safe view of `plan_billing` joined with + `plan_limits.max_users` (exposed as `max_seats`), filtered to + `is_public=True AND is_archived=False`, ordered by sort_order ASC, plan ASC. + +Distinct from `/admin/plan-limits` (admin-only, returns ALL plans including +archived/internal). This endpoint exists to power the marketing /pricing page +without exposing the rest of the admin-only billing surface. +""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.admin_database import get_admin_db +from app.models.plan_billing import PlanBilling +from app.models.plan_limits import PlanLimits +from app.schemas.billing import PublicPlanResponse + +router = APIRouter(prefix="/plans", tags=["plans"]) + + +@router.get("/public", response_model=list[PublicPlanResponse]) +async def list_public_plans( + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> list[PublicPlanResponse]: + """List public, non-archived plans for the marketing /pricing page. + + Public — no auth. Uses `get_admin_db` because this is a cross-tenant read + of the global plan catalog (same pattern as `/config/public`). + """ + stmt = ( + select(PlanBilling, PlanLimits.max_users) + .outerjoin(PlanLimits, PlanBilling.plan == PlanLimits.plan) + .where(PlanBilling.is_public.is_(True)) + .where(PlanBilling.is_archived.is_(False)) + .order_by(PlanBilling.sort_order.asc(), PlanBilling.plan.asc()) + ) + rows = (await db.execute(stmt)).all() + return [ + PublicPlanResponse( + plan=billing.plan, + display_name=billing.display_name, + description=billing.description, + monthly_price_cents=billing.monthly_price_cents, + annual_price_cents=billing.annual_price_cents, + max_seats=max_users, + sort_order=billing.sort_order, + is_public=billing.is_public, + ) + for billing, max_users in rows + ] diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 2839d49c..ce587d4f 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -45,6 +45,7 @@ from app.api.endpoints import ( notifications, oauth as oauth_endpoints, onboarding, + plans_public, public_templates, ratings, scripts, @@ -97,6 +98,7 @@ api_router.include_router(public_templates.router) # Public gallery (no auth, r 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 +api_router.include_router(plans_public.router) # Public plan catalog for /pricing page # --------------------------------------------------------------------------- # Admin endpoints — super_admin only diff --git a/backend/app/schemas/billing.py b/backend/app/schemas/billing.py index 0a1bcf98..aaae78e6 100644 --- a/backend/app/schemas/billing.py +++ b/backend/app/schemas/billing.py @@ -42,3 +42,23 @@ class BillingStateResponse(BaseModel): plan_billing: Optional[PlanBillingState] plan_limits: Dict[str, Any] enabled_features: Dict[str, bool] + + +class PublicPlanResponse(BaseModel): + """Public-safe view of a billable plan, used by the marketing /pricing page. + + Sourced from `plan_billing` joined with `plan_limits.max_users` (exposed + here as `max_seats`). Always filtered server-side to is_public=True and + is_archived=False, so `is_public` is a constant True for any row returned + here — included for clarity and forward compatibility. + """ + plan: str + display_name: str + description: Optional[str] = None + monthly_price_cents: Optional[int] = None + annual_price_cents: Optional[int] = None + max_seats: Optional[int] = None + sort_order: int + is_public: bool = True + + model_config = {"from_attributes": True} diff --git a/backend/tests/test_plans_public.py b/backend/tests/test_plans_public.py new file mode 100644 index 00000000..a676009a --- /dev/null +++ b/backend/tests/test_plans_public.py @@ -0,0 +1,132 @@ +"""Integration tests for the public plans endpoint. + +Covers GET /api/v1/plans/public — the marketing /pricing page data source. +""" + +from __future__ import annotations + +import pytest +from httpx import AsyncClient +from sqlalchemy import delete + +from app.models.plan_billing import PlanBilling +from app.models.plan_limits import PlanLimits + + +async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None: + """Ensure a plan_limits row exists for the given plan name.""" + existing = await test_db.get(PlanLimits, plan) + if existing is None: + test_db.add( + PlanLimits( + plan=plan, + max_trees=None, + max_sessions_per_month=None, + max_users=max_users, + custom_branding=False, + priority_support=False, + export_formats=["markdown", "text"], + ) + ) + await test_db.commit() + + +class TestGetPlansPublic: + """GET /api/v1/plans/public — anonymous, no auth.""" + + @pytest.mark.asyncio + async def test_get_plans_public_returns_only_is_public_rows( + self, client: AsyncClient, test_db + ): + """Rows with is_public=False or is_archived=True must NOT appear.""" + # Wipe any existing billing rows so this test owns the fixture state. + await test_db.execute(delete(PlanBilling)) + await test_db.commit() + + await _seed_plan_limits(test_db, "starter", 3) + await _seed_plan_limits(test_db, "pro", 10) + await _seed_plan_limits(test_db, "internal", None) + await _seed_plan_limits(test_db, "legacy", 5) + + test_db.add_all( + [ + PlanBilling( + plan="starter", + display_name="Starter", + monthly_price_cents=1900, + is_public=True, + is_archived=False, + sort_order=10, + ), + PlanBilling( + plan="pro", + display_name="Pro", + monthly_price_cents=4900, + is_public=True, + is_archived=False, + sort_order=20, + ), + PlanBilling( + plan="internal", + display_name="Internal", + is_public=False, # hidden + is_archived=False, + sort_order=30, + ), + PlanBilling( + plan="legacy", + display_name="Legacy", + is_public=True, + is_archived=True, # archived + sort_order=40, + ), + ] + ) + await test_db.commit() + + response = await client.get("/api/v1/plans/public") + assert response.status_code == 200 + plans = response.json() + plan_names = {p["plan"] for p in plans} + + assert "starter" in plan_names + assert "pro" in plan_names + assert "internal" not in plan_names + assert "legacy" not in plan_names + + # Schema sanity check + starter = next(p for p in plans if p["plan"] == "starter") + assert starter["display_name"] == "Starter" + assert starter["monthly_price_cents"] == 1900 + assert starter["max_seats"] == 3 + assert starter["is_public"] is True + + @pytest.mark.asyncio + async def test_get_plans_public_orders_by_sort_order_then_plan( + self, client: AsyncClient, test_db + ): + """Result must be ordered by sort_order ASC, then plan name ASC.""" + await test_db.execute(delete(PlanBilling)) + await test_db.commit() + + # plan_limits rows for FK satisfaction + for name in ("alpha", "bravo", "charlie", "delta"): + await _seed_plan_limits(test_db, name, None) + + # Two with sort_order=10 (charlie should come before delta by plan ASC), + # one with sort_order=5 (alpha first overall), + # one with sort_order=20 (bravo last). + test_db.add_all( + [ + PlanBilling(plan="charlie", display_name="C", sort_order=10, is_public=True, is_archived=False), + PlanBilling(plan="delta", display_name="D", sort_order=10, is_public=True, is_archived=False), + PlanBilling(plan="alpha", display_name="A", sort_order=5, is_public=True, is_archived=False), + PlanBilling(plan="bravo", display_name="B", sort_order=20, is_public=True, is_archived=False), + ] + ) + await test_db.commit() + + response = await client.get("/api/v1/plans/public") + assert response.status_code == 200 + ordered = [p["plan"] for p in response.json()] + assert ordered == ["alpha", "charlie", "delta", "bravo"] diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1d0ca2f4..64ff005d 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -10,6 +10,8 @@ export { default as stepsApi } from './steps' export { default as stepCategoriesApi } from './stepCategories' export { default as accountsApi } from './accounts' export { default as billingApi } from './billing' +export { default as plansApi } from './plans' +export type { PublicPlanResponse } from './plans' export { default as usageApi } from './usage' export { default as adminApi } from './admin' export { treeMarkdownApi } from './treeMarkdown' diff --git a/frontend/src/api/plans.ts b/frontend/src/api/plans.ts new file mode 100644 index 00000000..17b799af --- /dev/null +++ b/frontend/src/api/plans.ts @@ -0,0 +1,22 @@ +import apiClient from './client' + +export interface PublicPlanResponse { + plan: string + display_name: string + description: string | null + monthly_price_cents: number | null + annual_price_cents: number | null + max_seats: number | null + sort_order: number + is_public: boolean +} + +export const plansApi = { + /** Public plan catalog for the marketing /pricing page. No auth. */ + async getPublic(): Promise { + const response = await apiClient.get('/plans/public') + return response.data + }, +} + +export default plansApi diff --git a/frontend/src/pages/PricingPage.tsx b/frontend/src/pages/PricingPage.tsx new file mode 100644 index 00000000..6d5f4745 --- /dev/null +++ b/frontend/src/pages/PricingPage.tsx @@ -0,0 +1,440 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +import { plansApi, type PublicPlanResponse } from '@/api/plans' +import { PageMeta } from '@/components/common/PageMeta' +import { useAppConfig } from '@/hooks/useAppConfig' +import '@/styles/landing.css' + +/* --------------------------------------------------------------------------- + * v1 hardcoded comparison table + * + * The marketing /pricing page surfaces a small "what's in each plan" table. + * Long-term, the source of truth for "plan X has feature Y" should be a + * server-side feature-flag mapping (likely keyed off feature_flag.display_name + * + plan_features). For v1 we hardcode the well-known features so we can ship + * the page without a backend dependency. Replace this block when a server-side + * feature mapping endpoint exists. + * ------------------------------------------------------------------------- */ +type PlanColumn = 'starter' | 'pro' | 'enterprise' + +const COMPARISON_ROWS: Array<{ + feature: string + values: Record +}> = [ + { feature: 'PSA Integration', values: { starter: false, pro: true, enterprise: true } }, + { feature: 'KB Accelerator', values: { starter: false, pro: true, enterprise: true } }, + { feature: 'AI Builder', values: { starter: true, pro: true, enterprise: true } }, + { feature: 'Custom Branding', values: { starter: false, pro: false, enterprise: true } }, + { feature: 'Priority Support', values: { starter: false, pro: true, enterprise: true } }, +] + +function formatPrice(cents: number | null | undefined): string { + if (cents == null) return '' + const dollars = cents / 100 + // Whole dollars (no decimals) for marketing display. + return `$${Math.round(dollars).toLocaleString()}` +} + +function PricingNotFound() { + return ( +
+

Page not found

+

This page is not available.

+ + Go to login + +
+ ) +} + +interface PlanCardProps { + plan: PublicPlanResponse | null + fallback: { + plan: string + display_name: string + description: string + } + recommended?: boolean + hidePrice?: boolean + ctaLabel: string + ctaHref: string + ctaTestId: string +} + +function PlanCard({ plan, fallback, recommended, hidePrice, ctaLabel, ctaHref, ctaTestId }: PlanCardProps) { + const displayName = plan?.display_name ?? fallback.display_name + const description = plan?.description ?? fallback.description + const monthlyCents = plan?.monthly_price_cents ?? null + + return ( +
+ {recommended && ( + + Recommended + + )} + +
+

+ {displayName} +

+

+ {description} +

+
+ +
+ {hidePrice ? ( +
+ Custom pricing +
+ ) : monthlyCents != null ? ( +
+ + {formatPrice(monthlyCents)} + + / month +
+ ) : ( +
Contact us
+ )} +
+ + + {ctaLabel} + +
+ ) +} + +export function PricingPage() { + const appConfig = useAppConfig() + const [plans, setPlans] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Fetch plans on mount when self-serve is enabled. + useEffect(() => { + if (appConfig.isLoading) return + if (!appConfig.self_serve_enabled) return + + let cancelled = false + setLoading(true) + plansApi + .getPublic() + .then((data) => { + if (cancelled) return + setPlans(data) + setError(null) + }) + .catch(() => { + if (cancelled) return + // Non-fatal: page still renders with fallback descriptions and no + // server-driven prices. The CTA still works via /register?plan=... + setError('Unable to load live pricing.') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [appConfig.isLoading, appConfig.self_serve_enabled]) + + // Self-serve disabled: render a 404-style fallback. Done after hooks so + // the React rules-of-hooks invariant holds. + if (!appConfig.isLoading && !appConfig.self_serve_enabled) { + return ( + <> + + + + ) + } + + const planByName = (name: string) => + plans?.find((p) => p.plan.toLowerCase() === name) ?? null + + return ( +
+ + +
+ {/* ---- HERO ---- */} +
+

+ Simple pricing for MSPs of every size +

+

+ Try Pro free for 14 days. No credit card required. +

+
+ + {/* ---- PLAN CARDS ---- */} +
+ + + +
+ + {loading && ( +
+ Loading pricing… +
+ )} + {error && ( +
+ {error} +
+ )} + + {/* ---- COMPARISON TABLE ---- */} +
+

+ Compare plans +

+
+ + + + + + + + + + + {COMPARISON_ROWS.map((row) => ( + + + + + + + ))} + +
+ Feature + StarterProEnterprise
{row.feature} + {row.values.starter ? '✓' : '—'} + + {row.values.pro ? '✓' : '—'} + + {row.values.enterprise ? '✓' : '—'} +
+
+
+ + {/* ---- TESTIMONIAL SLOT (placeholder) ---- */} +
+
+ "Pilot testimonials coming soon." +
+
+ ResolutionFlow pilot, 2026 +
+
+ + {/* ---- TRUST STRIP ---- */} +
+ Built on Stripe + AWS · Encrypted in transit and at rest +
+
+
+ ) +} + +export default PricingPage diff --git a/frontend/src/pages/__tests__/PricingPage.test.tsx b/frontend/src/pages/__tests__/PricingPage.test.tsx new file mode 100644 index 00000000..e7aa7f76 --- /dev/null +++ b/frontend/src/pages/__tests__/PricingPage.test.tsx @@ -0,0 +1,162 @@ +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 { PricingPage } from '../PricingPage' +import { plansApi, type PublicPlanResponse } from '@/api/plans' +import { + __resetAppConfigCache, + __setAppConfigCache, +} from '@/hooks/useAppConfig' + +vi.mock('@/api/plans', () => ({ + plansApi: { + getPublic: vi.fn(), + }, +})) + +const STARTER: PublicPlanResponse = { + plan: 'starter', + display_name: 'Starter', + description: 'For solo techs.', + monthly_price_cents: 1900, + annual_price_cents: 19000, + max_seats: 3, + sort_order: 10, + is_public: true, +} + +const PRO: PublicPlanResponse = { + plan: 'pro', + display_name: 'Pro', + description: 'For growing MSP teams.', + monthly_price_cents: 4900, + annual_price_cents: 49000, + max_seats: 10, + sort_order: 20, + is_public: true, +} + +const ENTERPRISE: PublicPlanResponse = { + plan: 'enterprise', + display_name: 'Enterprise', + description: 'Custom seats + branding.', + monthly_price_cents: null, + annual_price_cents: null, + max_seats: null, + sort_order: 30, + is_public: true, +} + +function renderPage() { + return render( + + + + + , + ) +} + +describe('PricingPage', () => { + beforeEach(() => { + __resetAppConfigCache() + vi.clearAllMocks() + }) + + it('shows three plan cards with prices from API', async () => { + __setAppConfigCache({ + self_serve_enabled: true, + oauth_providers: [], + }) + vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE]) + + renderPage() + + await waitFor(() => { + expect(plansApi.getPublic).toHaveBeenCalled() + }) + + // Three plan cards present. + expect(screen.getByTestId('plan-card-starter')).toBeInTheDocument() + expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument() + expect(screen.getByTestId('plan-card-enterprise')).toBeInTheDocument() + + // Prices from API rendered. + await waitFor(() => { + expect(screen.getByText('$19')).toBeInTheDocument() + expect(screen.getByText('$49')).toBeInTheDocument() + }) + + // Enterprise card hides price (shows "Custom pricing" instead). + expect(screen.getByText(/Custom pricing/i)).toBeInTheDocument() + + // Pro is recommended. + expect(screen.getByTestId('recommended-badge')).toBeInTheDocument() + }) + + it('Start free trial button navigates to /register?plan=pro', async () => { + __setAppConfigCache({ + self_serve_enabled: true, + oauth_providers: [], + }) + vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE]) + + renderPage() + + const proCta = await screen.findByTestId('cta-pro') + expect(proCta).toHaveAttribute('href', '/register?plan=pro') + expect(proCta).toHaveTextContent(/Start free trial/i) + + const starterCta = screen.getByTestId('cta-starter') + expect(starterCta).toHaveAttribute('href', '/register?plan=starter') + }) + + it('Talk to sales button navigates to /contact-sales', async () => { + __setAppConfigCache({ + self_serve_enabled: true, + oauth_providers: [], + }) + vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE]) + + renderPage() + + const enterpriseCta = await screen.findByTestId('cta-enterprise') + expect(enterpriseCta).toHaveAttribute('href', '/contact-sales') + expect(enterpriseCta).toHaveTextContent(/Talk to sales/i) + }) + + it('returns 404 when self_serve_enabled is false', async () => { + __setAppConfigCache({ + self_serve_enabled: false, + oauth_providers: [], + }) + + renderPage() + + await waitFor(() => { + expect(screen.getByTestId('pricing-not-found')).toBeInTheDocument() + }) + expect(screen.getByText(/Page not found/i)).toBeInTheDocument() + + // No plan cards rendered, no API call made. + expect(screen.queryByTestId('plan-card-starter')).not.toBeInTheDocument() + expect(plansApi.getPublic).not.toHaveBeenCalled() + }) + + it('uses softer trust language (no SOC2/DPA claim yet)', async () => { + __setAppConfigCache({ + self_serve_enabled: true, + oauth_providers: [], + }) + vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE]) + + renderPage() + + const trust = await screen.findByTestId('trust-strip') + expect(trust).toHaveTextContent(/Built on Stripe \+ AWS/i) + expect(trust).toHaveTextContent(/Encrypted in transit and at rest/i) + expect(trust).not.toHaveTextContent(/SOC ?2/i) + }) +}) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 235ef710..2a7db0ca 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -22,6 +22,7 @@ const SurveyPage = lazyWithRetry(() => import('@/pages/SurveyPage')) const SurveyThankYouPage = lazyWithRetry(() => import('@/pages/SurveyThankYouPage')) const PrivacyPage = lazyWithRetry(() => import('@/pages/PrivacyPage')) const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage')) +const PricingPage = lazyWithRetry(() => import('@/pages/PricingPage')) // Standalone auth pages const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage')) @@ -131,6 +132,11 @@ export const router = sentryCreateBrowserRouter([ element: page(TermsPage), errorElement: , }, + { + path: '/pricing', + element: page(PricingPage), + errorElement: , + }, { path: '/login', element: , -- 2.49.1 From db2478dd892522c8114d598448c6b0aac8fb83d3 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 23:31:56 -0400 Subject: [PATCH 17/29] feat(sales): add /contact-sales form + landing page CTA Public Talk-to-Sales surface and a "See pricing" hero CTA on the marketing landing page. Phase 2 Task 43 of self-serve signup. - frontend/src/api/sales.ts: salesApi.createLead -> POST /sales-leads. - ContactSalesPage at /contact-sales (public, gated by self_serve_enabled with a 404-style fallback). Form fields: name, work email, company, team size (1-2 / 3-5 / 6-10 / 11-25 / 26+), and an optional "what brought you here?" textarea -> message. Submit button disabled while in flight to block duplicate submissions. - Confirmation surface replaces the form on success. Calendly block is hidden when VITE_CALENDLY_URL is unset. - detectSource(): 'pricing_page' if document.referrer contains '/pricing', else 'landing_page'. Server emits the canonical PostHog talk_to_sales_form_submitted event with this source. - LandingPage: new "See pricing" hero CTA gated by useAppConfig(). self_serve_enabled. - frontend/.env.example + Dockerfile: VITE_CALENDLY_URL ARG/ENV. - Tests: ContactSalesPage submit/confirmation, Calendly hide-when-unset, in-flight de-dup, 404 when self-serve off; LandingPage CTA on/off. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/.env.example | 5 + frontend/Dockerfile | 2 + frontend/src/api/index.ts | 6 + frontend/src/api/sales.ts | 32 ++ frontend/src/pages/ContactSalesPage.tsx | 396 ++++++++++++++++++ frontend/src/pages/LandingPage.tsx | 11 + .../pages/__tests__/ContactSalesPage.test.tsx | 146 +++++++ .../src/pages/__tests__/LandingPage.test.tsx | 69 +++ frontend/src/router.tsx | 6 + 9 files changed, 673 insertions(+) create mode 100644 frontend/src/api/sales.ts create mode 100644 frontend/src/pages/ContactSalesPage.tsx create mode 100644 frontend/src/pages/__tests__/ContactSalesPage.test.tsx create mode 100644 frontend/src/pages/__tests__/LandingPage.test.tsx diff --git a/frontend/.env.example b/frontend/.env.example index 574a1872..9c2f59cb 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -21,3 +21,8 @@ VITE_OAUTH_REDIRECT_BASE= # Self-serve signup safety fallback used by useAppConfig when GET /config/public # is unreachable. Authoritative value comes from backend SELF_SERVE_ENABLED. VITE_SELF_SERVE_ENABLED=false + +# Calendly link surfaced on the /contact-sales confirmation screen. When unset, +# the "Want to skip ahead?" block is hidden. Vite bakes at build time, so prod +# requires ARG+ENV in frontend/Dockerfile. +VITE_CALENDLY_URL= diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6bcdc0c8..66b67c4a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -22,6 +22,7 @@ ARG VITE_GOOGLE_CLIENT_ID ARG VITE_MS_CLIENT_ID ARG VITE_OAUTH_REDIRECT_BASE ARG VITE_SELF_SERVE_ENABLED +ARG VITE_CALENDLY_URL ENV VITE_API_URL=$VITE_API_URL ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY @@ -31,6 +32,7 @@ ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID ENV VITE_MS_CLIENT_ID=$VITE_MS_CLIENT_ID ENV VITE_OAUTH_REDIRECT_BASE=$VITE_OAUTH_REDIRECT_BASE ENV VITE_SELF_SERVE_ENABLED=$VITE_SELF_SERVE_ENABLED +ENV VITE_CALENDLY_URL=$VITE_CALENDLY_URL # Build the application RUN npm run build diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 64ff005d..3c079da1 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -12,6 +12,12 @@ export { default as accountsApi } from './accounts' export { default as billingApi } from './billing' export { default as plansApi } from './plans' export type { PublicPlanResponse } from './plans' +export { default as salesApi } from './sales' +export type { + SalesLeadCreatePayload, + SalesLeadCreateResponse, + SalesLeadSource, +} from './sales' export { default as usageApi } from './usage' export { default as adminApi } from './admin' export { treeMarkdownApi } from './treeMarkdown' diff --git a/frontend/src/api/sales.ts b/frontend/src/api/sales.ts new file mode 100644 index 00000000..5501d3bc --- /dev/null +++ b/frontend/src/api/sales.ts @@ -0,0 +1,32 @@ +import apiClient from './client' + +export type SalesLeadSource = 'pricing_page' | 'register_footer' | 'landing_page' + +export interface SalesLeadCreatePayload { + email: string + name: string + company: string + team_size?: string + message?: string + source: SalesLeadSource + posthog_distinct_id?: string +} + +export interface SalesLeadCreateResponse { + id: string + status: 'received' +} + +export const salesApi = { + /** + * Public Talk-to-Sales submission. No auth required. Rate-limited per IP + * server-side (5/hour). Server emits PostHog `talk_to_sales_form_submitted` + * — frontend should NOT also fire this event. + */ + async createLead(payload: SalesLeadCreatePayload): Promise { + const response = await apiClient.post('/sales-leads', payload) + return response.data + }, +} + +export default salesApi diff --git a/frontend/src/pages/ContactSalesPage.tsx b/frontend/src/pages/ContactSalesPage.tsx new file mode 100644 index 00000000..dd083ba6 --- /dev/null +++ b/frontend/src/pages/ContactSalesPage.tsx @@ -0,0 +1,396 @@ +import { useMemo, useState, type FormEvent } from 'react' +import { Link } from 'react-router-dom' + +import { salesApi, type SalesLeadSource } from '@/api/sales' +import { PageMeta } from '@/components/common/PageMeta' +import { useAppConfig } from '@/hooks/useAppConfig' +import '@/styles/landing.css' + +/* --------------------------------------------------------------------------- + * Source detection + * + * The backend `/sales-leads` endpoint requires a `source` enum. We classify + * by document.referrer: visitors landing on /contact-sales after viewing the + * pricing page get tagged `pricing_page`; everything else is `landing_page`. + * `register_footer` is reserved for the (future) sign-up footer CTA. + * + * Acceptable for v1 — server-side PostHog event uses this same `source` value. + * ------------------------------------------------------------------------- */ +function detectSource(): SalesLeadSource { + if (typeof document === 'undefined') return 'landing_page' + const ref = document.referrer || '' + if (ref.includes('/pricing')) return 'pricing_page' + return 'landing_page' +} + +const TEAM_SIZE_OPTIONS: Array<{ value: string; label: string }> = [ + { value: '', label: 'Select team size' }, + { value: '1-2', label: '1–2' }, + { value: '3-5', label: '3–5' }, + { value: '6-10', label: '6–10' }, + { value: '11-25', label: '11–25' }, + { value: '26+', label: 'More than 26' }, +] + +interface FormState { + name: string + email: string + company: string + team_size: string + message: string +} + +const INITIAL: FormState = { + name: '', + email: '', + company: '', + team_size: '', + message: '', +} + +function ContactSalesNotFound() { + return ( +
+

Page not found

+

This page is not available.

+ + Go to login + +
+ ) +} + +export function ContactSalesPage() { + const appConfig = useAppConfig() + const [form, setForm] = useState(INITIAL) + const [submitting, setSubmitting] = useState(false) + const [submitted, setSubmitted] = useState(false) + const [error, setError] = useState(null) + + const calendlyUrl = useMemo(() => { + const raw = import.meta.env.VITE_CALENDLY_URL + return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : '' + }, []) + + // Self-serve disabled: 404. (Same pattern as PricingPage — done after hooks.) + if (!appConfig.isLoading && !appConfig.self_serve_enabled) { + return ( + <> + + + + ) + } + + const handleChange = + (field: keyof FormState) => + (e: React.ChangeEvent) => { + setForm((prev) => ({ ...prev, [field]: e.target.value })) + } + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + if (submitting) return + + const name = form.name.trim() + const email = form.email.trim() + const company = form.company.trim() + + if (!name || !email || !company) { + setError('Please fill in name, work email, and company.') + return + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setError('Enter a valid work email address.') + return + } + + setSubmitting(true) + setError(null) + try { + await salesApi.createLead({ + name, + email, + company, + team_size: form.team_size || undefined, + message: form.message.trim() || undefined, + source: detectSource(), + }) + setSubmitted(true) + } catch { + // The backend may rate-limit (429) or reject for validation; surface a + // generic message and allow retry. Don't leak internal errors. + setError('Something went wrong. Please try again or email hello@resolutionflow.com.') + } finally { + setSubmitting(false) + } + } + + return ( +
+ + +
+
+

+ Talk to Sales +

+

+ Tell us about your MSP. We’ll reach out within 1 business day. +

+
+ +
+ {submitted ? ( +
+

+ Thanks — we’ll reach out within 1 business day. +

+ {calendlyUrl && ( +
+

+ Want to skip ahead? +

+ + Book a time + +
+ )} +
+ ) : ( +
+ + + + + + + + + + + + + + + + + +