15 Commits

Author SHA1 Message Date
0c326d0616 feat(dashboard): replace checklist with next-step card + unified list
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) <noreply@anthropic.com>
2026-05-06 23:19:58 -04:00
99343ab7a9 feat(dashboard): add TrialPill in AppLayout topbar
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 <Link>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) <noreply@anthropic.com>
2026-05-06 23:06:09 -04:00
53dd5f13e5 feat(onboarding): add wizard Steps 2 (PSA) and 3 (Invite team)
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) <noreply@anthropic.com>
2026-05-06 23:02:00 -04:00
9b517d3320 feat(onboarding): add welcome wizard scaffold + Step 1 (Your shop)
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) <noreply@anthropic.com>
2026-05-06 22:54:10 -04:00
7d939a4acf 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) <noreply@anthropic.com>
2026-05-06 22:46:43 -04:00
39e85c9770 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) <noreply@anthropic.com>
2026-05-06 21:34:22 -04:00
70ab1f34d4 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) <noreply@anthropic.com>
2026-05-06 21:15:25 -04:00
ece82225f2 feat(billing): add FeatureGate, UpgradePrompt, EmailVerificationGate components
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 <noreply@anthropic.com>
2026-05-06 21:01:53 -04:00
0b5ed9aa10 feat(billing): add useFeature, useFeatureLimit, useTrialBanner hooks
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 <noreply@anthropic.com>
2026-05-06 20:55:58 -04:00
7a9cb4b03b 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 <noreply@anthropic.com>
2026-05-06 20:47:50 -04:00
80baf89b00 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 <noreply@anthropic.com>
2026-05-06 20:38:50 -04:00
d05b475a41 feat(admin): extend /admin/plan-limits to manage plan_billing fields
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 <noreply@anthropic.com>
2026-05-06 20:35:10 -04:00
694279f89e feat(sales): add POST /sales-leads public endpoint
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 <noreply@anthropic.com>
2026-05-06 20:12:03 -04:00
16f5e4ce05 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 <noreply@anthropic.com>
2026-05-06 20:04:43 -04:00
2f8ec3775e 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 <noreply@anthropic.com>
2026-05-06 20:00:08 -04:00
92 changed files with 8297 additions and 419 deletions

View File

@@ -235,6 +235,7 @@ _SUBSCRIPTION_GUARD_ALLOWLIST = {
"/api/v1/billing/portal-session", "/api/v1/billing/portal-session",
"/api/v1/users/me", "/api/v1/users/me",
"/api/v1/users/me/onboarding-step", "/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/email/verify",
"/api/v1/auth/password/change", "/api/v1/auth/password/change",
"/api/v1/users/me", "/api/v1/users/me",
"/api/v1/users/me/onboarding-step",
"/api/v1/users/me/onboarding-dismiss-rest",
"/api/v1/billing/state", "/api/v1/billing/state",
"/api/v1/billing/checkout-session", "/api/v1/billing/checkout-session",
"/api/v1/billing/portal-session", "/api/v1/billing/portal-session",

View File

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

View File

@@ -8,34 +8,101 @@ from app.core.database import get_db
from app.core.audit import log_audit from app.core.audit import log_audit
from app.models.user import User from app.models.user import User
from app.models.plan_limits import PlanLimits from app.models.plan_limits import PlanLimits
from app.models.plan_billing import PlanBilling
from app.models.account import Account from app.models.account import Account
from app.models.account_limit_override import AccountLimitOverride from app.models.account_limit_override import AccountLimitOverride
from app.models.subscription import Subscription
from app.schemas.admin import ( from app.schemas.admin import (
PlanLimitResponse, PlanLimitUpdate, PlanLimitResponse, PlanLimitUpdate, PlanLimitWithBillingResponse,
AccountOverrideCreate, AccountOverrideUpdate, AccountOverrideResponse, AccountOverrideCreate, AccountOverrideUpdate, AccountOverrideResponse,
) )
from app.api.deps import require_admin from app.api.deps import require_admin
from app.services.billing import BillingService
router = APIRouter(prefix="/admin", tags=["admin-plan-limits"]) 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( async def list_plan_limits(
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)], current_user: Annotated[User, Depends(require_admin)],
): ):
"""List all plan limit configurations.""" """List all plan limit configurations, merged with plan_billing fields
result = await db.execute(select(PlanLimits)) where present. Plans without a plan_billing row return None for the
return result.scalars().all() 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( async def update_plan_limits(
data: PlanLimitUpdate, data: PlanLimitUpdate,
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)], 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)) result = await db.execute(select(PlanLimits).where(PlanLimits.plan == data.plan))
plan = result.scalar_one_or_none() plan = result.scalar_one_or_none()
if not plan: if not plan:
@@ -48,10 +115,50 @@ async def update_plan_limits(
plan.priority_support = data.priority_support plan.priority_support = data.priority_support
plan.export_formats = data.export_formats 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.commit()
await db.refresh(plan) 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]) @router.get("/account-overrides", response_model=list[AccountOverrideResponse])

View File

@@ -136,7 +136,15 @@ async def register(
# Validate platform invite code (skip if account invite was provided) # Validate platform invite code (skip if account invite was provided)
invite_code_record = None invite_code_record = None
if not account_invite_record: 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( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code is required" detail="Invite code is required"

View File

@@ -1,6 +1,6 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.account import Account
from app.models.user import User from app.models.user import User
from app.schemas.billing import ( from app.schemas.billing import (
BillingPortalSessionResponse,
BillingStateResponse, BillingStateResponse,
CheckoutSessionCreate, CheckoutSessionCreate,
CheckoutSessionResponse, CheckoutSessionResponse,
@@ -50,3 +51,26 @@ async def get_billing_state(
)).scalar_one() )).scalar_one()
state = await BillingService.get_billing_state(db, account) state = await BillingService.get_billing_state(db, account)
return BillingStateResponse(**state) 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)

View File

@@ -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,
)

View File

@@ -11,6 +11,7 @@ from app.core.admin_database import get_admin_db
from app.core.config import settings from app.core.config import settings
from app.core.security import create_access_token, create_refresh_token from app.core.security import create_access_token, create_refresh_token
from app.models.account import Account from app.models.account import Account
from app.models.account_invite import AccountInvite
from app.models.oauth_identity import OAuthIdentity from app.models.oauth_identity import OAuthIdentity
from app.models.user import User from app.models.user import User
from app.schemas.oauth import OAuthCallbackPayload, OAuthCallbackResponse 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( 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]: ) -> 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 = ( identity = (
await db.execute( await db.execute(
select(OAuthIdentity).where( select(OAuthIdentity).where(
@@ -53,28 +66,96 @@ async def _sign_in_or_register(
await db.execute(select(User).where(User.email == profile.email)) await db.execute(select(User).where(User.email == profile.email))
).scalar_one_or_none() ).scalar_one_or_none()
is_new_user = user is 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: if is_new_user:
account = Account( if invite_record is not None:
name=f"{profile.name}'s Account", # Join the invited account directly — no personal account, no
display_code=_generate_display_code(), # trial creation.
) user = User(
db.add(account) email=profile.email,
await db.flush() name=profile.name,
user = User( password_hash=None,
email=profile.email, account_id=invite_record.account_id,
name=profile.name, account_role=invite_record.role,
password_hash=None, role="engineer",
account_id=account.id, email_verified_at=datetime.now(timezone.utc),
account_role="owner", )
role="engineer", db.add(user)
email_verified_at=datetime.now(timezone.utc), await db.flush()
) invite_record.accepted_by_id = user.id
db.add(user) invite_record.used_at = datetime.now(timezone.utc)
await db.flush() await db.flush()
account.owner_id = user.id else:
await db.flush() account = Account(
# start_trial commits internally; flushed account/user above. name=f"{profile.name}'s Account",
await BillingService.start_trial(db, account.id) 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( db.add(
OAuthIdentity( OAuthIdentity(
@@ -98,7 +179,13 @@ async def google_callback(
raise HTTPException(status_code=503, detail="Google sign-in not configured") raise HTTPException(status_code=503, detail="Google sign-in not configured")
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/google/callback" redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/google/callback"
profile = await google_exchange_code(payload.code, redirect_uri) 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( return OAuthCallbackResponse(
access_token=create_access_token({"sub": str(user.id)}), access_token=create_access_token({"sub": str(user.id)}),
refresh_token=create_refresh_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") raise HTTPException(status_code=503, detail="Microsoft sign-in not configured")
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/microsoft/callback" redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/microsoft/callback"
profile = await microsoft_exchange_code(payload.code, redirect_uri) 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( return OAuthCallbackResponse(
access_token=create_access_token({"sub": str(user.id)}), access_token=create_access_token({"sub": str(user.id)}),
refresh_token=create_refresh_token({"sub": str(user.id)}), refresh_token=create_refresh_token({"sub": str(user.id)}),

View File

@@ -2,19 +2,24 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user from app.api.deps import get_current_active_user
from app.core.database import get_db from app.core.database import get_db
from app.core.admin_database import get_admin_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.assistant_chat import AssistantChat
from app.models.psa_connection import PsaConnection from app.models.psa_connection import PsaConnection
from app.models.session import Session from app.models.session import Session
from app.models.tree import Tree from app.models.tree import Tree
from app.models.user import User 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"]) router = APIRouter(prefix="/users", tags=["onboarding"])
@@ -85,6 +90,10 @@ async def get_onboarding_status(
) )
connected_psa = (psa_q.scalar() or 0) > 0 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( return OnboardingStatus(
created_flow=created_flow, created_flow=created_flow,
ran_session=ran_session, ran_session=ran_session,
@@ -94,6 +103,8 @@ async def get_onboarding_status(
connected_psa=connected_psa, connected_psa=connected_psa,
is_team_user=is_team_user, is_team_user=is_team_user,
dismissed=current_user.onboarding_dismissed, dismissed=current_user.onboarding_dismissed,
email_verified=email_verified,
shop_setup_done=shop_setup_done,
) )
@@ -109,3 +120,98 @@ async def dismiss_onboarding(
# Return updated status (reuse the GET logic) # Return updated status (reuse the GET logic)
return await get_onboarding_status(db=db, current_user=current_user) 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,
)

View File

@@ -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")

View File

@@ -26,8 +26,10 @@ from app.api.endpoints import (
billing, billing,
beta_feedback, beta_feedback,
beta_signup, beta_signup,
sales_leads,
branding, branding,
categories, categories,
config as config_endpoints,
copilot, copilot,
device_types, device_types,
draft_templates, draft_templates,
@@ -68,6 +70,7 @@ from app.api.endpoints import (
uploads, uploads,
webhooks, webhooks,
accounts, accounts,
account_invite_lookup,
) )
api_router = APIRouter() api_router = APIRouter()
@@ -88,9 +91,12 @@ 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(shared.router) # Public share links (no auth)
api_router.include_router(shares.public_router) # Public session share links (optional 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(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(webhooks.router) # Stripe webhook receiver
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited) 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(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 # Admin endpoints — super_admin only

View File

@@ -84,6 +84,7 @@ class Settings(BaseSettings):
RESEND_API_KEY: Optional[str] = None RESEND_API_KEY: Optional[str] = None
FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>" FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>"
FEEDBACK_EMAIL: Optional[str] = None FEEDBACK_EMAIL: Optional[str] = None
SALES_LEAD_RECIPIENT_EMAIL: str = "sales@resolutionflow.com"
@property @property
def email_enabled(self) -> bool: def email_enabled(self) -> bool:

View File

@@ -1,6 +1,11 @@
import logging import logging
from typing import TYPE_CHECKING
from app.core.config import settings from app.core.config import settings
if TYPE_CHECKING:
from app.models.sales_lead import SalesLead
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -484,6 +489,99 @@ class EmailService:
logger.exception("Failed to send beta signup notification for %s", signup_email) logger.exception("Failed to send beta signup notification for %s", signup_email)
return False 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"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">New Sales Lead</p>
</td></tr>
<tr><td style="padding:0 40px 16px;">
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
Source: <strong style="color:#f8fafc;">{safe_source}</strong>
</p>
</td></tr>
<tr><td style="padding:0 40px 16px;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.06);border-radius:12px;">
<tr><td style="padding:16px;">
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Name</p>
<p style="margin:0 0 12px;color:#f8fafc;font-size:16px;font-weight:600;">{safe_name}</p>
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Email</p>
<p style="margin:0 0 12px;color:#22d3ee;font-size:16px;font-weight:600;">{safe_email}</p>
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Company</p>
<p style="margin:0 0 12px;color:#f8fafc;font-size:16px;font-weight:600;">{safe_company}</p>
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Team Size</p>
<p style="margin:0;color:#f8fafc;font-size:16px;font-weight:600;">{safe_team_size}</p>
</td></tr>
</table>
</td></tr>
<tr><td style="padding:0 40px 16px;">
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Message</p>
<p style="margin:0;color:#8891a0;font-size:14px;line-height:1.6;white-space:pre-wrap;">{safe_message}</p>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
Submitted at {date_str} · Lead ID: {lead.id}
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
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 @staticmethod
async def send_notification_email( async def send_notification_email(
to_email: str, to_email: str,

View File

@@ -172,6 +172,21 @@ class PlanLimitResponse(BaseModel):
from_attributes = True 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): class PlanLimitUpdate(BaseModel):
plan: str plan: str
max_trees: Optional[int] = None max_trees: Optional[int] = None
@@ -180,6 +195,19 @@ class PlanLimitUpdate(BaseModel):
custom_branding: bool = False custom_branding: bool = False
priority_support: bool = False priority_support: bool = False
export_formats: list = Field(default_factory=lambda: ["markdown", "text"]) 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): class AccountOverrideCreate(BaseModel):

View File

@@ -13,6 +13,10 @@ class CheckoutSessionResponse(BaseModel):
url: str url: str
class BillingPortalSessionResponse(BaseModel):
url: str
class SubscriptionState(BaseModel): class SubscriptionState(BaseModel):
status: str status: str
plan: str plan: str

View File

@@ -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]

View File

@@ -4,6 +4,11 @@ from pydantic import BaseModel
class OAuthCallbackPayload(BaseModel): class OAuthCallbackPayload(BaseModel):
code: str code: str
state: str | None = None 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): class OAuthCallbackResponse(BaseModel):
@@ -11,3 +16,17 @@ class OAuthCallbackResponse(BaseModel):
refresh_token: str refresh_token: str
token_type: str = "bearer" token_type: str = "bearer"
is_new_user: bool is_new_user: bool
class InviteLookupResponse(BaseModel):
"""Public response surface for GET /accounts/invites/{code}/lookup.
Returns the minimum context needed for the AcceptInvitePage:
account name (so we can title the card), inviter name (for the resend
fallback message), invited email (locked into the form), and role.
"""
account_name: str
inviter_name: str
invited_email: str
role: str

View File

@@ -1,12 +1,55 @@
from pydantic import BaseModel from typing import Literal, Optional
from pydantic import BaseModel, Field
class OnboardingStatus(BaseModel): class OnboardingStatus(BaseModel):
created_flow: bool created_flow: bool
ran_session: bool ran_session: bool
exported_session: bool exported_session: bool
# Kept for backward-compat during deploy; new code paths should not branch on this.
tried_ai_assistant: bool tried_ai_assistant: bool
invited_teammate: bool invited_teammate: bool
connected_psa: bool connected_psa: bool
is_team_user: bool is_team_user: bool
dismissed: 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) ----------------------------------------------
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

View File

@@ -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"

View File

@@ -58,6 +58,8 @@ class UserResponse(UserBase):
timezone: str = "UTC" timezone: str = "UTC"
avatar_url: Optional[str] = None avatar_url: Optional[str] = None
email_verified_at: Optional[datetime] = None email_verified_at: Optional[datetime] = None
onboarding_step_completed: Optional[int] = None
onboarding_dismissed: bool = False
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -1,6 +1,7 @@
"""Single billing service module. Stripe is the only impl — no provider """Single billing service module. Stripe is the only impl — no provider
abstraction. Account row is canonical local state; Stripe is canonical abstraction. Account row is canonical local state; Stripe is canonical
remote state; the webhook handler bridges the two.""" remote state; the webhook handler bridges the two."""
import logging
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import stripe import stripe
@@ -17,8 +18,32 @@ from app.models.subscription import Subscription
TRIAL_DAYS = 14 TRIAL_DAYS = 14
logger = logging.getLogger(__name__)
class BillingService: 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 @staticmethod
async def start_trial(db: AsyncSession, account_id) -> Subscription: async def start_trial(db: AsyncSession, account_id) -> Subscription:
"""Idempotent. Creates a trialing Subscription on Pro for the account if """Idempotent. Creates a trialing Subscription on Pro for the account if
@@ -105,6 +130,25 @@ class BillingService:
) )
return session.url 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 @staticmethod
async def get_billing_state(db: AsyncSession, account): async def get_billing_state(db: AsyncSession, account):
"""Aggregate Subscription + PlanLimits + PlanBilling + resolved feature """Aggregate Subscription + PlanLimits + PlanBilling + resolved feature

View File

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

View File

@@ -1,7 +1,12 @@
"""Integration tests for admin plan limits and account override endpoints.""" """Integration tests for admin plan limits and account override endpoints."""
from unittest.mock import AsyncMock, patch
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy import select
from app.models.plan_billing import PlanBilling
class TestAdminPlanLimits: class TestAdminPlanLimits:
@@ -56,3 +61,204 @@ class TestAdminPlanLimits:
"""Non-admin gets 403.""" """Non-admin gets 403."""
response = await client.get("/api/v1/admin/plan-limits", headers=auth_headers) response = await client.get("/api/v1/admin/plan-limits", headers=auth_headers)
assert response.status_code == 403 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

View File

@@ -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"

View File

@@ -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

View File

@@ -1,6 +1,11 @@
"""Tests for onboarding status endpoints.""" """Tests for onboarding status endpoints."""
from datetime import datetime, timezone
import pytest import pytest
from sqlalchemy import select
from app.models.user import User
@pytest.mark.asyncio @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["connected_psa"] is False
assert data["is_team_user"] is False assert data["is_team_user"] is False
assert data["dismissed"] 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 @pytest.mark.asyncio

View File

@@ -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

View File

@@ -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

View File

@@ -3,3 +3,21 @@ VITE_API_URL=http://localhost:8000
# Sentry error monitoring (optional in dev, required in production) # Sentry error monitoring (optional in dev, required in production)
VITE_SENTRY_DSN= 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

View File

@@ -17,10 +17,20 @@ ARG VITE_API_URL
ARG VITE_SENTRY_DSN ARG VITE_SENTRY_DSN
ARG VITE_PUBLIC_POSTHOG_KEY ARG VITE_PUBLIC_POSTHOG_KEY
ARG VITE_PUBLIC_POSTHOG_HOST 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_API_URL=$VITE_API_URL
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY
ENV VITE_PUBLIC_POSTHOG_HOST=$VITE_PUBLIC_POSTHOG_HOST 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 # Build the application
RUN npm run build RUN npm run build

View File

@@ -1,6 +1,22 @@
import apiClient from './client' import apiClient from './client'
import type { Account, SubscriptionDetails, AccountMember, AccountInvite } from '@/types' 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 = { export const accountsApi = {
async getMyAccount(): Promise<Account> { async getMyAccount(): Promise<Account> {
const response = await apiClient.get<Account>('/accounts/me') const response = await apiClient.get<Account>('/accounts/me')
@@ -39,6 +55,18 @@ export const accountsApi = {
return response.data 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<BulkInviteResponse> {
const response = await apiClient.post<BulkInviteResponse>(
'/accounts/me/invites/bulk',
{ invites },
)
return response.data
},
async getInvites(): Promise<AccountInvite[]> { async getInvites(): Promise<AccountInvite[]> {
const response = await apiClient.get<AccountInvite[]>('/accounts/me/invites') const response = await apiClient.get<AccountInvite[]>('/accounts/me/invites')
return response.data return response.data

View File

@@ -1,6 +1,13 @@
import apiClient from './client' import apiClient from './client'
import type { Token, User, UserCreate, UserLogin, UserUpdate } from '@/types' 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 = { export const authApi = {
async register(data: UserCreate): Promise<User> { async register(data: UserCreate): Promise<User> {
const response = await apiClient.post<User>('/auth/register', data) const response = await apiClient.post<User>('/auth/register', data)
@@ -71,6 +78,36 @@ export const authApi = {
async verifyEmail(token: string): Promise<void> { async verifyEmail(token: string): Promise<void> {
await apiClient.post('/auth/email/verify', { token }) await apiClient.post('/auth/email/verify', { token })
}, },
async googleCallback(
code: string,
options?: { accountInviteCode?: string; invitedEmail?: string },
): Promise<OAuthCallbackResponse> {
const response = await apiClient.post<OAuthCallbackResponse>(
'/auth/google/callback',
{
code,
account_invite_code: options?.accountInviteCode,
invited_email: options?.invitedEmail,
},
)
return response.data
},
async microsoftCallback(
code: string,
options?: { accountInviteCode?: string; invitedEmail?: string },
): Promise<OAuthCallbackResponse> {
const response = await apiClient.post<OAuthCallbackResponse>(
'/auth/microsoft/callback',
{
code,
account_invite_code: options?.accountInviteCode,
invited_email: options?.invitedEmail,
},
)
return response.data
},
} }
export default authApi export default authApi

View File

@@ -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<BillingStatePayload> {
const response = await apiClient.get<BillingStateApiResponse>('/billing/state')
return transformBillingState(response.data)
},
}
export default billingApi

View File

@@ -0,0 +1,15 @@
import apiClient from './client'
export interface PublicConfig {
self_serve_enabled: boolean
oauth_providers: string[]
}
export const configApi = {
async getPublic(): Promise<PublicConfig> {
const response = await apiClient.get<PublicConfig>('/config/public')
return response.data
},
}
export default configApi

View File

@@ -9,6 +9,8 @@ export { default as foldersApi } from './folders'
export { default as stepsApi } from './steps' export { default as stepsApi } from './steps'
export { default as stepCategoriesApi } from './stepCategories' export { default as stepCategoriesApi } from './stepCategories'
export { default as accountsApi } from './accounts' 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 { default as adminApi } from './admin'
export { treeMarkdownApi } from './treeMarkdown' export { treeMarkdownApi } from './treeMarkdown'
export { default as analyticsApi } from './analytics' export { default as analyticsApi } from './analytics'

View File

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

View File

@@ -4,11 +4,15 @@ export interface OnboardingStatus {
created_flow: boolean created_flow: boolean
ran_session: boolean ran_session: boolean
exported_session: boolean exported_session: boolean
/** @deprecated Phase 2 — kept for backward-compat. New UI no longer branches on this. */
tried_ai_assistant: boolean tried_ai_assistant: boolean
invited_teammate: boolean invited_teammate: boolean
connected_psa: boolean connected_psa: boolean
is_team_user: boolean is_team_user: boolean
dismissed: 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<OnboardingStatus> { export async function getOnboardingStatus(): Promise<OnboardingStatus> {
@@ -19,3 +23,51 @@ export async function getOnboardingStatus(): Promise<OnboardingStatus> {
export async function dismissOnboarding(): Promise<void> { export async function dismissOnboarding(): Promise<void> {
await apiClient.post('/users/onboarding-status/dismiss') 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<OnboardingStepResponse> {
const response = await apiClient.patch<OnboardingStepResponse>(
'/users/me/onboarding-step',
payload,
)
return response.data
},
/** Skip the rest of the welcome wizard — sets users.onboarding_dismissed=TRUE. */
async dismissRest(): Promise<OnboardingStepResponse> {
const response = await apiClient.post<OnboardingStepResponse>(
'/users/me/onboarding-dismiss-rest',
)
return response.data
},
}

23
frontend/src/api/usage.ts Normal file
View File

@@ -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

View File

@@ -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 16 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 `<EmailVerificationWall />`
* instead of children.
*
* Behavior:
* - No user (signed out): renders children (let route guards handle auth).
* - User has `email_verified_at`: renders children.
* - Day 16 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 <EmailVerificationWall />
}
return <>{children}</>
}
export default EmailVerificationGate

View File

@@ -0,0 +1,90 @@
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 (
<div
className={cn(
'flex min-h-[60vh] items-center justify-center px-4 py-12',
className,
)}
data-testid="email-verification-wall"
>
<div className="w-full max-w-md rounded-lg border border-default bg-card p-6 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full border border-default bg-elevated text-muted-foreground">
<MailCheck className="h-5 w-5" aria-hidden="true" />
</div>
<h2 className="text-lg font-semibold text-heading">
Verify your email to continue
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{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.'}
</p>
<div className="mt-6 flex flex-col gap-2">
<button
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 && <Loader2 className="h-4 w-4 animate-spin" />}
Resend verification email
</button>
<button
type="button"
onClick={handleLogout}
data-testid="sign-out-button"
className="rounded-md border border-default bg-elevated px-4 py-2 text-sm font-medium text-primary transition-colors hover:bg-white/[0.06]"
>
Sign out
</button>
</div>
</div>
</div>
)
}
export default EmailVerificationWall

View File

@@ -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 `<UpgradePrompt feature={feature} />`.
* 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 <UpgradePrompt feature={feature} />
}
export default FeatureGate

View File

@@ -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<string, FeatureMeta> = {
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 (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-default bg-white/[0.04] px-6 py-10 text-center',
className,
)}
data-testid="upgrade-prompt"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-default bg-elevated text-muted-foreground">
<Lock className="h-4 w-4" aria-hidden="true" />
</div>
<div className="space-y-1">
<h3 className="text-base font-semibold text-heading">
{displayName} is available on {requiredPlan}
</h3>
{description && (
<p className="max-w-md text-sm text-muted-foreground">{description}</p>
)}
</div>
<Link
to="/account/billing/select-plan"
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
Upgrade to {requiredPlan}
</Link>
</div>
)
}
export default UpgradePrompt

View File

@@ -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> = {}): 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(<BrowserRouter>{ui}</BrowserRouter>)
}
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(
<EmailVerificationGate>
<div>protected</div>
</EmailVerificationGate>,
)
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(
<EmailVerificationGate>
<div>protected</div>
</EmailVerificationGate>,
)
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(
<EmailVerificationGate>
<div>protected</div>
</EmailVerificationGate>,
)
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(
<EmailVerificationGate>
<div>protected</div>
</EmailVerificationGate>,
)
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(
<EmailVerificationGate>
<div>protected</div>
</EmailVerificationGate>,
)
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(
<EmailVerificationGate>
<div>protected</div>
</EmailVerificationGate>,
)
expect(screen.queryByText('protected')).not.toBeInTheDocument()
expect(screen.getByTestId('email-verification-wall')).toBeInTheDocument()
})
})

View File

@@ -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(<BrowserRouter>{ui}</BrowserRouter>)
}
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(
<FeatureGate feature="psa_integration">
<div>protected content</div>
</FeatureGate>,
)
expect(screen.queryByText('protected content')).not.toBeInTheDocument()
expect(screen.getByTestId('upgrade-prompt')).toBeInTheDocument()
unmount()
// Enabled — renders children.
useBillingStore.setState({ enabledFeatures: { psa_integration: true } })
renderWithRouter(
<FeatureGate feature="psa_integration">
<div>protected content</div>
</FeatureGate>,
)
expect(screen.getByText('protected content')).toBeInTheDocument()
expect(screen.queryByTestId('upgrade-prompt')).not.toBeInTheDocument()
})
it('renders custom fallback when disabled', () => {
renderWithRouter(
<FeatureGate
feature="psa_integration"
fallback={<div>custom fallback</div>}
>
<div>protected</div>
</FeatureGate>,
)
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(
<FeatureGate feature="psa_integration" fallback={null}>
<div>protected</div>
</FeatureGate>,
)
expect(screen.queryByText('protected')).not.toBeInTheDocument()
expect(container.textContent).toBe('')
})
})

View File

@@ -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(<BrowserRouter>{ui}</BrowserRouter>)
}
describe('UpgradePrompt', () => {
it('renders display name and required plan from catalog', () => {
renderWithRouter(<UpgradePrompt feature="psa_integration" />)
expect(
screen.getByText(/PSA Integration is available on Pro/i),
).toBeInTheDocument()
})
it('CTA navigates to /account/billing/select-plan', () => {
renderWithRouter(<UpgradePrompt feature="psa_integration" />)
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(<UpgradePrompt feature="some_new_feature" />)
expect(
screen.getByText(/Some New Feature is available on Pro/i),
).toBeInTheDocument()
})
})

View File

@@ -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<TrialBannerStage> = [
'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 (
<div
className="card-interactive overflow-hidden p-4 fade-in"
data-testid="next-step-card"
style={{ animationDelay: '150ms' }}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground">
Next step
</p>
<h3 className="mt-1 text-base font-semibold text-foreground">{next.title}</h3>
<p className="mt-1 text-sm text-muted-foreground">{next.description}</p>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss setup prompts"
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors shrink-0"
>
<X size={16} />
</button>
</div>
<div className="mt-3">
<Link
to={next.ctaPath}
data-testid="next-step-cta"
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{next.ctaLabel}
<ArrowRight size={14} />
</Link>
</div>
</div>
)
}
export default NextStepCard

View File

@@ -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<OnboardingStatus | null>(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 (
<div className="card-interactive overflow-hidden fade-in" style={{ animationDelay: '150ms' }}>
{/* Progress bar */}
<div className="h-1 w-full bg-[rgba(255,255,255,0.04)]">
<div
className="h-full bg-primary transition-all duration-500 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="p-4">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Getting Started
</p>
<p className="text-sm text-foreground mt-0.5">
{isAllDone ? (
<span className="text-accent-text font-semibold">You're all set!</span>
) : (
<span>
<span className="text-accent-text font-semibold">{completedCount}</span>
{' '}of {totalCount} complete
</span>
)}
</p>
</div>
<button
onClick={handleDismiss}
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
aria-label="Dismiss onboarding checklist"
>
<X size={16} />
</button>
</div>
{/* Checklist items */}
<ul className="space-y-1">
{items.map((item) => {
const done = status[item.key]
return (
<li key={item.key}>
<button
onClick={() => !done && navigate(item.path)}
disabled={done}
className={cn(
'w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors text-left',
done
? 'cursor-default'
: 'hover:bg-[rgba(255,255,255,0.04)]'
)}
>
{/* Checkbox */}
<span
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors',
done
? 'bg-primary border-transparent'
: 'border-border'
)}
>
{done && <Check size={12} className="text-white" />}
</span>
{/* Label */}
<span
className={cn(
'flex-1',
done
? 'text-muted-foreground line-through'
: 'text-foreground'
)}
>
{item.label}
</span>
{/* Arrow for incomplete items */}
{!done && (
<ChevronRight size={14} className="text-muted-foreground shrink-0" />
)}
</button>
</li>
)
})}
</ul>
</div>
</div>
)
}

View File

@@ -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<TrialBannerStage> = [
'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 (
<div className="card-interactive overflow-hidden" data-testid="setup-checklist">
<div className="px-4 pt-3 pb-2">
<p className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground">
Setup steps · {completedCount} of {totalCount}
</p>
</div>
<ul className="px-2 pb-2 space-y-1">
{items.map((item) => (
<li key={item.key}>
{item.done ? (
<div
className="w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm cursor-default"
data-testid={`checklist-item-${item.key}`}
data-done="true"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-transparent bg-primary">
<Check size={12} className="text-white" />
</span>
<span className="flex-1 text-muted-foreground line-through">
{item.label}
</span>
</div>
) : (
<Link
to={item.path}
className={cn(
'w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors text-left',
'hover:bg-[rgba(255,255,255,0.04)]',
)}
data-testid={`checklist-item-${item.key}`}
data-done="false"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-border" />
<span className="flex-1 text-foreground">{item.label}</span>
<ChevronRight size={14} className="text-muted-foreground shrink-0" />
</Link>
)}
</li>
))}
</ul>
</div>
)
}
export default SetupChecklist

View File

@@ -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<typeof vi.fn>
function makeStatus(overrides: Partial<OnboardingStatus> = {}): 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(<BrowserRouter>{ui}</BrowserRouter>)
}
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(<NextStepCard />)
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(<NextStepCard />)
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(<NextStepCard />)
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(<NextStepCard />)
// 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(<NextStepCard />)
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')
})
})

View File

@@ -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<typeof vi.fn>
function makeStatus(overrides: Partial<OnboardingStatus> = {}): 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(<BrowserRouter>{ui}</BrowserRouter>)
}
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(<SetupChecklist />)
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(<SetupChecklist />)
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(<SetupChecklist />)
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()
})
})
})

View File

@@ -4,15 +4,20 @@ import { Menu, X, LayoutGrid, Clock, AlertTriangle, GitBranch, Wand2, BarChart3,
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions' import { usePermissions } from '@/hooks/usePermissions'
import { useUserPreferencesStore } from '@/store/userPreferencesStore' import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { useBillingPoll } from '@/hooks/useBillingPoll'
import { BrandLogo } from '@/components/common/BrandLogo' import { BrandLogo } from '@/components/common/BrandLogo'
import { TopBar } from './TopBar' import { TopBar } from './TopBar'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
import { EmailVerificationBanner } from './EmailVerificationBanner' import { EmailVerificationBanner } from './EmailVerificationBanner'
import { EmailVerificationGate } from '@/components/common/EmailVerificationGate'
import { ViewTransitionOutlet } from './ViewTransitionOutlet' import { ViewTransitionOutlet } from './ViewTransitionOutlet'
import { FeedbackWidget } from '@/components/common/FeedbackWidget' import { FeedbackWidget } from '@/components/common/FeedbackWidget'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export function AppLayout() { export function AppLayout() {
// Poll /billing/state every 60s while authenticated. Hook no-ops when logged out.
useBillingPoll()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout } = useAuthStore() const { user, logout } = useAuthStore()
@@ -169,7 +174,9 @@ export function AppLayout() {
{/* Main Content */} {/* Main Content */}
<main className="main-content flex flex-col overflow-hidden min-h-0"> <main className="main-content flex flex-col overflow-hidden min-h-0">
<EmailVerificationBanner /> <EmailVerificationBanner />
<ViewTransitionOutlet /> <EmailVerificationGate>
<ViewTransitionOutlet />
</EmailVerificationGate>
</main> </main>
</div> </div>

View File

@@ -5,7 +5,39 @@ import { useAuthStore } from '@/store/authStore'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast' 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 user = useAuthStore((s) => s.user)
const [dismissed, setDismissed] = useState(false) const [dismissed, setDismissed] = useState(false)
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
@@ -19,6 +51,11 @@ export function EmailVerificationBanner() {
if (!user || user.email_verified_at || dismissed || !verificationEnabled) return null if (!user || user.email_verified_at || dismissed || !verificationEnabled) return null
// Past grace period: the wall takes over inside <EmailVerificationGate>.
// 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 () => { const handleResend = async () => {
setIsSending(true) setIsSending(true)
try { try {
@@ -32,22 +69,29 @@ export function EmailVerificationBanner() {
} }
return ( return (
<div className="flex items-center gap-3 border-b border-amber-400/20 bg-amber-400/5 px-4 py-2 text-sm"> <div
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-400" /> data-testid="email-verification-banner"
<span className="text-amber-200"> className="flex items-center gap-3 border-b border-warning/20 bg-warning-dim px-4 py-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<span className="text-foreground">
Your email is not verified. Your email is not verified.
</span> </span>
<button <button
type="button"
onClick={handleResend} onClick={handleResend}
disabled={isSending} disabled={isSending}
data-testid="banner-resend-button"
className={cn( className={cn(
'text-amber-400 underline hover:text-amber-300 disabled:opacity-50' 'text-warning underline hover:opacity-80 disabled:opacity-50',
)} )}
> >
{isSending ? <Loader2 className="inline h-3 w-3 animate-spin" /> : 'Resend verification email'} {isSending ? <Loader2 className="inline h-3 w-3 animate-spin" /> : 'Resend verification email'}
</button> </button>
<button <button
type="button"
onClick={() => setDismissed(true)} onClick={() => setDismissed(true)}
aria-label="Dismiss"
className="ml-auto text-muted-foreground hover:text-foreground" className="ml-auto text-muted-foreground hover:text-foreground"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />

View File

@@ -6,6 +6,7 @@ import { usePermissions } from '@/hooks/usePermissions'
import { BrandLogo } from '@/components/common/BrandLogo' import { BrandLogo } from '@/components/common/BrandLogo'
import { CommandPalette } from './CommandPalette' import { CommandPalette } from './CommandPalette'
import { NotificationsPanel } from './NotificationsPanel' import { NotificationsPanel } from './NotificationsPanel'
import { TrialPill } from './TrialPill'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export function TopBar() { export function TopBar() {
@@ -110,6 +111,9 @@ export function TopBar() {
{/* Spacer - push actions to right */} {/* Spacer - push actions to right */}
<div className="flex-1" /> <div className="flex-1" />
{/* Billing-state pill (trial countdown / paid tier / past_due / etc.) */}
<TrialPill />
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Link <Link

View File

@@ -0,0 +1,147 @@
import { Link } from 'react-router-dom'
import { Clock } from 'lucide-react'
import { useTrialBanner } from '@/hooks/useTrialBanner'
import { useBillingStore } from '@/store/billingStore'
import { cn } from '@/lib/utils'
/**
* Topbar billing-state pill.
*
* Reads `useTrialBanner()` to map subscription state → label + tone.
* Returns `null` when there is nothing to display (e.g. subscription not yet
* loaded). Clickable variants (expired / past_due / canceled) render as
* keyboard-focusable `<Link>`s; static variants render as `<span>`.
*
* 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 = (
<>
<span className="hidden sm:inline">{content.label}</span>
<span className="sm:hidden inline-flex items-center" aria-hidden="true">
<Clock size={14} />
</span>
</>
)
if (content.href) {
return (
<Link
to={content.href}
className={className}
title={content.label}
data-testid="trial-pill"
>
{inner}
</Link>
)
}
return (
<span
className={className}
title={content.label}
data-testid="trial-pill"
>
{inner}
</span>
)
}
function resolveContent(
stage: ReturnType<typeof useTrialBanner>['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

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import { AppLayout } from '../AppLayout'
import { useAuthStore } from '@/store/authStore'
import type { User } from '@/types'
// Mock heavy/external pieces so this stays a focused integration test for the
// gate placement. We don't care that TopBar/Sidebar render real content here —
// only that the EmailVerificationGate is in the tree and gates the outlet.
vi.mock('@/hooks/useBillingPoll', () => ({
useBillingPoll: () => undefined,
}))
vi.mock('@/hooks/usePermissions', () => ({
usePermissions: () => ({ effectiveRole: 'engineer' }),
}))
vi.mock('../TopBar', () => ({
TopBar: () => <div data-testid="top-bar" />,
}))
vi.mock('../Sidebar', () => ({
Sidebar: () => <div data-testid="sidebar" />,
}))
vi.mock('../EmailVerificationBanner', () => ({
EmailVerificationBanner: () => <div data-testid="email-verification-banner-mock" />,
}))
vi.mock('@/components/common/FeedbackWidget', () => ({
FeedbackWidget: () => null,
}))
vi.mock('@/api/auth', () => ({
authApi: {
getVerificationStatus: vi.fn().mockResolvedValue({ enabled: true }),
sendVerificationEmail: vi.fn().mockResolvedValue(undefined),
},
}))
vi.mock('@/lib/toast', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
function makeUser(overrides: Partial<User> = {}): 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 renderAppLayout() {
return render(
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route element={<AppLayout />}>
<Route
index
element={<div data-testid="child-route-content">child route</div>}
/>
</Route>
</Routes>
</MemoryRouter>,
)
}
describe('AppLayout — EmailVerificationGate wiring', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(FROZEN_NOW)
useAuthStore.setState({ user: null, token: null, isAuthenticated: false })
})
afterEach(() => {
vi.useRealTimers()
useAuthStore.setState({ user: null, token: null, isAuthenticated: false })
})
it('renders the wall and hides the child route on day 8 unverified', () => {
// created 8 days before frozen now -> elapsed=8, > grace=6 -> wall.
useAuthStore.setState({
user: makeUser({ created_at: '2026-04-28T00:00:00Z' }),
})
renderAppLayout()
expect(screen.getByTestId('email-verification-wall')).toBeInTheDocument()
expect(screen.queryByTestId('child-route-content')).not.toBeInTheDocument()
})
it('renders the child route within the grace period (day 1 unverified)', () => {
useAuthStore.setState({
user: makeUser({ created_at: '2026-05-05T00:00:00Z' }),
})
renderAppLayout()
expect(screen.getByTestId('child-route-content')).toBeInTheDocument()
expect(
screen.queryByTestId('email-verification-wall'),
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,119 @@
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 { EmailVerificationBanner } from '../EmailVerificationBanner'
import { useAuthStore } from '@/store/authStore'
import { authApi } from '@/api/auth'
import type { User } from '@/types'
vi.mock('@/api/auth', () => ({
authApi: {
getVerificationStatus: vi.fn(),
sendVerificationEmail: vi.fn(),
},
}))
vi.mock('@/lib/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
function makeUser(overrides: Partial<User> = {}): 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')
describe('EmailVerificationBanner', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(FROZEN_NOW)
useAuthStore.setState({ user: null, token: null, isAuthenticated: false })
vi.mocked(authApi.getVerificationStatus).mockResolvedValue({
enabled: true,
})
vi.mocked(authApi.sendVerificationEmail).mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('hides past grace day-7+', async () => {
// Created 8 days before frozen now -> elapsed=8, > grace=6.
useAuthStore.setState({
user: makeUser({ created_at: '2026-04-28T00:00:00Z' }),
})
const { container } = render(<EmailVerificationBanner />)
// Wait long enough for any pending verification-status fetch to resolve.
await waitFor(() => {
expect(authApi.getVerificationStatus).toHaveBeenCalled()
})
expect(
screen.queryByTestId('email-verification-banner'),
).not.toBeInTheDocument()
expect(container.firstChild).toBeNull()
})
it('renders within the grace window', async () => {
// Created 1 day before frozen now -> elapsed=1, within grace.
useAuthStore.setState({
user: makeUser({ created_at: '2026-05-05T00:00:00Z' }),
})
render(<EmailVerificationBanner />)
await waitFor(() => {
expect(
screen.getByTestId('email-verification-banner'),
).toBeInTheDocument()
})
})
it('resend triggers API call', async () => {
useAuthStore.setState({
user: makeUser({ created_at: '2026-05-05T00:00:00Z' }),
})
render(<EmailVerificationBanner />)
await waitFor(() => {
expect(
screen.getByTestId('email-verification-banner'),
).toBeInTheDocument()
})
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await user.click(screen.getByTestId('banner-resend-button'))
await waitFor(() => {
expect(authApi.sendVerificationEmail).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -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(
<MemoryRouter>
<TrialPill />
</MemoryRouter>,
)
}
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')
})
})

View File

@@ -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<PublicConfig> | 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<PublicConfig> {
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<PublicConfig | null>(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

View File

@@ -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

View File

@@ -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)
})
})

View File

@@ -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

View File

@@ -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)
})
})

View File

@@ -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<string, CacheEntry>()
/** 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<string, unknown>; 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<CacheEntry | undefined>(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<number>(initialFresh ? initialCached.current!.used : 0)
const [isLoading, setIsLoading] = useState<boolean>(!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<string>(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

View File

@@ -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<OnboardingStatus | null>(null)
useEffect(() => {
getOnboardingStatus()
.then(setStatus)
.catch(() => {
// Silently fail — never block the dashboard if the endpoint is down.
})
}, [])
return status
}
export default useOnboardingStatus

View File

@@ -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>): 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<SubscriptionState>) {
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 (13 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)
})
})
})

View File

@@ -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
// 13 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

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { encodeOAuthState, decodeOAuthState } from './oauthState'
describe('oauthState', () => {
it('round-trips ASCII payloads', () => {
const encoded = encodeOAuthState({
csrf: 'abc123',
accountInviteCode: 'CODE12345',
invitedEmail: 'user@example.com',
})
expect(encoded).not.toContain('+')
expect(encoded).not.toContain('/')
expect(encoded).not.toContain('=')
expect(decodeOAuthState(encoded)).toEqual({
csrf: 'abc123',
accountInviteCode: 'CODE12345',
invitedEmail: 'user@example.com',
})
})
it('round-trips non-Latin-1 email characters without throwing', () => {
// Pre-fix: btoa(json) throws DOMException on code points > 255.
const payload = {
csrf: 'abc123',
accountInviteCode: 'CODE12345',
invitedEmail: 'user@münchen.de',
}
const encoded = encodeOAuthState(payload)
expect(decodeOAuthState(encoded)).toEqual(payload)
})
it('round-trips emoji and CJK characters', () => {
const payload = {
csrf: 'abc123',
accountInviteCode: 'CODE12345',
invitedEmail: '日本語+🎉@例え.jp',
}
expect(decodeOAuthState(encodeOAuthState(payload))).toEqual(payload)
})
it('returns null for legacy raw-hex CSRF state (not JSON)', () => {
expect(decodeOAuthState('a1b2c3d4e5f60718293a4b5c6d7e8f90')).toBeNull()
})
it('returns null for null / empty input', () => {
expect(decodeOAuthState(null)).toBeNull()
expect(decodeOAuthState('')).toBeNull()
})
it('returns null for malformed base64', () => {
expect(decodeOAuthState('!!!not-base64!!!')).toBeNull()
})
})

View File

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

View File

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

View File

@@ -0,0 +1,195 @@
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'
import { decodeOAuthState } from '@/lib/oauthState'
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.
*
* 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()
const location = useLocation()
const { setTokens, fetchUser } = useAuthStore()
const [error, setError] = useState<string | null>(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 /
// 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')
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) {
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
}
if (!code) {
setError('Missing authorization code')
return
}
let cancelled = false
void (async () => {
try {
const inviteOptions = decoded
? {
accountInviteCode: decoded.accountInviteCode,
invitedEmail: decoded.invitedEmail,
}
: undefined
const result =
provider === 'microsoft'
? await authApi.microsoftCallback(code, inviteOptions)
: await authApi.googleCallback(code, inviteOptions)
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
// 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
const axiosErr = err as {
response?: { data?: { detail?: unknown } }
}
const detail = axiosErr.response?.data?.detail
// Backend returns { error: "invite_email_mismatch" } etc.
let msg: string | null = null
if (typeof detail === 'string') {
msg = detail
} else if (
detail &&
typeof detail === 'object' &&
'error' in (detail as Record<string, unknown>)
) {
const code = (detail as { error: string }).error
if (code === 'invite_email_mismatch') {
msg =
'The email on your provider account does not match the invited email. ' +
'Sign in with the matching account, or ask your inviter to resend.'
} else if (code === 'invite_invalid_or_expired_or_revoked') {
msg = 'This invite is no longer valid. Ask your inviter to resend.'
} else {
msg = code
}
}
msg =
msg ||
(err instanceof Error ? err.message : 'Sign-in failed')
setError(msg)
}
})()
return () => {
cancelled = true
}
}, [location.search, provider, setTokens, fetchUser, navigate])
return (
<>
<PageMeta title="Signing you in" description="Completing OAuth sign-in" />
<div className="flex min-h-screen items-center justify-center bg-black px-4">
<div className="relative w-full max-w-md space-y-6 text-center">
<div className="flex justify-center">
<BrandLogo size="lg" />
</div>
{error ? (
<>
<h1 className="text-xl font-semibold text-foreground">
Sign-in failed
</h1>
<p className="text-sm text-red-400">{error}</p>
<button
onClick={() => navigate('/login', { replace: true })}
className="rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-white hover:brightness-110"
>
Back to sign in
</button>
</>
) : (
<>
<h1 className="text-xl font-semibold text-foreground">
Signing you in
</h1>
<p className="text-sm text-muted-foreground">
Finishing up the {provider === 'microsoft' ? 'Microsoft' : 'Google'} sign-in flow.
</p>
</>
)}
</div>
</div>
</>
)
}
export default OAuthCallbackPage

View File

@@ -1,3 +1,4 @@
import { useState } from 'react'
import { PageMeta } from '@/components/common/PageMeta' import { PageMeta } from '@/components/common/PageMeta'
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
import { StartSessionInput } from '@/components/dashboard/StartSessionInput' import { StartSessionInput } from '@/components/dashboard/StartSessionInput'
@@ -7,6 +8,10 @@ import { TicketQueue } from '@/components/dashboard/TicketQueue'
import { PerformanceCards } from '@/components/dashboard/PerformanceCards' import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards' import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
import { TeamSummary } from '@/components/dashboard/TeamSummary' 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 }) { function SectionLabel({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
return ( return (
@@ -22,6 +27,17 @@ function SectionLabel({ children, action }: { children: React.ReactNode; action?
export function QuickStartPage() { export function QuickStartPage() {
const user = useAuthStore((s) => s.user) 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 now = new Date()
const greeting = now.getHours() < 12 const greeting = now.getHours() < 12
@@ -47,6 +63,29 @@ export function QuickStartPage() {
</h1> </h1>
</div> </div>
{/* Next-step card — surfaces a single onboarding nudge below the hero. */}
{onboardingVisible && (
<div className="mb-6">
<NextStepCard />
<div className="mt-2">
<button
type="button"
onClick={() => setShowAllSetupSteps((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline"
data-testid="toggle-setup-checklist"
aria-expanded={showAllSetupSteps}
>
{showAllSetupSteps ? 'Hide setup steps' : 'Show all setup steps'}
</button>
</div>
{showAllSetupSteps && (
<div className="mt-3">
<SetupChecklist />
</div>
)}
</div>
)}
{/* Chat-style input */} {/* Chat-style input */}
<StartSessionInput /> <StartSessionInput />

View File

@@ -1,18 +1,77 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
import { inviteApi } from '@/api/invite' import { inviteApi } from '@/api/invite'
import { useAppConfig } from '@/hooks/useAppConfig'
import { BrandLogo } from '@/components/common/BrandLogo' import { BrandLogo } from '@/components/common/BrandLogo'
import { PasswordInput } from '@/components/common/PasswordInput' import { PasswordInput } from '@/components/common/PasswordInput'
import { PageMeta } from '@/components/common/PageMeta' import { PageMeta } from '@/components/common/PageMeta'
import { cn } from '@/lib/utils' 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() { export function RegisterPage() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const { register, isLoading, error, clearError } = useAuthStore() const { register, isLoading, error, clearError } = useAuthStore()
const appConfig = useAppConfig()
const [inviteCode, setInviteCode] = useState('') 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 [inviteCodeMessage, setInviteCodeMessage] = useState('')
const [name, setName] = useState('') const [name, setName] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@@ -20,6 +79,32 @@ export function RegisterPage() {
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('')
const [localError, setLocalError] = 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) => { const validateInviteCode = async (code: string) => {
if (!code.trim()) { if (!code.trim()) {
setInviteCodeStatus('idle') setInviteCodeStatus('idle')
@@ -43,8 +128,8 @@ export function RegisterPage() {
setLocalError('') setLocalError('')
clearError() clearError()
// Only validate invite code if one was entered // Only validate invite code when the field is shown (legacy invite flow).
if (inviteCode.trim() && inviteCodeStatus === 'invalid') { if (showInviteCode && inviteCode.trim() && inviteCodeStatus === 'invalid') {
setLocalError('Please enter a valid invite code') setLocalError('Please enter a valid invite code')
return return
} }
@@ -65,12 +150,15 @@ export function RegisterPage() {
} }
try { try {
// Only include invite_code if provided const userData =
const userData = inviteCode.trim() showInviteCode && inviteCode.trim()
? { email, password, name, invite_code: inviteCode.trim() } ? { email, password, name, invite_code: inviteCode.trim() }
: { email, password, name } : { email, password, name }
await register(userData) 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 { } catch {
// Error is set in the store // Error is set in the store
} }
@@ -78,28 +166,30 @@ export function RegisterPage() {
return ( return (
<> <>
<PageMeta title="Create Account" description="Create your ResolutionFlow account to start building guided troubleshooting flows" /> <PageMeta
<div className="flex min-h-screen items-center justify-center bg-black px-4"> title="Create Account"
{/* Subtle radial overlay */} description="Create your ResolutionFlow account to start building guided troubleshooting flows"
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" /> />
<div className="flex min-h-screen items-center justify-center bg-black px-4">
{/* Subtle radial overlay */}
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
<div className="relative w-full max-w-md space-y-8"> <div className="relative w-full max-w-md space-y-8">
<div className="text-center"> <div className="text-center">
<div className="mb-4 flex justify-center sm:mb-6"> <div className="mb-4 flex justify-center sm:mb-6">
<BrandLogo size="lg" /> <BrandLogo size="lg" />
</div>
<h1 className="text-3xl font-bold font-heading text-foreground tracking-tight">
ResolutionFlow
</h1>
<p className="mt-2 text-base font-medium text-muted-foreground sm:mt-3 sm:text-lg">
AI-Powered Troubleshooting for MSPs
</p>
<p className="mt-1 text-sm text-muted-foreground sm:mt-2">
Create your account
</p>
</div> </div>
<h1 className="text-3xl font-bold font-heading text-foreground tracking-tight">
ResolutionFlow
</h1>
<p className="mt-2 text-base font-medium text-muted-foreground sm:mt-3 sm:text-lg">
AI-Powered Troubleshooting for MSPs
</p>
<p className="mt-1 text-sm text-muted-foreground sm:mt-2">
Create your account
</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="bg-card border border-border rounded-xl p-6 space-y-4"> <div className="bg-card border border-border rounded-xl p-6 space-y-4">
{(error || localError) && ( {(error || localError) && (
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400"> <div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
@@ -107,140 +197,217 @@ export function RegisterPage() {
</div> </div>
)} )}
<div> {showOAuthButtons && (googleAvailable || microsoftAvailable) && (
<label htmlFor="inviteCode" className="block text-sm font-medium text-foreground"> <div className="space-y-3">
Invite code {googleAvailable && (
</label> <button
<input type="button"
id="inviteCode" onClick={() => handleOAuth('google')}
name="inviteCode" data-testid="oauth-google"
type="text" className={cn(
value={inviteCode} 'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
onChange={(e) => { // TODO(brand): swap to white-on-black with Google "G" mark
setInviteCode(e.target.value.toUpperCase()) // when brand assets are imported. Neutral fallback for now.
setInviteCodeStatus('idle') 'bg-card border border-border text-foreground hover:bg-foreground/5',
}} 'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
onBlur={(e) => validateInviteCode(e.target.value)} 'transition-all',
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', Continue with Google
'focus:outline-hidden focus:ring-1', </button>
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" {microsoftAvailable && (
/> <button
{inviteCodeStatus === 'checking' && ( type="button"
<p className="mt-1 text-xs text-muted-foreground">Validating...</p> onClick={() => handleOAuth('microsoft')}
data-testid="oauth-microsoft"
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-card border border-border text-foreground hover:bg-foreground/5',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'transition-all',
)}
>
Continue with Microsoft
</button>
)}
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase tracking-wider">
<span className="bg-card px-2 text-muted-foreground">
or sign up with email
</span>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{showInviteCode && (
<div>
<label
htmlFor="inviteCode"
className="block text-sm font-medium text-foreground"
>
Invite code
</label>
<input
id="inviteCode"
name="inviteCode"
type="text"
value={inviteCode}
onChange={(e) => {
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' && (
<p className="mt-1 text-xs text-muted-foreground">
Validating...
</p>
)}
{inviteCodeStatus === 'valid' && (
<p className="mt-1 text-xs text-emerald-400">
{inviteCodeMessage}
</p>
)}
{inviteCodeStatus === 'invalid' && (
<p className="mt-1 text-xs text-red-400">
{inviteCodeMessage}
</p>
)}
</div>
)} )}
{inviteCodeStatus === 'valid' && (
<p className="mt-1 text-xs text-emerald-400">{inviteCodeMessage}</p>
)}
{inviteCodeStatus === 'invalid' && (
<p className="mt-1 text-xs text-red-400">{inviteCodeMessage}</p>
)}
</div>
<div> <div>
<label htmlFor="name" className="block text-sm font-medium text-foreground"> <label
Full name htmlFor="name"
</label> className="block text-sm font-medium text-foreground"
<input >
id="name" Full name
name="name" </label>
type="text" <input
autoComplete="name" id="name"
required name="name"
value={name} type="text"
onChange={(e) => setName(e.target.value)} autoComplete="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
className={cn(
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
)}
placeholder="John Smith"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-foreground"
>
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-foreground"
>
Password
</label>
<PasswordInput
id="password"
name="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className={cn(
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
)}
placeholder="••••••••••"
/>
<p className="mt-1 text-xs text-muted-foreground">
Must be at least 10 characters
</p>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-foreground"
>
Confirm password
</label>
<PasswordInput
id="confirmPassword"
name="confirmPassword"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={cn(
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
)}
placeholder="••••••••••"
/>
</div>
<button
type="submit"
disabled={isLoading}
className={cn( className={cn(
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2', 'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'text-foreground placeholder:text-muted-foreground', 'bg-primary text-white hover:brightness-110',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20' 'focus:outline-hidden focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-black',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-all',
)} )}
placeholder="John Smith" >
/> {isLoading ? 'Creating account...' : 'Create account'}
</div> </button>
</form>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<PasswordInput
id="password"
name="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className={cn(
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
placeholder="••••••••••"
/>
<p className="mt-1 text-xs text-muted-foreground">
Must be at least 10 characters
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground">
Confirm password
</label>
<PasswordInput
id="confirmPassword"
name="confirmPassword"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={cn(
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
placeholder="••••••••••"
/>
</div>
<button
type="submit"
disabled={isLoading}
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-primary text-white hover:brightness-110',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-black',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-all'
)}
>
{isLoading ? 'Creating account...' : 'Create account'}
</button>
</div> </div>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
@@ -249,9 +416,8 @@ export function RegisterPage() {
Sign in Sign in
</Link> </Link>
</p> </p>
</form> </div>
</div> </div>
</div>
</> </>
) )
} }

View File

@@ -1,73 +1,221 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useSearchParams, Link } from 'react-router-dom' import { useNavigate, useSearchParams, Link } from 'react-router-dom'
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react' import { CheckCircle2, XCircle, Loader2, MailCheck } from 'lucide-react'
import { authApi } from '@/api/auth' import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import { PageMeta } from '@/components/common/PageMeta' import { PageMeta } from '@/components/common/PageMeta'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type Status = 'loading' | 'success' | 'error' | 'already-verified' | 'no-token'
const SUCCESS_REDIRECT_MS = 1200
/**
* Standalone landing page for the email-verification link
* (`/verify-email?token=...`).
*
* Behavior:
* - If the user is already verified, short-circuit to a friendly
* "Already verified" state. No API call.
* - Else fire `POST /auth/email/verify` exactly once (a `useRef` guard keeps
* React 19 strict-mode double-invoke from double-firing the call). On
* success, refresh the auth store and bounce to `/?verified=1` so the
* dashboard surfaces a toast.
* - On error, show "Invalid or expired token" + a "Resend" CTA that calls
* `POST /auth/email/send-verification`.
*/
export function VerifyEmailPage() { export function VerifyEmailPage() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const navigate = useNavigate()
const token = searchParams.get('token') const token = searchParams.get('token')
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(token ? 'loading' : 'error')
const [errorMessage, setErrorMessage] = useState(token ? '' : 'No verification token provided') const alreadyVerified = useAuthStore(
(s) => Boolean(s.user?.email_verified_at),
)
const initialStatus: Status = alreadyVerified
? 'already-verified'
: token
? 'loading'
: 'no-token'
const [status, setStatus] = useState<Status>(initialStatus)
const [errorMessage, setErrorMessage] = useState<string>('')
const [isResending, setIsResending] = useState(false)
// Single-fire guard: React 19 strict mode runs effects twice on mount.
// Without this, the verify endpoint would burn the token on the first call
// and then 400 on the second, flashing an error past the success state.
const hasFiredRef = useRef(false)
useEffect(() => { useEffect(() => {
if (status !== 'loading') return
if (!token) return if (!token) return
if (hasFiredRef.current) return
hasFiredRef.current = true
authApi.verifyEmail(token) let cancelled = false
.then(() => setStatus('success'))
.catch((err) => { authApi
setStatus('error') .verifyEmail(token)
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail .then(async () => {
setErrorMessage(detail ?? 'Verification failed') // Refresh user so `email_verified_at` is populated everywhere.
try {
await useAuthStore.getState().fetchUser()
} catch {
// Non-fatal: server confirmed verification, the local user object
// will refresh on next page load.
}
if (cancelled) return
setStatus('success')
toast.success('Email verified')
// Brief success state, then redirect with a query flag so the
// dashboard can re-surface confirmation if it wants to.
window.setTimeout(() => {
navigate('/?verified=1', { replace: true })
}, SUCCESS_REDIRECT_MS)
}) })
}, [token]) .catch((err) => {
if (cancelled) return
const detail = (err as { response?: { data?: { detail?: string } } })
.response?.data?.detail
setErrorMessage(detail ?? 'Invalid or expired verification link')
setStatus('error')
})
return () => {
cancelled = true
}
}, [status, token, navigate])
const handleResend = async () => {
setIsResending(true)
try {
await authApi.sendVerificationEmail()
toast.success('Verification email sent — check your inbox')
} catch {
toast.error('Failed to send verification email')
} finally {
setIsResending(false)
}
}
return ( return (
<> <>
<PageMeta title="Verify Email" description="Verify your ResolutionFlow email address" /> <PageMeta
<div className="flex min-h-screen items-center justify-center bg-background p-4"> title="Verify Email"
<div className="card-flat w-full max-w-md p-8 text-center"> description="Verify your ResolutionFlow email address"
{status === 'loading' && ( />
<> <div className="flex min-h-screen items-center justify-center bg-background p-4">
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" /> <div className="card-flat w-full max-w-md p-8 text-center">
<p className="mt-4 text-foreground">Verifying your email...</p> {status === 'loading' && (
</> <>
)} <Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
{status === 'success' && ( <p className="mt-4 text-foreground">Verifying your email</p>
<> </>
<CheckCircle2 className="mx-auto h-12 w-12 text-emerald-400" /> )}
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Email Verified</h1>
<p className="mt-2 text-muted-foreground">Your email has been successfully verified.</p> {status === 'success' && (
<Link <>
to="/" <CheckCircle2 className="mx-auto h-12 w-12 text-success" />
className={cn( <h1 className="mt-4 text-xl font-bold font-heading text-foreground">
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-white', Email verified
'hover:brightness-110' </h1>
)} <p className="mt-2 text-muted-foreground">
> Redirecting you to the dashboard
Go to Dashboard </p>
</Link> <Link
</> to="/?verified=1"
)} replace
{status === 'error' && ( className={cn(
<> 'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
<XCircle className="mx-auto h-12 w-12 text-rose-500" /> 'hover:brightness-110',
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Verification Failed</h1> )}
<p className="mt-2 text-muted-foreground">{errorMessage}</p> >
<Link Go to dashboard
to="/" </Link>
className={cn( </>
'mt-6 inline-flex items-center rounded-lg bg-input border border-border px-6 py-2 text-sm font-medium text-foreground', )}
'hover:border-border-hover'
)} {status === 'already-verified' && (
> <>
Go to Dashboard <MailCheck className="mx-auto h-12 w-12 text-success" />
</Link> <h1 className="mt-4 text-xl font-bold font-heading text-foreground">
</> You&apos;re already verified
)} </h1>
<p className="mt-2 text-muted-foreground">
This account&apos;s email is already confirmed. No further
action needed.
</p>
<Link
to="/"
className={cn(
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
'hover:brightness-110',
)}
>
Go to dashboard
</Link>
</>
)}
{status === 'error' && (
<>
<XCircle className="mx-auto h-12 w-12 text-danger" />
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
Verification failed
</h1>
<p className="mt-2 text-muted-foreground">
{errorMessage || 'Invalid or expired verification link'}
</p>
<div className="mt-6 flex flex-col gap-2">
<button
type="button"
onClick={handleResend}
disabled={isResending}
data-testid="resend-button"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground hover:brightness-110 disabled:opacity-50"
>
{isResending && <Loader2 className="h-4 w-4 animate-spin" />}
Resend verification email
</button>
<Link
to="/"
className={cn(
'inline-flex items-center justify-center rounded-lg border border-default bg-input px-6 py-2 text-sm font-medium text-foreground',
'hover:border-border-hover',
)}
>
Go to dashboard
</Link>
</div>
</>
)}
{status === 'no-token' && (
<>
<XCircle className="mx-auto h-12 w-12 text-danger" />
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
Missing verification token
</h1>
<p className="mt-2 text-muted-foreground">
The link you used doesn&apos;t include a verification token.
Try the link in your verification email again.
</p>
<Link
to="/"
className={cn(
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
'hover:brightness-110',
)}
>
Go to dashboard
</Link>
</>
)}
</div>
</div> </div>
</div>
</> </>
) )
} }

View File

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

View File

@@ -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(
<HelmetProvider>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route
path="/auth/google/callback"
element={<OAuthCallbackPage />}
/>
<Route
path="/auth/microsoft/callback"
element={<OAuthCallbackPage />}
/>
</Routes>
</MemoryRouter>
</HelmetProvider>,
)
}
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()
})
})

View File

@@ -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: () => <div data-testid="mock-start-session" />,
}))
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<typeof vi.fn>
function makeStatus(overrides: Partial<OnboardingStatus> = {}): 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(
<BrowserRouter>
<QuickStartPage />
</BrowserRouter>,
)
// 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)
})
})

View File

@@ -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(
<HelmetProvider>
<MemoryRouter initialEntries={[initialPath]}>
<RegisterPage />
</MemoryRouter>
</HelmetProvider>,
)
}
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')
})
})

View File

@@ -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> = {}): 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(
<HelmetProvider>
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>
</HelmetProvider>,
)
}
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(
<HelmetProvider>
<MemoryRouter initialEntries={['/verify-email?token=valid-token']}>
<Routes>
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>
</HelmetProvider>,
)
// Force a re-render to simulate React 19 strict-mode double-invoke.
rerender(
<HelmetProvider>
<MemoryRouter initialEntries={['/verify-email?token=valid-token']}>
<Routes>
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>
</HelmetProvider>,
)
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()
})
})

View File

@@ -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 <PageLoader />
if (user.onboarding_dismissed) return <Navigate to="/" replace />
const completed = user.onboarding_step_completed ?? 0
if (completed >= 3) return <Navigate to="/" replace />
if (completed === 2) return <Navigate to="/welcome/step-3" replace />
if (completed === 1) return <Navigate to="/welcome/step-2" replace />
return <Navigate to="/welcome/step-1" replace />
}
export default WelcomeRouter

View File

@@ -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: '12' },
{ value: '3-5', label: '35' },
{ value: '6-10', label: '610' },
{ value: '11-25', label: '1125' },
{ 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<string>(account?.name ?? '')
const [teamSize, setTeamSize] = useState<TeamSizeBucket | ''>('')
const [role, setRole] = useState<RoleAtSignup | ''>('')
const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="mx-auto w-full max-w-2xl px-4 py-10">
<header className="mb-8">
<p className="text-xs uppercase tracking-wider text-muted-foreground">
Step 1 of 3
</p>
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
Your shop
</h1>
<p className="mt-2 text-sm text-muted-foreground">
A couple of quick questions so we can tailor ResolutionFlow to your team.
</p>
</header>
<form
onSubmit={handleContinue}
className="rounded-2xl border border-border bg-card p-6 space-y-5"
data-testid="welcome-step-1-form"
>
<div>
<label
htmlFor="company_name"
className="block text-sm font-medium text-foreground"
>
Company name
</label>
<input
id="company_name"
name="company_name"
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className={inputClass}
placeholder="Acme MSP"
data-testid="welcome-step-1-company-name"
/>
</div>
<div>
<label
htmlFor="team_size"
className="block text-sm font-medium text-foreground"
>
Team size
</label>
<select
id="team_size"
name="team_size"
value={teamSize}
onChange={(e) => setTeamSize(e.target.value as TeamSizeBucket | '')}
className={inputClass}
data-testid="welcome-step-1-team-size"
>
<option value="">Select team size</option>
{TEAM_SIZE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="role"
className="block text-sm font-medium text-foreground"
>
Your role
</label>
<select
id="role"
name="role"
value={role}
onChange={(e) => setRole(e.target.value as RoleAtSignup | '')}
className={inputClass}
data-testid="welcome-step-1-role"
>
<option value="">Select your role</option>
{ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{error && (
<p className="text-xs text-red-400" data-testid="welcome-step-1-error">
{error}
</p>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={isBusy}
data-testid="welcome-step-1-continue"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-primary text-primary-foreground hover:bg-primary/90',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'disabled:opacity-60',
)}
>
{submitting === 'continue' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Continue
</button>
<button
type="button"
onClick={handleSkipStep}
disabled={isBusy}
data-testid="welcome-step-1-skip"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium btn-press',
'bg-card border border-border text-foreground hover:bg-foreground/5',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
'disabled:opacity-60',
)}
>
{submitting === 'skip' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Skip this step
</button>
</div>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={handleDismissRest}
disabled={isBusy}
data-testid="welcome-step-1-dismiss-rest"
className={cn(
'text-xs text-muted-foreground hover:underline',
'disabled:opacity-60',
)}
>
{submitting === 'dismiss' ? 'Saving…' : 'Skip the rest'}
</button>
</div>
</div>
)
}
export default WelcomeStep1

View File

@@ -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<PrimaryPsa | null>(null)
const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="mx-auto w-full max-w-2xl px-4 py-10">
<header className="mb-8">
<p className="text-xs uppercase tracking-wider text-muted-foreground">
Step 2 of 3
</p>
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
Your PSA
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Pick the PSA your team uses today. We'll wire it up later — no
credentials needed yet.
</p>
</header>
<div
className="rounded-2xl border border-border bg-card p-6 space-y-5"
data-testid="welcome-step-2-form"
>
<div
role="radiogroup"
aria-label="Primary PSA"
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
>
{PSA_OPTIONS.map((opt) => {
const selected = primaryPsa === opt.value
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={selected}
onClick={() => setPrimaryPsa(opt.value)}
disabled={isBusy}
data-testid={`welcome-step-2-tile-${opt.value}`}
className={cn(
'rounded-xl border px-4 py-3 text-left transition-colors btn-press',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'disabled:opacity-60',
selected
? 'border-primary bg-primary/5'
: 'border-border bg-card hover:border-primary/40 hover:bg-foreground/5',
)}
>
<div className="text-sm font-semibold text-foreground">
{opt.label}
</div>
<div className="text-xs text-muted-foreground">
{opt.description}
</div>
</button>
)
})}
</div>
{showConnectNow && (
<div className="pt-1">
<Link
to="/account/integrations"
data-testid="welcome-step-2-connect-now"
className="text-xs text-muted-foreground hover:underline"
>
Connect now &rarr;
</Link>
</div>
)}
{error && (
<p className="text-xs text-red-400" data-testid="welcome-step-2-error">
{error}
</p>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleContinue}
disabled={isBusy}
data-testid="welcome-step-2-continue"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-primary text-primary-foreground hover:bg-primary/90',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'disabled:opacity-60',
)}
>
{submitting === 'continue' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Continue
</button>
<button
type="button"
onClick={handleSkipStep}
disabled={isBusy}
data-testid="welcome-step-2-skip"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium btn-press',
'bg-card border border-border text-foreground hover:bg-foreground/5',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
'disabled:opacity-60',
)}
>
{submitting === 'skip' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Skip this step
</button>
</div>
</div>
<div className="mt-6 text-center">
<button
type="button"
onClick={handleDismissRest}
disabled={isBusy}
data-testid="welcome-step-2-dismiss-rest"
className={cn(
'text-xs text-muted-foreground hover:underline',
'disabled:opacity-60',
)}
>
{submitting === 'dismiss' ? 'Saving' : 'Skip the rest'}
</button>
</div>
</div>
)
}
export default WelcomeStep2

View File

@@ -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<InviteRow[]>(() =>
Array.from({ length: DEFAULT_ROW_COUNT }, makeEmptyRow),
)
const [submitting, setSubmitting] = useState<
'send' | 'skip' | 'dismiss' | 'continue-anyway' | null
>(null)
const [error, setError] = useState<string | null>(null)
const [hasUnresolvedFailures, setHasUnresolvedFailures] = useState(false)
const isBusy = submitting !== null
const updateRow = (idx: number, patch: Partial<InviteRow>) => {
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<number, string> = {}
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<string, string>()
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 (
<div className="mx-auto w-full max-w-2xl px-4 py-10">
<header className="mb-8">
<p className="text-xs uppercase tracking-wider text-muted-foreground">
Step 3 of 3
</p>
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
Invite your team
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Add up to {MAX_ROWS} teammates. They'll get an email with a link to
join. Leave blank to do this later.
</p>
</header>
<div
className="rounded-2xl border border-border bg-card p-6 space-y-4"
data-testid="welcome-step-3-form"
>
<div className="space-y-3">
{rows.map((row, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-start gap-2">
<input
type="email"
value={row.email}
onChange={(e) => 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}
/>
<select
value={row.role}
onChange={(e) =>
updateRow(idx, { role: e.target.value as RowRole })
}
className={cn(inputClass, 'w-32 flex-shrink-0')}
data-testid={`welcome-step-3-role-${idx}`}
disabled={isBusy}
>
{ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
type="button"
onClick={() => removeRow(idx)}
disabled={isBusy || rows.length <= 1}
data-testid={`welcome-step-3-remove-${idx}`}
aria-label="Remove row"
className={cn(
'inline-flex h-10 w-10 items-center justify-center rounded-xl',
'text-muted-foreground hover:bg-foreground/5 hover:text-foreground',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
'disabled:opacity-30 disabled:hover:bg-transparent',
)}
>
<X className="h-4 w-4" />
</button>
</div>
{row.error && (
<p
className="pl-1 text-xs text-red-400"
data-testid={`welcome-step-3-row-error-${idx}`}
>
{row.error}
</p>
)}
</div>
))}
</div>
<button
type="button"
onClick={addRow}
disabled={isBusy || rows.length >= MAX_ROWS}
data-testid="welcome-step-3-add-row"
className={cn(
'inline-flex items-center gap-1.5 rounded-xl px-2 py-1 text-xs font-medium',
'text-muted-foreground hover:bg-foreground/5 hover:text-foreground',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
'disabled:opacity-50',
)}
>
<Plus className="h-3.5 w-3.5" />
Add another
</button>
{error && (
<p className="text-xs text-red-400" data-testid="welcome-step-3-error">
{error}
</p>
)}
<div className="flex flex-wrap items-center gap-3 pt-2">
<button
type="button"
onClick={handleSendInvites}
disabled={isBusy}
data-testid="welcome-step-3-send"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-primary text-primary-foreground hover:bg-primary/90',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'disabled:opacity-60',
)}
>
{submitting === 'send' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Send invites and continue
</button>
{hasUnresolvedFailures && (
<button
type="button"
onClick={handleContinueAnyway}
disabled={isBusy}
data-testid="welcome-step-3-continue-anyway"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium btn-press',
'bg-card border border-border text-foreground hover:bg-foreground/5',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
'disabled:opacity-60',
)}
>
{submitting === 'continue-anyway' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Continue anyway
</button>
)}
<button
type="button"
onClick={handleSkipStep}
disabled={isBusy}
data-testid="welcome-step-3-skip"
className={cn(
'text-sm text-muted-foreground hover:underline',
'disabled:opacity-60',
)}
>
{submitting === 'skip' ? 'Saving' : 'Skip'}
</button>
</div>
</div>
<div className="mt-6 text-center">
<button
type="button"
onClick={handleDismissRest}
disabled={isBusy}
data-testid="welcome-step-3-dismiss-rest"
className={cn(
'text-xs text-muted-foreground hover:underline',
'disabled:opacity-60',
)}
>
{submitting === 'dismiss' ? 'Saving' : 'Skip the rest'}
</button>
</div>
</div>
)
}
export default WelcomeStep3

View File

@@ -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> = {}): 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(
<MemoryRouter initialEntries={['/welcome']}>
<Routes>
<Route path="/welcome" element={<WelcomeRouter />} />
<Route path="/welcome/step-1" element={<div>step-1</div>} />
<Route path="/welcome/step-2" element={<div>step-2</div>} />
<Route path="/welcome/step-3" element={<div>step-3</div>} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)
}
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()
})
})
})

View File

@@ -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<typeof import('@/api/onboarding')>(
'@/api/onboarding',
)
return {
...actual,
onboardingApi: {
...actual.onboardingApi,
updateStep: vi.fn(),
dismissRest: vi.fn(),
},
}
})
function makeUser(overrides: Partial<User> = {}): 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> = {}): 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(
<MemoryRouter initialEntries={['/welcome/step-1']}>
<Routes>
<Route path="/welcome/step-1" element={<WelcomeStep1 />} />
<Route path="/welcome/step-2" element={<div>step-2</div>} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)
}
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()
})
})

View File

@@ -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<typeof import('@/api/onboarding')>(
'@/api/onboarding',
)
return {
...actual,
onboardingApi: {
...actual.onboardingApi,
updateStep: vi.fn(),
dismissRest: vi.fn(),
},
}
})
function makeUser(overrides: Partial<User> = {}): 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> = {}): 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(
<MemoryRouter initialEntries={['/welcome/step-2']}>
<Routes>
<Route path="/welcome/step-2" element={<WelcomeStep2 />} />
<Route path="/welcome/step-3" element={<div>step-3</div>} />
<Route path="/account/integrations" element={<div>integrations</div>} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)
}
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()
})
})
})

View File

@@ -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<typeof import('@/api/onboarding')>(
'@/api/onboarding',
)
return {
...actual,
onboardingApi: {
...actual.onboardingApi,
updateStep: vi.fn(),
dismissRest: vi.fn(),
},
}
})
vi.mock('@/api/accounts', async () => {
const actual = await vi.importActual<typeof import('@/api/accounts')>(
'@/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> = {}): 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> = {}): 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(
<MemoryRouter initialEntries={['/welcome/step-3']}>
<Routes>
<Route path="/welcome/step-3" element={<WelcomeStep3 />} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)
}
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()
})
})

View File

@@ -25,6 +25,8 @@ const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage'))
// Standalone auth pages // Standalone auth pages
const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage')) 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 ChangePasswordPage = lazyWithRetry(() => import('@/pages/ChangePasswordPage'))
const ForgotPasswordPage = lazyWithRetry(() => import('@/pages/ForgotPasswordPage')) const ForgotPasswordPage = lazyWithRetry(() => import('@/pages/ForgotPasswordPage'))
const ResetPasswordPage = lazyWithRetry(() => import('@/pages/ResetPasswordPage')) const ResetPasswordPage = lazyWithRetry(() => import('@/pages/ResetPasswordPage'))
@@ -65,6 +67,11 @@ const DevBranchingPage = lazyWithRetry(() => import('@/pages/DevBranchingPage'))
const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage')) const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage'))
const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage')) const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage'))
const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsPage')) 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 WelcomeStep2 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep2'))
const WelcomeStep3 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep3'))
const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams')) const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams'))
const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor')) const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor'))
// Admin pages // Admin pages
@@ -149,6 +156,21 @@ export const router = sentryCreateBrowserRouter([
element: page(VerifyEmailPage), element: page(VerifyEmailPage),
errorElement: <RouteError />, errorElement: <RouteError />,
}, },
{
path: '/accept-invite',
element: page(AcceptInvitePage),
errorElement: <RouteError />,
},
{
path: '/auth/google/callback',
element: page(OAuthCallbackPage),
errorElement: <RouteError />,
},
{
path: '/auth/microsoft/callback',
element: page(OAuthCallbackPage),
errorElement: <RouteError />,
},
{ {
path: '/survey', path: '/survey',
element: page(SurveyPage), element: page(SurveyPage),
@@ -223,6 +245,12 @@ export const router = sentryCreateBrowserRouter([
{ path: 'dev/branching', element: page(DevBranchingPage) }, { path: 'dev/branching', element: page(DevBranchingPage) },
{ path: 'guides', element: page(GuidesHubPage) }, { path: 'guides', element: page(GuidesHubPage) },
{ path: 'guides/:slug', element: page(GuideDetailPage) }, { 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) },
{ path: 'welcome/step-2', element: page(WelcomeStep2) },
{ path: 'welcome/step-3', element: page(WelcomeStep3) },
// Admin routes // Admin routes
{ {
path: 'admin', path: 'admin',

View File

@@ -6,6 +6,7 @@ import { authApi } from '@/api/auth'
import { identifyUser, resetAnalytics, analytics } from '@/lib/analytics' import { identifyUser, resetAnalytics, analytics } from '@/lib/analytics'
import { apiClient } from '@/api/client' import { apiClient } from '@/api/client'
import { clearCachedQuota } from '@/hooks/useCachedQuota' import { clearCachedQuota } from '@/hooks/useCachedQuota'
import { useBillingStore } from '@/store/billingStore'
interface AuthState { interface AuthState {
user: User | null user: User | null
@@ -85,6 +86,7 @@ export const useAuthStore = create<AuthState>()(
localStorage.removeItem('access_token') localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token') localStorage.removeItem('refresh_token')
clearCachedQuota() clearCachedQuota()
useBillingStore.getState().reset()
Sentry.setUser(null) Sentry.setUser(null)
resetAnalytics() resetAnalytics()
set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null }) set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null })
@@ -117,6 +119,11 @@ export const useAuthStore = create<AuthState>()(
identifyUser({ id: user.id, email: user.email, role: user.role, is_super_admin: user.is_super_admin, account_id: account?.id }) 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 }) 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) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to fetch user' const message = error instanceof Error ? error.message : 'Failed to fetch user'
set({ error: message, isLoading: false }) set({ error: message, isLoading: false })

View File

@@ -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<typeof vi.fn>
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<BillingStatePayload>((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)
})
})

View File

@@ -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<string, unknown>
enabledFeatures: Record<string, boolean>
isLoading: boolean
error: string | null
}
interface BillingActions {
/** Fetch billing state. Sets `isLoading` while in flight. */
fetch: () => Promise<void>
/** Same as `fetch` but intended for explicit refresh after Stripe Checkout. */
refetch: () => Promise<void>
/** 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<BillingStore>((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

View File

@@ -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<string, unknown>
enabledFeatures: Record<string, boolean>
}
/** Raw snake_case payload returned by the backend. */
export interface BillingStateApiResponse {
subscription: SubscriptionState | null
plan_billing: PlanBillingState | null
plan_limits: Record<string, unknown>
enabled_features: Record<string, boolean>
}

View File

@@ -93,6 +93,14 @@ export type {
KBQuotaResponse, KBQuotaResponse,
} from './kbAccelerator' } from './kbAccelerator'
export type {
SubscriptionStatus,
SubscriptionState as BillingSubscriptionState,
PlanBillingState,
BillingStatePayload,
BillingStateApiResponse,
} from './billing'
export * from './scripts' export * from './scripts'
export * from './script-builder' export * from './script-builder'
export * from './integrations' export * from './integrations'

View File

@@ -18,6 +18,8 @@ export interface User {
timezone: string timezone: string
avatar_url: string | null avatar_url: string | null
email_verified_at: string | null email_verified_at: string | null
onboarding_step_completed: number | null
onboarding_dismissed: boolean
} }
export interface UserCreate { export interface UserCreate {
@@ -26,6 +28,8 @@ export interface UserCreate {
name: string name: string
role?: UserRole role?: UserRole
invite_code?: string invite_code?: string
/** Account invite code to join an existing account (issued via /accounts/me/invites). */
account_invite_code?: string
} }
export interface UserLogin { export interface UserLogin {