Compare commits
25 Commits
feat/billi
...
380fcf7bde
| Author | SHA1 | Date | |
|---|---|---|---|
| 380fcf7bde | |||
| 4b098deac5 | |||
| 502c0a44e8 | |||
| 06200fabb1 | |||
| 3630dd5a80 | |||
| 5e0c9d2de1 | |||
| fee4cb5b74 | |||
| c75ce0c9a3 | |||
| db2478dd89 | |||
| 67fae91087 | |||
| 0c326d0616 | |||
| 99343ab7a9 | |||
| 53dd5f13e5 | |||
| 9b517d3320 | |||
| 7d939a4acf | |||
| 39e85c9770 | |||
| 70ab1f34d4 | |||
| ece82225f2 | |||
| 0b5ed9aa10 | |||
| 7a9cb4b03b | |||
| 80baf89b00 | |||
| d05b475a41 | |||
| 694279f89e | |||
| 16f5e4ce05 | |||
| 2f8ec3775e |
@@ -1,9 +1,10 @@
|
|||||||
# CURRENT_TASK.md
|
# CURRENT_TASK.md
|
||||||
|
|
||||||
**Active task:** None — pick next from `.ai/TODO.md` or `03-DEVELOPMENT-ROADMAP.md`.
|
**Active task:** Self-serve signup Phase 2 — code complete on `feat/self-serve-signup-phase-2` (HEAD `c75ce0c`). PR not yet opened. Next: review/merge, then Phase O manual ops (Stripe live setup, internal validation, flag flip). See `.ai/HANDOFF.md` for the resume point.
|
||||||
|
|
||||||
## Recently shipped
|
## Recently shipped
|
||||||
|
|
||||||
|
- **2026-05-06 — `feat/self-serve-signup-phase-2`** Phase 2 frontend cutover code (Tasks 27–44 of the plan, 18 commits). Backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Phase O (Stripe live setup, internal validation, flag flip) is operational and pending. Single alembic head `c6cbfc534fad` (no new migrations).
|
||||||
- **2026-05-02 — PR #159** In-product User Guides rewrite. Merged into `main`. Replaced 15 feature-dump guides with 43 problem-oriented Diátaxis how-tos grouped under 10 categories. Dropped Maintenance Flows / AI Assistant / Flow Assist Sparkles (UI no longer exists). Renamed Step Library → Solutions Library. Authored 14 net-new how-tos for FlowPilot-era surfaces (tasklane keyboard flow, what-we-know, resolve, escalate, record-fix-outcome, post-docs-to-ticket, share-update, pause-and-leave, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate, etc.). Schema additions: `category`, optional `relatedSlugs`; hub renders category sections; detail page renders related-guides footer. Fixed rendering bug where `**bold**` in `step.tip` rendered literally. Killed misleading "N sections" subtitle on guide cards. Browser-verified against engineer + owner login (sidebar labels, account sub-pages, pilot-screen header buttons, Tasks panel, integration form). Two unverified items intentionally deferred: change-teammate-role (requires non-owner test member to inspect role-change control) and detailed Resolve / Escalate modal contents (Resolve gated by 6 pending tasks in test data). tsc and Vite build clean.
|
- **2026-05-02 — PR #159** In-product User Guides rewrite. Merged into `main`. Replaced 15 feature-dump guides with 43 problem-oriented Diátaxis how-tos grouped under 10 categories. Dropped Maintenance Flows / AI Assistant / Flow Assist Sparkles (UI no longer exists). Renamed Step Library → Solutions Library. Authored 14 net-new how-tos for FlowPilot-era surfaces (tasklane keyboard flow, what-we-know, resolve, escalate, record-fix-outcome, post-docs-to-ticket, share-update, pause-and-leave, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate, etc.). Schema additions: `category`, optional `relatedSlugs`; hub renders category sections; detail page renders related-guides footer. Fixed rendering bug where `**bold**` in `step.tip` rendered literally. Killed misleading "N sections" subtitle on guide cards. Browser-verified against engineer + owner login (sidebar labels, account sub-pages, pilot-screen header buttons, Tasks panel, integration form). Two unverified items intentionally deferred: change-teammate-role (requires non-owner test member to inspect role-change control) and detailed Resolve / Escalate modal contents (Resolve gated by 6 pending tasks in test data). tsc and Vite build clean.
|
||||||
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Merged into `main` as `5e10005`.
|
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Merged into `main` as `5e10005`.
|
||||||
- **Impeccable pass** (5 sub-passes — distill / quieter / layout / typeset / polish): score 24/40 → 33/40. Removed the duplicate "Suggested checks" chip strip; added an inline `Next steps · N pending in Tasks` cue above the latest action-bearing AI bubble; consolidated the desktop session header to Resolve + Escalate + ⋯ kebab (Context / New Ticket / Update Ticket / Pause now under the kebab, mobile kebab gained Context + New Ticket parity); centered the messages column to `max-w-3xl` to match the composer; bubbles dropped to `rounded-xl`. Decoration sweep: dropped 3px side stripes (TaskLane done states, all 6 ProposalBanner modes, WhatWeKnowItem rows), gradient backgrounds (WhatWeKnow + every banner), accent borderTop on TaskLane header, backdrop-blur on handoff overlay, animate-pulse-amber ring in VerifyingBanner, bordered avatar boxes in banners. Type sweep: 14 distinct sizes → 5-step scale (10/11/12/13/14px). Icon disambiguation: `MessageCircleQuestion` split into `Pencil` (Answer CTA) + `HelpCircle` (per-check explainer). Dead `font-sans` audit (12 sites) and double `text-xs` cleanups.
|
- **Impeccable pass** (5 sub-passes — distill / quieter / layout / typeset / polish): score 24/40 → 33/40. Removed the duplicate "Suggested checks" chip strip; added an inline `Next steps · N pending in Tasks` cue above the latest action-bearing AI bubble; consolidated the desktop session header to Resolve + Escalate + ⋯ kebab (Context / New Ticket / Update Ticket / Pause now under the kebab, mobile kebab gained Context + New Ticket parity); centered the messages column to `max-w-3xl` to match the composer; bubbles dropped to `rounded-xl`. Decoration sweep: dropped 3px side stripes (TaskLane done states, all 6 ProposalBanner modes, WhatWeKnowItem rows), gradient backgrounds (WhatWeKnow + every banner), accent borderTop on TaskLane header, backdrop-blur on handoff overlay, animate-pulse-amber ring in VerifyingBanner, bordered avatar boxes in banners. Type sweep: 14 distinct sizes → 5-step scale (10/11/12/13/14px). Icon disambiguation: `MessageCircleQuestion` split into `Pencil` (Answer CTA) + `HelpCircle` (per-check explainer). Dead `font-sans` audit (12 sites) and double `text-xs` cleanups.
|
||||||
|
|||||||
@@ -2,35 +2,71 @@
|
|||||||
|
|
||||||
# HANDOFF.md
|
# HANDOFF.md
|
||||||
|
|
||||||
**Last updated:** 2026-05-06 (Phase 1 backend complete on `feat/self-serve-signup-spec`)
|
**Last updated:** 2026-05-07 (Phase 2 code complete + four post-implementation fixes; cutover ops still pending)
|
||||||
|
|
||||||
**Active task:** Phase 1 self-serve signup backend foundation — DONE on branch. PR not yet opened.
|
**Active task:** None mid-flight. Branch `feat/self-serve-signup-phase-2` (HEAD `502c0a4`) is ready to PR.
|
||||||
|
|
||||||
## Where this session ended
|
## Where this session ended
|
||||||
|
|
||||||
24 commits on top of `main` (`31ca3fb`). All 26 tasks from `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-1-backend.md` complete. Full pytest run is green (1167 passed, 35 deselected). Single alembic head: `c6cbfc534fad`.
|
Tasks 27–44 of Phase 2 implemented across 18 commits on `feat/self-serve-signup-phase-2`, branched off `main` (`f918b76`). Each task went through implementer + spec review + code-quality review per `superpowers:subagent-driven-development`. Cross-cutting review caught one redirect bug (fixed in-flight). After initial handoff, external code review surfaced four more real bugs — all fixed (commits `5e0c9d2`, `3630dd5`, `06200fa`, `502c0a4`):
|
||||||
|
|
||||||
Phase 1 covered: schema additions (oauth_identities, plan_billing, sales_leads, stripe_events, plus 5 new columns across users/accounts/account_invites), Subscription complimentary status + has_pro_entitlement, the two new guards (`require_active_subscription`, `require_verified_email_after_grace`), full BillingService (start_trial / create_checkout_session / apply_subscription_event / get_billing_state), Stripe webhook handler, Google + Microsoft OAuth callbacks with oauth_identities linking, OAuth-only password guard, register-time verification email + invite email-match, bulk + soft-revoke invite routes, GET /billing/state, and the pilot complimentary backfill migration.
|
1. **OAuth refresh tokens never stored** (Phase 1 latent): `_store_refresh_token` extracted to module-public `store_refresh_token`; both Google + Microsoft callbacks now persist the JTI before returning. Test covers callback → `/auth/refresh` round-trip.
|
||||||
|
2. **OAuth callback never marked store authenticated** (Phase 2 introduced): `setTokens` now sets `isAuthenticated: true`. Verified safe for the refresh-interceptor caller.
|
||||||
|
3. **Stripe webhook idempotency could permanently drop failed events** (Phase 1 latent): `apply_subscription_event` now adds StripeEvent + runs handler + commits atomically; handler exception → rollback → Stripe retries. Inner `_handle_*` commits removed.
|
||||||
|
4. **Missing `/account/billing` and `/account/billing/select-plan` pages** (Phase 2 oversight): all the new billing CTAs (TrialPill, UpgradePrompt, NextStepCard, SetupChecklist) linked to non-existent routes. Built BillingPage (subscription summary + Manage-billing → portal session) and SelectPlanPage (plan cards + checkout flow).
|
||||||
|
|
||||||
The conftest's `test_user` fixture was modified to seed a Pro/active Subscription post-register (delete-then-insert) so the new subscription guard doesn't 402 every existing test. Two existing tests adapted because they explicitly assumed the old free-plan default: `test_subscription_limits.py` (the two free-plan tests now downgrade inline) and `test_kb_accelerator.py::TestQuota::test_get_quota` (the `kb_setup` fixture downgrades to free).
|
**Backend additions (Phase I, Tasks 27–31):**
|
||||||
|
- `BillingService.open_customer_portal` + `GET /billing/portal-session` (allowlisted for canceled/unverified users).
|
||||||
|
- `PATCH /users/me/onboarding-step` + `POST /users/me/onboarding-dismiss-rest`.
|
||||||
|
- `POST /sales-leads` (public, 5/hr/IP rate limit, fire-and-forget notification email, server-side PostHog event stub).
|
||||||
|
- `/admin/plan-limits` GET/PUT round-trips `plan_billing` (Stripe IDs, prices, public/archived flags) in one transaction; `BillingService.invalidate_billing_cache` no-op stub for future cache.
|
||||||
|
- `GET /config/public` (`{self_serve_enabled, oauth_providers}`); register-endpoint gate now `REQUIRE_INVITE_CODE and not SELF_SERVE_ENABLED and not invite_code`.
|
||||||
|
- `GET /accounts/invites/{code}/lookup` (Phase 36).
|
||||||
|
- OAuth callback extended to honor `account_invite_code` + `invited_email` for invitee-via-OAuth path; rejects existing-email user with `email_already_registered_use_login`.
|
||||||
|
- `GET /plans/public` (Phase 42).
|
||||||
|
- `POST /beta-signup` returns 307 to `${FRONTEND_URL}/register?from=beta`.
|
||||||
|
- `OnboardingStatus` extended with `email_verified` + `shop_setup_done`; `UserResponse` exposes `onboarding_step_completed` + `onboarding_dismissed`.
|
||||||
|
|
||||||
|
**Frontend additions:**
|
||||||
|
- `useBillingStore` (Zustand, polled 60s via `useBillingPoll` mounted in `AppLayout`), `useFeature` / `useFeatureLimit` / `useTrialBanner` hooks, `FeatureGate` / `UpgradePrompt` / `EmailVerificationGate` components.
|
||||||
|
- `RegisterPage` redesign: OAuth (Google/Microsoft) buttons + invite-code-conditional. `OAuthCallbackPage` at `/auth/{google,microsoft}/callback` with CSRF state validation. `useAppConfig` hook (consumes `/config/public`, falls back to `VITE_SELF_SERVE_ENABLED`).
|
||||||
|
- `AcceptInvitePage` at `/accept-invite?code=...` (locked email, 3 sign-in options, `/?welcome=teammate` on success).
|
||||||
|
- `EmailVerificationBanner` refactored to design-system tokens + grace-period hide; `EmailVerificationWall` polished; `VerifyEmailPage` at `/verify-email?token=...` (single-fire guard).
|
||||||
|
- `WelcomeRouter` + `WelcomeStep1/2/3` at `/welcome*`. PSA tile UI + bulk invite emails per row.
|
||||||
|
- `TrialPill` in topbar (pristine/warning/urgent/expired/paid/complimentary/past_due/canceled).
|
||||||
|
- `NextStepCard` + `SetupChecklist` (replaces orphaned `OnboardingChecklist`); unified list, no SOLO/TEAM split, no Script Builder item.
|
||||||
|
- `PricingPage` at `/pricing` (B-style, 3 plan cards, hardcoded comparison v1).
|
||||||
|
- `ContactSalesPage` at `/contact-sales`. `LandingPage` got "See pricing" CTA + replaced beta-signup form with `<Link to="/register?from=beta">`.
|
||||||
|
|
||||||
|
New env vars (Vite ARG/ENV in Dockerfile + `.env.example`): `VITE_SELF_SERVE_ENABLED`, `VITE_GOOGLE_CLIENT_ID`, `VITE_MS_CLIENT_ID`, `VITE_OAUTH_REDIRECT_BASE`, `VITE_CALENDLY_URL`. Backend env: `SALES_LEAD_RECIPIENT_EMAIL` (default `sales@resolutionflow.com`).
|
||||||
|
|
||||||
## Resume point — DO THIS NEXT
|
## Resume point — DO THIS NEXT
|
||||||
|
|
||||||
1. Open the PR for branch `feat/self-serve-signup-spec`. Use `gh pr create` against `main`. Suggested title: `feat: self-serve signup backend (Phase 1)`. Body should mention dark-launch posture (every new endpoint is gated by env config, not a feature flag — see Task 26 §3 in the plan).
|
1. **Review the branch and open a PR.** 18 commits, all squashed-or-not at user discretion. Branch `feat/self-serve-signup-phase-2` from `main`.
|
||||||
2. Phase 2 (frontend + cutover) lives in a sibling plan: `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend.md` (assumed; verify path). It's the next logical task once Phase 1 ships.
|
2. **Phase O — manual operational tasks** (Tasks 45–47 from the plan):
|
||||||
|
- **Task 45:** Stripe live-mode setup (Products, Prices, Customer Portal, webhook endpoint + signing secret) and Railway prod env vars (`STRIPE_*`, `OAUTH_REDIRECT_BASE`, `GOOGLE_CLIENT_*`, `MS_CLIENT_*`). Run `python -m scripts.sync_stripe_plan_ids` to populate `plan_billing` rows with live Stripe IDs.
|
||||||
|
- **Task 46:** Internal validation pass via `INTERNAL_TESTER_EMAILS` allowlist (per-email bypass of `SELF_SERVE_ENABLED=false`). Backend support for the allowlist itself is NOT implemented — needs a small addition to `auth.py`/`config.py` if the validation pass requires it. Otherwise validate in test mode with the flag flipped temporarily.
|
||||||
|
- **Task 47:** Flip `SELF_SERVE_ENABLED=true` (backend) + `VITE_SELF_SERVE_ENABLED=true` (frontend rebuild). Watch PostHog funnel + Stripe webhook error rate.
|
||||||
|
3. **Followups deferred during Phase 2** (logged below).
|
||||||
|
|
||||||
## Followups deferred from this session
|
## Followups deferred from Phase 2
|
||||||
|
|
||||||
- **OAuth callbacks don't call `_store_refresh_token`.** The Google/Microsoft callbacks issue a refresh JWT but never persist its hash to `refresh_tokens` (the password-login flow does via `auth.py:_store_refresh_token`). Result: refresh-token revocation/rotation lookups won't find OAuth-issued tokens. Decide before Phase 2 dark-launch whether to backfill — likely yes, by extracting `_store_refresh_token` to a shared module and calling it from `_sign_in_or_register`.
|
- **PostHog server-side capture infrastructure missing.** `/sales-leads` calls `_capture_posthog_event` which lazy-imports `app.core.analytics.posthog` (no-op if module absent). Wire up the real PostHog server SDK before cutover — needs `app/core/analytics.py` with a configured client, plus `python-posthog` dep.
|
||||||
- **`stripe_enabled` was relaxed** in Task 14 from `bool(STRIPE_SECRET_KEY) and bool(STRIPE_WEBHOOK_SECRET)` to just the secret key. The webhook handler in Task 16 independently checks `STRIPE_WEBHOOK_SECRET` before calling `construct_event`, so signature verification is still safe — but if any other code reads `stripe_enabled` and assumes the webhook secret is set, that's a latent bug. Audit before Phase 2 cutover.
|
- **Frontend `/usage/{field}` endpoint missing.** `useFeatureLimit` lazy-fetches `apiClient.get('/usage/' + field)` — endpoint doesn't exist; hook silently falls back to `used=0`. Build the backend endpoint before any UI surface relies on real usage counts.
|
||||||
- **`backend/app/core/stripe_handlers.py`** is a stub module that's no longer referenced after Task 16. Safe to delete in a follow-up; left in place to keep Phase 1 diff focused.
|
- **Pre-existing dual subscription stores** (`authStore.subscription` legacy + `billingStore.subscription` new). `Subscription.status` union in `frontend/src/types/account.ts` is missing `'complimentary'`. Phase 2 didn't introduce — but worth deprecating the old store before any UI reads `complimentary` from the wrong source.
|
||||||
- **Pilot backfill migration `c6cbfc534fad` has not been applied to prod yet.** It runs once at deploy time and is forward-only.
|
- **`INTERNAL_TESTER_EMAILS` per-email allowlist** (Task 46): backend support not implemented. Add when Phase O Task 46 needs it.
|
||||||
|
- **`accept-invite` not gated by `self_serve_enabled`** — by design (invites work in any mode). Confirm if pilot wants stricter gating.
|
||||||
|
- **`UserResponse.onboarding_step_completed` validator** suggested by reviewer (reject `complete` without data) — skipped, spec types `data?` as optional.
|
||||||
|
- **Welcome wizard behind `EmailVerificationGate`:** Day 7+ unverified users hit the wall on `/welcome/*` and have no path back into the wizard until they verify. Per spec — wall says "Resend verification" → user verifies → wall closes → wizard resumes. If product wants a different UX, allowlist `/welcome/*` in the gate.
|
||||||
|
- **`SelectPlanPage` seat input doesn't validate against plan `max_seats`.** Stripe Checkout will reject if exceeded; nicer UX would cap client-side.
|
||||||
|
- **`BillingPage` Manage-billing button always enabled** — clicking with no `stripe_customer_id` falls back to a toast. By design; could disable + tooltip if billing-state payload were extended with that flag.
|
||||||
|
|
||||||
## Environment notes (carry-forward)
|
## Environment notes (carry-forward)
|
||||||
|
|
||||||
- Code-server LXC has bun + docker but no native python/node/npm. Use `docker exec resolutionflow_{backend,frontend} ...` for build/test commands.
|
- Code-server LXC: docker-only, no native python/node/npm. Use `docker exec resolutionflow_{backend,frontend} ...`.
|
||||||
- Pytest WORKDIR is `/app` — test paths in pytest commands are `tests/<file>`, NOT `backend/tests/<file>`.
|
- Pytest: `docker exec resolutionflow_backend pytest tests/<file> -v --override-ini="addopts="`.
|
||||||
- Backend pytest cmd: `docker exec resolutionflow_backend pytest tests/<path> -v --override-ini="addopts="`. The full run takes ~25 min.
|
- Vitest: `docker exec -w /app resolutionflow_frontend npm test -- <path> --run`.
|
||||||
- Alembic via `docker exec -w /app resolutionflow_backend alembic ...`. Never pass `--rev-id`.
|
- TS build: `docker exec -w /app resolutionflow_frontend npx tsc -b`.
|
||||||
- No `gh` CLI on this LXC — use the Gitea API (`$GITEA_TOKEN` in `.claude/settings.local.json`) for PR/issue work, or run `gh` from a host that has it.
|
- Alembic: `docker exec -w /app resolutionflow_backend alembic ...`. Never `--rev-id`.
|
||||||
- Headless Chromium (`/qa`, `/browse`) needs `CONTAINER=1` in the env launching the browse server (LXC namespace constraint).
|
- No `gh` CLI — Gitea API via `$GITEA_TOKEN` for PR/issue work.
|
||||||
|
- Single alembic head: `c6cbfc534fad` (from Phase 1). Phase 2 added no migrations.
|
||||||
|
|||||||
@@ -12,6 +12,18 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-05-06 — Claude — Self-serve signup Phase 2 (frontend + cutover code) shipped on `feat/self-serve-signup-phase-2`
|
||||||
|
|
||||||
|
- Executed Tasks 27–44 of `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md` via `superpowers:subagent-driven-development`. 18 commits on `feat/self-serve-signup-phase-2` (off `main` `f918b76`); HEAD `c75ce0c`. Each task: dispatched implementer subagent with full task text + curated context, then spec-compliance + code-quality review subagents; review issues either fixed in-flight via `git commit --amend` or noted as deferred scope.
|
||||||
|
- Backend (Phase I, Tasks 27–31): `BillingService.open_customer_portal` + `GET /billing/portal-session`; `PATCH /users/me/onboarding-step` + dismiss-rest sibling; public `POST /sales-leads` (5/hr/IP); `/admin/plan-limits` GET/PUT round-trips `plan_billing` in one transaction with NOT-NULL guards on `display_name|is_public|is_archived|sort_order`; `BillingService.invalidate_billing_cache` no-op stub; `GET /config/public` (`{self_serve_enabled, oauth_providers}`); `auth/register` invite-code gate now `REQUIRE_INVITE_CODE and not SELF_SERVE_ENABLED and not invite_code`. Also (T36): `GET /accounts/invites/{code}/lookup` (public, joinedload account+inviter); OAuth callback honors `account_invite_code+invited_email`, rejects existing-email user with `email_already_registered_use_login`. Also (T42, T44): `GET /plans/public`; `POST /beta-signup` returns 307 to `${FRONTEND_URL}/register?from=beta`. `OnboardingStatus` extended with `email_verified`+`shop_setup_done`; `UserResponse` exposes `onboarding_step_completed`+`onboarding_dismissed`.
|
||||||
|
- Frontend (Phases J–N, Tasks 32–44): `useBillingStore` Zustand store + `useBillingPoll` mounted in `AppLayout`; `useFeature` / `useFeatureLimit` (60s module cache, lazy `/usage/{field}` fetch with silent fallback — endpoint deferred) / `useTrialBanner` (fractional-day boundary so 24h = warning); `FeatureGate` / `UpgradePrompt` (inline `FEATURE_CATALOG`) / `EmailVerificationGate` (mounted in AppLayout around `<ViewTransitionOutlet />`). `RegisterPage` redesign with OAuth buttons + invite-code conditional; `OAuthCallbackPage` with CSRF state validation + UTF-8-safe base64url state encoding (factored into `lib/oauthState.ts`); `useAppConfig` hook. `AcceptInvitePage` at `/accept-invite` with locked email; `EmailVerificationBanner` refactored to design-system tokens; `EmailVerificationWall` polished; `VerifyEmailPage` at `/verify-email` with single-fire ref guard; `WelcomeRouter` + `WelcomeStep1/2/3` at `/welcome*`; `TrialPill` in topbar (8 stages); `NextStepCard` + `SetupChecklist` (replace orphaned `OnboardingChecklist`); `PricingPage` at `/pricing`; `ContactSalesPage` at `/contact-sales`; `LandingPage` got "See pricing" CTA + replaced beta-signup form with `<Link>`.
|
||||||
|
- Final cross-cutting review caught one real bug — relative `/beta-signup` 307 target landing on API origin instead of frontend — fixed via amend (HEAD `c75ce0c`).
|
||||||
|
- Tests: ~165+ new tests across backend pytest + frontend vitest. Sweep at end-of-branch all-green; tsc -b clean.
|
||||||
|
- Phase O (Tasks 45–47) is explicit manual operations: Stripe live-mode setup, internal validation via `INTERNAL_TESTER_EMAILS` per-email allowlist (backend support for that allowlist is NOT yet built), feature-flag flip + week-1 monitoring. Surfaced as the resume point in HANDOFF.md.
|
||||||
|
- Working tree was dirty before this session (`.ai/HANDOFF.md`, `.env.example`s, `core.*` core dumps, `docs/architecture/`, `docs/tutorials/`); intentionally not staged into Phase 2 commits. Files touched: see `git log --oneline f918b76..HEAD` on `feat/self-serve-signup-phase-2`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-05-02 ~01:00 UTC — Claude — In-product User Guides Diátaxis rewrite shipped (PR #159)
|
## 2026-05-02 ~01:00 UTC — Claude — In-product User Guides Diátaxis rewrite shipped (PR #159)
|
||||||
|
|
||||||
- Audited the in-product `/guides` collection against live UI via `/browse` (engineer + owner test users). Existing 15 guides predated the FlowPilot pivot — every "click X in the sidebar" reference was wrong (Dashboard → Home, All Flows → Flows, Sessions → History, Exports gone, etc.). Three guides described surfaces that no longer exist: Maintenance Flows, AI Assistant page, Flow Assist Sparkles button. Findings written to `/tmp/guides-audit.md`.
|
- Audited the in-product `/guides` collection against live UI via `/browse` (engineer + owner test users). Existing 15 guides predated the FlowPilot pivot — every "click X in the sidebar" reference was wrong (Dashboard → Home, All Flows → Flows, Sessions → History, Exports gone, etc.). Three guides described surfaces that no longer exist: Maintenance Flows, AI Assistant page, Flow Assist Sparkles button. Findings written to `/tmp/guides-audit.md`.
|
||||||
|
|||||||
@@ -21,4 +21,12 @@ ANTHROPIC_API_KEY=
|
|||||||
VOYAGE_API_KEY=
|
VOYAGE_API_KEY=
|
||||||
|
|
||||||
# ConnectWise PSA Integration
|
# ConnectWise PSA Integration
|
||||||
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
|
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
# Test keys from Stripe Dashboard → Developers → API keys (with Test mode toggled on).
|
||||||
|
# Webhook secret for local dev: from `stripe listen --forward-to localhost:8000/api/v1/webhooks/stripe`.
|
||||||
|
# When unset, app/core/config.py:stripe_enabled returns False and Stripe code paths short-circuit.
|
||||||
|
STRIPE_SECRET_KEY=sk_test_
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_test_
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_
|
||||||
@@ -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",
|
||||||
|
|||||||
54
backend/app/api/endpoints/account_invite_lookup.py
Normal file
54
backend/app/api/endpoints/account_invite_lookup.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -47,8 +47,16 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||||
|
|
||||||
|
|
||||||
async def _store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
|
async def store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
|
||||||
"""Decode a refresh token JWT and store its hash in the database."""
|
"""Decode a refresh token JWT and store its hash in the database.
|
||||||
|
|
||||||
|
Module-public so OAuth callback endpoints (and any future token-issuing
|
||||||
|
surface) can register the JTI in the ``refresh_tokens`` table the same
|
||||||
|
way ``/auth/login`` does. Without this the first ``/auth/refresh`` call
|
||||||
|
will reject the token as "revoked" because no row exists.
|
||||||
|
|
||||||
|
Caller is responsible for committing the session.
|
||||||
|
"""
|
||||||
payload = decode_token(refresh_token_str)
|
payload = decode_token(refresh_token_str)
|
||||||
if payload and payload.get("jti"):
|
if payload and payload.get("jti"):
|
||||||
token_record = RefreshToken(
|
token_record = RefreshToken(
|
||||||
@@ -136,7 +144,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"
|
||||||
@@ -312,7 +328,7 @@ async def login(
|
|||||||
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||||
|
|
||||||
# Store refresh token hash in DB
|
# Store refresh token hash in DB
|
||||||
await _store_refresh_token(db, refresh_token_str, user.id)
|
await store_refresh_token(db, refresh_token_str, user.id)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return Token(
|
return Token(
|
||||||
@@ -347,7 +363,7 @@ async def login_json(
|
|||||||
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||||
|
|
||||||
# Store refresh token hash in DB
|
# Store refresh token hash in DB
|
||||||
await _store_refresh_token(db, refresh_token_str, user.id)
|
await store_refresh_token(db, refresh_token_str, user.id)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return Token(
|
return Token(
|
||||||
@@ -405,7 +421,7 @@ async def refresh_token(
|
|||||||
new_refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
new_refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||||
|
|
||||||
# Store new refresh token
|
# Store new refresh token
|
||||||
await _store_refresh_token(db, new_refresh_token_str, user.id)
|
await store_refresh_token(db, new_refresh_token_str, user.id)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return Token(
|
return Token(
|
||||||
|
|||||||
@@ -1,31 +1,44 @@
|
|||||||
"""Public beta signup endpoint — no auth required."""
|
"""Legacy beta signup endpoint — redirects to /register?from=beta.
|
||||||
|
|
||||||
|
Phase 2 (self-serve signup) makes the public register flow the canonical
|
||||||
|
front door. The old `/api/v1/beta-signup` POST endpoint is kept mounted to
|
||||||
|
preserve any external links that still hit it, but now responds with a
|
||||||
|
307 Temporary Redirect to `/register?from=beta` so the user lands in the
|
||||||
|
real signup flow. The `?from=beta` marker lets the frontend tag the
|
||||||
|
signup origin for analytics.
|
||||||
|
|
||||||
|
Note: there is no `beta_signup` database table — the original endpoint
|
||||||
|
only fired a notification email. There is therefore no waitlist to email
|
||||||
|
and no migration to run when retiring the endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel, EmailStr
|
from fastapi import APIRouter
|
||||||
from app.core.email import EmailService
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/beta-signup", tags=["beta"])
|
router = APIRouter(prefix="/beta-signup", tags=["beta"])
|
||||||
|
|
||||||
|
# Local-dev fallback when FRONTEND_URL isn't configured. The redirect must
|
||||||
class BetaSignupRequest(BaseModel):
|
# be absolute — a relative URL would resolve against the API origin
|
||||||
email: EmailStr
|
# (api.resolutionflow.com), which has no /register page.
|
||||||
|
_DEFAULT_FRONTEND_URL = "http://localhost:5173"
|
||||||
|
|
||||||
|
|
||||||
class BetaSignupResponse(BaseModel):
|
@router.post("", include_in_schema=False)
|
||||||
success: bool
|
async def beta_signup_redirect() -> RedirectResponse:
|
||||||
message: str
|
"""Redirect legacy beta-signup POST to the public register page.
|
||||||
|
|
||||||
|
Returns 307 so any client following the redirect preserves the HTTP
|
||||||
@router.post("", response_model=BetaSignupResponse)
|
method; the frontend treats `/register?from=beta` as the canonical
|
||||||
async def beta_signup(data: BetaSignupRequest):
|
entry point and reads the `from` query param for analytics.
|
||||||
"""Collect beta interest — sends notification to beta@resolutionflow.com."""
|
"""
|
||||||
sent = await EmailService.send_beta_signup_notification(data.email)
|
frontend_url = settings.FRONTEND_URL or _DEFAULT_FRONTEND_URL
|
||||||
if not sent:
|
return RedirectResponse(
|
||||||
logger.warning("Beta signup recorded (email delivery skipped): %s", data.email)
|
url=f"{frontend_url}/register?from=beta",
|
||||||
return BetaSignupResponse(
|
status_code=307,
|
||||||
success=True,
|
|
||||||
message="Thanks! We'll be in touch with beta access details.",
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
40
backend/app/api/endpoints/config.py
Normal file
40
backend/app/api/endpoints/config.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -7,10 +7,12 @@ 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
|
||||||
|
|
||||||
|
from app.api.endpoints.auth import store_refresh_token
|
||||||
from app.core.admin_database import get_admin_db
|
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 +33,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 +67,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,10 +180,23 @@ 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,
|
||||||
|
)
|
||||||
|
refresh_token_str = create_refresh_token({"sub": str(user.id)})
|
||||||
|
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
|
||||||
|
# reject this token as "revoked" (the rotation logic requires a row to
|
||||||
|
# mark as used). _sign_in_or_register already committed; this needs a
|
||||||
|
# second commit.
|
||||||
|
await store_refresh_token(db, refresh_token_str, user.id)
|
||||||
|
await db.commit()
|
||||||
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=refresh_token_str,
|
||||||
is_new_user=is_new,
|
is_new_user=is_new,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,9 +210,22 @@ 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,
|
||||||
|
)
|
||||||
|
refresh_token_str = create_refresh_token({"sub": str(user.id)})
|
||||||
|
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
|
||||||
|
# reject this token as "revoked" (the rotation logic requires a row to
|
||||||
|
# mark as used). _sign_in_or_register already committed; this needs a
|
||||||
|
# second commit.
|
||||||
|
await store_refresh_token(db, refresh_token_str, user.id)
|
||||||
|
await db.commit()
|
||||||
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=refresh_token_str,
|
||||||
is_new_user=is_new,
|
is_new_user=is_new,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
58
backend/app/api/endpoints/plans_public.py
Normal file
58
backend/app/api/endpoints/plans_public.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Public plans endpoint — no auth required.
|
||||||
|
|
||||||
|
GET /api/v1/plans/public
|
||||||
|
Returns the public-safe view of `plan_billing` joined with
|
||||||
|
`plan_limits.max_users` (exposed as `max_seats`), filtered to
|
||||||
|
`is_public=True AND is_archived=False`, ordered by sort_order ASC, plan ASC.
|
||||||
|
|
||||||
|
Distinct from `/admin/plan-limits` (admin-only, returns ALL plans including
|
||||||
|
archived/internal). This endpoint exists to power the marketing /pricing page
|
||||||
|
without exposing the rest of the admin-only billing surface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.admin_database import get_admin_db
|
||||||
|
from app.models.plan_billing import PlanBilling
|
||||||
|
from app.models.plan_limits import PlanLimits
|
||||||
|
from app.schemas.billing import PublicPlanResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/plans", tags=["plans"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/public", response_model=list[PublicPlanResponse])
|
||||||
|
async def list_public_plans(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||||
|
) -> list[PublicPlanResponse]:
|
||||||
|
"""List public, non-archived plans for the marketing /pricing page.
|
||||||
|
|
||||||
|
Public — no auth. Uses `get_admin_db` because this is a cross-tenant read
|
||||||
|
of the global plan catalog (same pattern as `/config/public`).
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(PlanBilling, PlanLimits.max_users)
|
||||||
|
.outerjoin(PlanLimits, PlanBilling.plan == PlanLimits.plan)
|
||||||
|
.where(PlanBilling.is_public.is_(True))
|
||||||
|
.where(PlanBilling.is_archived.is_(False))
|
||||||
|
.order_by(PlanBilling.sort_order.asc(), PlanBilling.plan.asc())
|
||||||
|
)
|
||||||
|
rows = (await db.execute(stmt)).all()
|
||||||
|
return [
|
||||||
|
PublicPlanResponse(
|
||||||
|
plan=billing.plan,
|
||||||
|
display_name=billing.display_name,
|
||||||
|
description=billing.description,
|
||||||
|
monthly_price_cents=billing.monthly_price_cents,
|
||||||
|
annual_price_cents=billing.annual_price_cents,
|
||||||
|
max_seats=max_users,
|
||||||
|
sort_order=billing.sort_order,
|
||||||
|
is_public=billing.is_public,
|
||||||
|
)
|
||||||
|
for billing, max_users in rows
|
||||||
|
]
|
||||||
114
backend/app/api/endpoints/sales_leads.py
Normal file
114
backend/app/api/endpoints/sales_leads.py
Normal 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")
|
||||||
@@ -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,
|
||||||
@@ -43,6 +45,7 @@ from app.api.endpoints import (
|
|||||||
notifications,
|
notifications,
|
||||||
oauth as oauth_endpoints,
|
oauth as oauth_endpoints,
|
||||||
onboarding,
|
onboarding,
|
||||||
|
plans_public,
|
||||||
public_templates,
|
public_templates,
|
||||||
ratings,
|
ratings,
|
||||||
scripts,
|
scripts,
|
||||||
@@ -68,6 +71,7 @@ from app.api.endpoints import (
|
|||||||
uploads,
|
uploads,
|
||||||
webhooks,
|
webhooks,
|
||||||
accounts,
|
accounts,
|
||||||
|
account_invite_lookup,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -88,9 +92,13 @@ 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
|
||||||
|
api_router.include_router(plans_public.router) # Public plan catalog for /pricing page
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Admin endpoints — super_admin only
|
# Admin endpoints — super_admin only
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -38,3 +42,23 @@ class BillingStateResponse(BaseModel):
|
|||||||
plan_billing: Optional[PlanBillingState]
|
plan_billing: Optional[PlanBillingState]
|
||||||
plan_limits: Dict[str, Any]
|
plan_limits: Dict[str, Any]
|
||||||
enabled_features: Dict[str, bool]
|
enabled_features: Dict[str, bool]
|
||||||
|
|
||||||
|
|
||||||
|
class PublicPlanResponse(BaseModel):
|
||||||
|
"""Public-safe view of a billable plan, used by the marketing /pricing page.
|
||||||
|
|
||||||
|
Sourced from `plan_billing` joined with `plan_limits.max_users` (exposed
|
||||||
|
here as `max_seats`). Always filtered server-side to is_public=True and
|
||||||
|
is_archived=False, so `is_public` is a constant True for any row returned
|
||||||
|
here — included for clarity and forward compatibility.
|
||||||
|
"""
|
||||||
|
plan: str
|
||||||
|
display_name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
monthly_price_cents: Optional[int] = None
|
||||||
|
annual_price_cents: Optional[int] = None
|
||||||
|
max_seats: Optional[int] = None
|
||||||
|
sort_order: int
|
||||||
|
is_public: bool = True
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
18
backend/app/schemas/config.py
Normal file
18
backend/app/schemas/config.py
Normal 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]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
27
backend/app/schemas/sales_lead.py
Normal file
27
backend/app/schemas/sales_lead.py
Normal 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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -166,28 +210,44 @@ class BillingService:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Idempotent. Returns True if the event was applied; False if it had
|
"""Idempotent. Returns True if the event was applied; False if it had
|
||||||
already been processed (idempotent ack). The webhook handler returns 200
|
already been processed (idempotent ack). The webhook handler returns 200
|
||||||
either way."""
|
either way.
|
||||||
|
|
||||||
|
Atomic: the StripeEvent idempotency mark and the handler's state
|
||||||
|
mutations are committed in a single transaction. If the handler raises
|
||||||
|
the entire transaction (idempotency mark + partial mutations) is rolled
|
||||||
|
back, so a Stripe retry will re-run the handler. Without this, a
|
||||||
|
handler that fails mid-flight would leave the StripeEvent row persisted
|
||||||
|
and silently desync subscription state from Stripe.
|
||||||
|
"""
|
||||||
|
db.add(StripeEvent(
|
||||||
|
id=event_id,
|
||||||
|
event_type=event_type,
|
||||||
|
payload_excerpt=_excerpt(payload),
|
||||||
|
))
|
||||||
try:
|
try:
|
||||||
db.add(StripeEvent(
|
await db.flush()
|
||||||
id=event_id,
|
|
||||||
event_type=event_type,
|
|
||||||
payload_excerpt=_excerpt(payload),
|
|
||||||
))
|
|
||||||
await db.commit()
|
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
|
# Duplicate event_id — already processed (or in flight). Ack with False.
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if event_type == "checkout.session.completed":
|
try:
|
||||||
await _handle_checkout_completed(db, payload)
|
if event_type == "checkout.session.completed":
|
||||||
elif event_type == "customer.subscription.updated":
|
await _handle_checkout_completed(db, payload)
|
||||||
await _handle_subscription_updated(db, payload)
|
elif event_type == "customer.subscription.updated":
|
||||||
elif event_type == "customer.subscription.deleted":
|
await _handle_subscription_updated(db, payload)
|
||||||
await _handle_subscription_deleted(db, payload)
|
elif event_type == "customer.subscription.deleted":
|
||||||
elif event_type == "invoice.payment_failed":
|
await _handle_subscription_deleted(db, payload)
|
||||||
await _handle_payment_failed(db, payload)
|
elif event_type == "invoice.payment_failed":
|
||||||
elif event_type == "invoice.payment_succeeded":
|
await _handle_payment_failed(db, payload)
|
||||||
await _handle_payment_succeeded(db, payload)
|
elif event_type == "invoice.payment_succeeded":
|
||||||
|
await _handle_payment_succeeded(db, payload)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
# Roll back the StripeEvent insert + any partial handler mutations
|
||||||
|
# so Stripe's retry can re-run cleanly.
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -238,7 +298,7 @@ async def _handle_checkout_completed(db: AsyncSession, payload: dict):
|
|||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if pb is not None:
|
if pb is not None:
|
||||||
sub.plan = pb.plan
|
sub.plan = pb.plan
|
||||||
await db.commit()
|
# No commit — apply_subscription_event commits once for the full event.
|
||||||
|
|
||||||
|
|
||||||
async def _handle_subscription_updated(db: AsyncSession, payload: dict):
|
async def _handle_subscription_updated(db: AsyncSession, payload: dict):
|
||||||
@@ -253,7 +313,7 @@ async def _handle_subscription_updated(db: AsyncSession, payload: dict):
|
|||||||
sub.current_period_end = datetime.fromtimestamp(obj["current_period_end"], tz=timezone.utc)
|
sub.current_period_end = datetime.fromtimestamp(obj["current_period_end"], tz=timezone.utc)
|
||||||
sub.cancel_at_period_end = obj.get("cancel_at_period_end", False)
|
sub.cancel_at_period_end = obj.get("cancel_at_period_end", False)
|
||||||
sub.seat_limit = obj["items"]["data"][0]["quantity"]
|
sub.seat_limit = obj["items"]["data"][0]["quantity"]
|
||||||
await db.commit()
|
# No commit — apply_subscription_event commits once for the full event.
|
||||||
|
|
||||||
|
|
||||||
async def _handle_subscription_deleted(db: AsyncSession, payload: dict):
|
async def _handle_subscription_deleted(db: AsyncSession, payload: dict):
|
||||||
@@ -264,7 +324,7 @@ async def _handle_subscription_deleted(db: AsyncSession, payload: dict):
|
|||||||
if sub is None:
|
if sub is None:
|
||||||
return
|
return
|
||||||
sub.status = "canceled"
|
sub.status = "canceled"
|
||||||
await db.commit()
|
# No commit — apply_subscription_event commits once for the full event.
|
||||||
|
|
||||||
|
|
||||||
async def _handle_payment_failed(db: AsyncSession, payload: dict):
|
async def _handle_payment_failed(db: AsyncSession, payload: dict):
|
||||||
@@ -278,7 +338,7 @@ async def _handle_payment_failed(db: AsyncSession, payload: dict):
|
|||||||
if sub is None:
|
if sub is None:
|
||||||
return
|
return
|
||||||
sub.status = "past_due"
|
sub.status = "past_due"
|
||||||
await db.commit()
|
# No commit — apply_subscription_event commits once for the full event.
|
||||||
|
|
||||||
|
|
||||||
async def _handle_payment_succeeded(db: AsyncSession, payload: dict):
|
async def _handle_payment_succeeded(db: AsyncSession, payload: dict):
|
||||||
@@ -293,4 +353,4 @@ async def _handle_payment_succeeded(db: AsyncSession, payload: dict):
|
|||||||
return
|
return
|
||||||
if sub.status == "past_due":
|
if sub.status == "past_due":
|
||||||
sub.status = "active"
|
sub.status = "active"
|
||||||
await db.commit()
|
# No commit — apply_subscription_event commits once for the full event.
|
||||||
|
|||||||
290
backend/tests/test_account_invite_lookup.py
Normal file
290
backend/tests/test_account_invite_lookup.py
Normal 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"
|
||||||
@@ -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
|
||||||
|
|||||||
43
backend/tests/test_beta_signup_redirect.py
Normal file
43
backend/tests/test_beta_signup_redirect.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Integration tests for the legacy /beta-signup redirect.
|
||||||
|
|
||||||
|
Phase 2 retires the public beta-signup form in favor of the regular
|
||||||
|
register flow. The endpoint stays mounted but answers with a 307 to
|
||||||
|
the absolute frontend `/register?from=beta` URL so any external links
|
||||||
|
keep working. There is no `beta_signup` table to migrate — the old
|
||||||
|
endpoint only fired an email notification — so this test only covers
|
||||||
|
the redirect contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beta_signup_redirects_to_register(client, monkeypatch):
|
||||||
|
"""POST /beta-signup returns 307 to the absolute frontend register URL."""
|
||||||
|
monkeypatch.setattr(settings, "FRONTEND_URL", "https://example.com")
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/beta-signup",
|
||||||
|
json={"email": "anyone@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 307, response.text
|
||||||
|
assert (
|
||||||
|
response.headers["location"]
|
||||||
|
== "https://example.com/register?from=beta"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beta_signup_redirect_ignores_body(client, monkeypatch):
|
||||||
|
"""Redirect fires regardless of payload — no validation on the legacy route."""
|
||||||
|
monkeypatch.setattr(settings, "FRONTEND_URL", "https://example.com")
|
||||||
|
|
||||||
|
response = await client.post("/api/v1/beta-signup", json={})
|
||||||
|
assert response.status_code == 307
|
||||||
|
assert (
|
||||||
|
response.headers["location"]
|
||||||
|
== "https://example.com/register?from=beta"
|
||||||
|
)
|
||||||
83
backend/tests/test_billing_portal.py
Normal file
83
backend/tests/test_billing_portal.py
Normal 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"
|
||||||
100
backend/tests/test_config_public.py
Normal file
100
backend/tests/test_config_public.py
Normal 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
|
||||||
@@ -2,8 +2,10 @@ import uuid
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from app.core.security import decode_token, hash_token
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.oauth_identity import OAuthIdentity
|
from app.models.oauth_identity import OAuthIdentity
|
||||||
|
from app.models.refresh_token import RefreshToken
|
||||||
from app.models.subscription import Subscription
|
from app.models.subscription import Subscription
|
||||||
from app.services.oauth_providers import OAuthProfile
|
from app.services.oauth_providers import OAuthProfile
|
||||||
|
|
||||||
@@ -118,3 +120,77 @@ async def test_microsoft_callback_creates_user(client, test_db, monkeypatch):
|
|||||||
select(OAuthIdentity).where(OAuthIdentity.user_id == user.id)
|
select(OAuthIdentity).where(OAuthIdentity.user_id == user.id)
|
||||||
)).scalar_one()
|
)).scalar_one()
|
||||||
assert identity.provider == "microsoft"
|
assert identity.provider == "microsoft"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oauth_google_callback_stores_refresh_token_jti(
|
||||||
|
client, test_db, monkeypatch
|
||||||
|
):
|
||||||
|
"""A successful Google OAuth callback must persist the refresh-token JTI
|
||||||
|
in the refresh_tokens table — otherwise /auth/refresh rejects it."""
|
||||||
|
from app.core.config import settings
|
||||||
|
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
|
||||||
|
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
|
||||||
|
|
||||||
|
profile = OAuthProfile(
|
||||||
|
provider_subject="google_subject_jti_test",
|
||||||
|
email="jtitest@example.com",
|
||||||
|
name="JTI Test",
|
||||||
|
)
|
||||||
|
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"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.json()
|
||||||
|
body = response.json()
|
||||||
|
refresh_token_str = body["refresh_token"]
|
||||||
|
|
||||||
|
payload = decode_token(refresh_token_str)
|
||||||
|
assert payload is not None
|
||||||
|
jti = payload["jti"]
|
||||||
|
token_hash = hash_token(jti)
|
||||||
|
|
||||||
|
user = (await test_db.execute(
|
||||||
|
select(User).where(User.email == "jtitest@example.com")
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
stored = (await test_db.execute(
|
||||||
|
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
assert stored is not None, "OAuth callback did not persist refresh-token JTI"
|
||||||
|
assert stored.user_id == user.id
|
||||||
|
assert stored.revoked_at is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oauth_refresh_works_after_oauth_signup(
|
||||||
|
client, test_db, monkeypatch
|
||||||
|
):
|
||||||
|
"""End-to-end: OAuth callback issues a refresh token; calling /auth/refresh
|
||||||
|
with that token must succeed (not be rejected as revoked)."""
|
||||||
|
from app.core.config import settings
|
||||||
|
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
|
||||||
|
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
|
||||||
|
|
||||||
|
profile = OAuthProfile(
|
||||||
|
provider_subject="google_subject_refresh_test",
|
||||||
|
email="refresh-after-oauth@example.com",
|
||||||
|
name="Refresh After OAuth",
|
||||||
|
)
|
||||||
|
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
|
||||||
|
callback_resp = await client.post(
|
||||||
|
"/api/v1/auth/google/callback", json={"code": "auth_code_xyz"}
|
||||||
|
)
|
||||||
|
assert callback_resp.status_code == 200, callback_resp.json()
|
||||||
|
refresh_token_str = callback_resp.json()["refresh_token"]
|
||||||
|
|
||||||
|
refresh_resp = await client.post(
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
headers={"Authorization": f"Bearer {refresh_token_str}"},
|
||||||
|
)
|
||||||
|
assert refresh_resp.status_code == 200, refresh_resp.json()
|
||||||
|
refreshed = refresh_resp.json()
|
||||||
|
assert refreshed["access_token"]
|
||||||
|
assert refreshed["refresh_token"]
|
||||||
|
# Token rotation: new refresh token differs from the original.
|
||||||
|
assert refreshed["refresh_token"] != refresh_token_str
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
149
backend/tests/test_onboarding_step.py
Normal file
149
backend/tests/test_onboarding_step.py
Normal 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
|
||||||
132
backend/tests/test_plans_public.py
Normal file
132
backend/tests/test_plans_public.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""Integration tests for the public plans endpoint.
|
||||||
|
|
||||||
|
Covers GET /api/v1/plans/public — the marketing /pricing page data source.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from app.models.plan_billing import PlanBilling
|
||||||
|
from app.models.plan_limits import PlanLimits
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None:
|
||||||
|
"""Ensure a plan_limits row exists for the given plan name."""
|
||||||
|
existing = await test_db.get(PlanLimits, plan)
|
||||||
|
if existing is None:
|
||||||
|
test_db.add(
|
||||||
|
PlanLimits(
|
||||||
|
plan=plan,
|
||||||
|
max_trees=None,
|
||||||
|
max_sessions_per_month=None,
|
||||||
|
max_users=max_users,
|
||||||
|
custom_branding=False,
|
||||||
|
priority_support=False,
|
||||||
|
export_formats=["markdown", "text"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPlansPublic:
|
||||||
|
"""GET /api/v1/plans/public — anonymous, no auth."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_plans_public_returns_only_is_public_rows(
|
||||||
|
self, client: AsyncClient, test_db
|
||||||
|
):
|
||||||
|
"""Rows with is_public=False or is_archived=True must NOT appear."""
|
||||||
|
# Wipe any existing billing rows so this test owns the fixture state.
|
||||||
|
await test_db.execute(delete(PlanBilling))
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
await _seed_plan_limits(test_db, "starter", 3)
|
||||||
|
await _seed_plan_limits(test_db, "pro", 10)
|
||||||
|
await _seed_plan_limits(test_db, "internal", None)
|
||||||
|
await _seed_plan_limits(test_db, "legacy", 5)
|
||||||
|
|
||||||
|
test_db.add_all(
|
||||||
|
[
|
||||||
|
PlanBilling(
|
||||||
|
plan="starter",
|
||||||
|
display_name="Starter",
|
||||||
|
monthly_price_cents=1900,
|
||||||
|
is_public=True,
|
||||||
|
is_archived=False,
|
||||||
|
sort_order=10,
|
||||||
|
),
|
||||||
|
PlanBilling(
|
||||||
|
plan="pro",
|
||||||
|
display_name="Pro",
|
||||||
|
monthly_price_cents=4900,
|
||||||
|
is_public=True,
|
||||||
|
is_archived=False,
|
||||||
|
sort_order=20,
|
||||||
|
),
|
||||||
|
PlanBilling(
|
||||||
|
plan="internal",
|
||||||
|
display_name="Internal",
|
||||||
|
is_public=False, # hidden
|
||||||
|
is_archived=False,
|
||||||
|
sort_order=30,
|
||||||
|
),
|
||||||
|
PlanBilling(
|
||||||
|
plan="legacy",
|
||||||
|
display_name="Legacy",
|
||||||
|
is_public=True,
|
||||||
|
is_archived=True, # archived
|
||||||
|
sort_order=40,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/plans/public")
|
||||||
|
assert response.status_code == 200
|
||||||
|
plans = response.json()
|
||||||
|
plan_names = {p["plan"] for p in plans}
|
||||||
|
|
||||||
|
assert "starter" in plan_names
|
||||||
|
assert "pro" in plan_names
|
||||||
|
assert "internal" not in plan_names
|
||||||
|
assert "legacy" not in plan_names
|
||||||
|
|
||||||
|
# Schema sanity check
|
||||||
|
starter = next(p for p in plans if p["plan"] == "starter")
|
||||||
|
assert starter["display_name"] == "Starter"
|
||||||
|
assert starter["monthly_price_cents"] == 1900
|
||||||
|
assert starter["max_seats"] == 3
|
||||||
|
assert starter["is_public"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_plans_public_orders_by_sort_order_then_plan(
|
||||||
|
self, client: AsyncClient, test_db
|
||||||
|
):
|
||||||
|
"""Result must be ordered by sort_order ASC, then plan name ASC."""
|
||||||
|
await test_db.execute(delete(PlanBilling))
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
# plan_limits rows for FK satisfaction
|
||||||
|
for name in ("alpha", "bravo", "charlie", "delta"):
|
||||||
|
await _seed_plan_limits(test_db, name, None)
|
||||||
|
|
||||||
|
# Two with sort_order=10 (charlie should come before delta by plan ASC),
|
||||||
|
# one with sort_order=5 (alpha first overall),
|
||||||
|
# one with sort_order=20 (bravo last).
|
||||||
|
test_db.add_all(
|
||||||
|
[
|
||||||
|
PlanBilling(plan="charlie", display_name="C", sort_order=10, is_public=True, is_archived=False),
|
||||||
|
PlanBilling(plan="delta", display_name="D", sort_order=10, is_public=True, is_archived=False),
|
||||||
|
PlanBilling(plan="alpha", display_name="A", sort_order=5, is_public=True, is_archived=False),
|
||||||
|
PlanBilling(plan="bravo", display_name="B", sort_order=20, is_public=True, is_archived=False),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/plans/public")
|
||||||
|
assert response.status_code == 200
|
||||||
|
ordered = [p["plan"] for p in response.json()]
|
||||||
|
assert ordered == ["alpha", "charlie", "delta", "bravo"]
|
||||||
134
backend/tests/test_sales_leads.py
Normal file
134
backend/tests/test_sales_leads.py
Normal 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
|
||||||
@@ -142,3 +142,178 @@ async def test_webhook_idempotency(
|
|||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
assert r1.json()["applied"] is True
|
assert r1.json()["applied"] is True
|
||||||
assert r2.json()["applied"] is False
|
assert r2.json()["applied"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Atomic-idempotency regression tests
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_event_handler_failure_does_not_persist_idempotency_mark(
|
||||||
|
test_db, test_user,
|
||||||
|
):
|
||||||
|
"""If the handler raises, the StripeEvent row must NOT be persisted —
|
||||||
|
otherwise Stripe's retry will be silently dropped as a duplicate and the
|
||||||
|
subscription state will desync from Stripe."""
|
||||||
|
from app.services.billing import BillingService
|
||||||
|
from app.models.stripe_event import StripeEvent
|
||||||
|
|
||||||
|
event_id = "evt_handler_fail_1"
|
||||||
|
payload = {"data": {"object": {
|
||||||
|
"id": "sub_doesnotmatter",
|
||||||
|
"status": "active",
|
||||||
|
"current_period_start": 1714521600,
|
||||||
|
"current_period_end": 1717113600,
|
||||||
|
"items": {"data": [{"quantity": 1}]},
|
||||||
|
"cancel_at_period_end": False,
|
||||||
|
}}}
|
||||||
|
|
||||||
|
boom = RuntimeError("simulated handler failure")
|
||||||
|
with patch(
|
||||||
|
"app.services.billing._handle_subscription_updated",
|
||||||
|
side_effect=boom,
|
||||||
|
):
|
||||||
|
with pytest.raises(RuntimeError, match="simulated handler failure"):
|
||||||
|
await BillingService.apply_subscription_event(
|
||||||
|
test_db,
|
||||||
|
event_id=event_id,
|
||||||
|
event_type="customer.subscription.updated",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The StripeEvent row must not exist — handler raised, the entire
|
||||||
|
# transaction (idempotency mark + partial mutations) was rolled back.
|
||||||
|
row = (await test_db.execute(
|
||||||
|
select(StripeEvent).where(StripeEvent.id == event_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
assert row is None, (
|
||||||
|
"StripeEvent row was persisted despite handler failure — "
|
||||||
|
"Stripe retry will be silently dropped"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_event_retry_after_failure_succeeds(
|
||||||
|
test_db, test_user,
|
||||||
|
):
|
||||||
|
"""A failed first attempt followed by a successful retry must apply state.
|
||||||
|
This is the core Stripe webhook retry contract."""
|
||||||
|
from app.services.billing import BillingService
|
||||||
|
from app.models.stripe_event import StripeEvent
|
||||||
|
|
||||||
|
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||||
|
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
|
||||||
|
test_db.add(Subscription(
|
||||||
|
account_id=account_id, plan="pro", status="trialing",
|
||||||
|
stripe_subscription_id="sub_retry",
|
||||||
|
))
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
event_id = "evt_retry_1"
|
||||||
|
payload = {"data": {"object": {
|
||||||
|
"id": "sub_retry",
|
||||||
|
"status": "active",
|
||||||
|
"current_period_start": 1714521600,
|
||||||
|
"current_period_end": 1717113600,
|
||||||
|
"items": {"data": [{"quantity": 3}]},
|
||||||
|
"cancel_at_period_end": False,
|
||||||
|
}}}
|
||||||
|
|
||||||
|
# First attempt — handler raises mid-flight.
|
||||||
|
with patch(
|
||||||
|
"app.services.billing._handle_subscription_updated",
|
||||||
|
side_effect=RuntimeError("transient blip"),
|
||||||
|
):
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
await BillingService.apply_subscription_event(
|
||||||
|
test_db,
|
||||||
|
event_id=event_id,
|
||||||
|
event_type="customer.subscription.updated",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
# No idempotency mark, sub still trialing.
|
||||||
|
row = (await test_db.execute(
|
||||||
|
select(StripeEvent).where(StripeEvent.id == event_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
assert row is None
|
||||||
|
sub = (await test_db.execute(
|
||||||
|
select(Subscription).where(Subscription.account_id == account_id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert sub.status == "trialing"
|
||||||
|
|
||||||
|
# Second attempt — same event_id, handler succeeds.
|
||||||
|
applied = await BillingService.apply_subscription_event(
|
||||||
|
test_db,
|
||||||
|
event_id=event_id,
|
||||||
|
event_type="customer.subscription.updated",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
assert applied is True
|
||||||
|
|
||||||
|
# Idempotency mark now persisted, sub state reconciled.
|
||||||
|
row = (await test_db.execute(
|
||||||
|
select(StripeEvent).where(StripeEvent.id == event_id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert row.id == event_id
|
||||||
|
await test_db.refresh(sub)
|
||||||
|
assert sub.status == "active"
|
||||||
|
assert sub.seat_limit == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_event_duplicate_event_id_skips(
|
||||||
|
test_db, test_user,
|
||||||
|
):
|
||||||
|
"""Two successful invocations with the same event_id must not double-apply.
|
||||||
|
Second call returns False; mutations are not repeated."""
|
||||||
|
from app.services.billing import BillingService
|
||||||
|
|
||||||
|
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||||
|
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
|
||||||
|
test_db.add(Subscription(
|
||||||
|
account_id=account_id, plan="pro", status="trialing",
|
||||||
|
stripe_subscription_id="sub_dup",
|
||||||
|
))
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
event_id = "evt_dedupe_1"
|
||||||
|
payload = {"data": {"object": {
|
||||||
|
"id": "sub_dup",
|
||||||
|
"status": "active",
|
||||||
|
"current_period_start": 1714521600,
|
||||||
|
"current_period_end": 1717113600,
|
||||||
|
"items": {"data": [{"quantity": 7}]},
|
||||||
|
"cancel_at_period_end": False,
|
||||||
|
}}}
|
||||||
|
|
||||||
|
applied1 = await BillingService.apply_subscription_event(
|
||||||
|
test_db,
|
||||||
|
event_id=event_id,
|
||||||
|
event_type="customer.subscription.updated",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
assert applied1 is True
|
||||||
|
|
||||||
|
sub = (await test_db.execute(
|
||||||
|
select(Subscription).where(Subscription.account_id == account_id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert sub.status == "active"
|
||||||
|
assert sub.seat_limit == 7
|
||||||
|
|
||||||
|
# Mutate locally so we can prove the second call doesn't re-run the handler.
|
||||||
|
sub.seat_limit = 99
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
applied2 = await BillingService.apply_subscription_event(
|
||||||
|
test_db,
|
||||||
|
event_id=event_id,
|
||||||
|
event_type="customer.subscription.updated",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
assert applied2 is False
|
||||||
|
|
||||||
|
await test_db.refresh(sub)
|
||||||
|
# Handler did NOT run again — our local mutation is preserved.
|
||||||
|
assert sub.seat_limit == 99
|
||||||
|
|||||||
@@ -3,3 +3,26 @@ 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
|
||||||
|
|
||||||
|
# Calendly link surfaced on the /contact-sales confirmation screen. When unset,
|
||||||
|
# the "Want to skip ahead?" block is hidden. Vite bakes at build time, so prod
|
||||||
|
# requires ARG+ENV in frontend/Dockerfile.
|
||||||
|
VITE_CALENDLY_URL=
|
||||||
|
|||||||
@@ -17,10 +17,22 @@ 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
|
||||||
|
ARG VITE_CALENDLY_URL
|
||||||
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
|
||||||
|
ENV VITE_CALENDLY_URL=$VITE_CALENDLY_URL
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
79
frontend/src/api/billing.ts
Normal file
79
frontend/src/api/billing.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { AxiosError } from 'axios'
|
||||||
|
|
||||||
|
import apiClient from './client'
|
||||||
|
import {
|
||||||
|
BillingPortalError,
|
||||||
|
type BillingPortalErrorCode,
|
||||||
|
type BillingPortalSessionResponse,
|
||||||
|
type BillingStateApiResponse,
|
||||||
|
type BillingStatePayload,
|
||||||
|
type CheckoutSessionRequest,
|
||||||
|
type CheckoutSessionResponse,
|
||||||
|
} from '@/types/billing'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a Stripe Customer Portal session URL for the active account.
|
||||||
|
*
|
||||||
|
* Throws a typed `BillingPortalError` when:
|
||||||
|
* - HTTP 503 → `stripe_not_configured` (server-side Stripe is disabled)
|
||||||
|
* - HTTP 400 + `error: 'no_stripe_customer'` → account hasn't been billed yet
|
||||||
|
*
|
||||||
|
* Other errors (5xx, network) propagate as the underlying AxiosError.
|
||||||
|
*/
|
||||||
|
async getPortalSession(): Promise<BillingPortalSessionResponse> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<BillingPortalSessionResponse>(
|
||||||
|
'/billing/portal-session',
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError && err.response) {
|
||||||
|
const { status, data } = err.response
|
||||||
|
const code: BillingPortalErrorCode | null =
|
||||||
|
status === 503
|
||||||
|
? 'stripe_not_configured'
|
||||||
|
: status === 400 && data?.detail?.error === 'no_stripe_customer'
|
||||||
|
? 'no_stripe_customer'
|
||||||
|
: null
|
||||||
|
if (code) {
|
||||||
|
throw new BillingPortalError(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCheckoutSession(
|
||||||
|
payload: CheckoutSessionRequest,
|
||||||
|
): Promise<CheckoutSessionResponse> {
|
||||||
|
const response = await apiClient.post<CheckoutSessionResponse>(
|
||||||
|
'/billing/checkout-session',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default billingApi
|
||||||
15
frontend/src/api/config.ts
Normal file
15
frontend/src/api/config.ts
Normal 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
|
||||||
@@ -9,6 +9,16 @@ 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 plansApi } from './plans'
|
||||||
|
export type { PublicPlanResponse } from './plans'
|
||||||
|
export { default as salesApi } from './sales'
|
||||||
|
export type {
|
||||||
|
SalesLeadCreatePayload,
|
||||||
|
SalesLeadCreateResponse,
|
||||||
|
SalesLeadSource,
|
||||||
|
} from './sales'
|
||||||
|
export { default as usageApi } from './usage'
|
||||||
export { default as adminApi } from './admin'
|
export { 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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
22
frontend/src/api/plans.ts
Normal file
22
frontend/src/api/plans.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
export interface PublicPlanResponse {
|
||||||
|
plan: string
|
||||||
|
display_name: string
|
||||||
|
description: string | null
|
||||||
|
monthly_price_cents: number | null
|
||||||
|
annual_price_cents: number | null
|
||||||
|
max_seats: number | null
|
||||||
|
sort_order: number
|
||||||
|
is_public: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const plansApi = {
|
||||||
|
/** Public plan catalog for the marketing /pricing page. No auth. */
|
||||||
|
async getPublic(): Promise<PublicPlanResponse[]> {
|
||||||
|
const response = await apiClient.get<PublicPlanResponse[]>('/plans/public')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default plansApi
|
||||||
32
frontend/src/api/sales.ts
Normal file
32
frontend/src/api/sales.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
export type SalesLeadSource = 'pricing_page' | 'register_footer' | 'landing_page'
|
||||||
|
|
||||||
|
export interface SalesLeadCreatePayload {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
company: string
|
||||||
|
team_size?: string
|
||||||
|
message?: string
|
||||||
|
source: SalesLeadSource
|
||||||
|
posthog_distinct_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesLeadCreateResponse {
|
||||||
|
id: string
|
||||||
|
status: 'received'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const salesApi = {
|
||||||
|
/**
|
||||||
|
* Public Talk-to-Sales submission. No auth required. Rate-limited per IP
|
||||||
|
* server-side (5/hour). Server emits PostHog `talk_to_sales_form_submitted`
|
||||||
|
* — frontend should NOT also fire this event.
|
||||||
|
*/
|
||||||
|
async createLead(payload: SalesLeadCreatePayload): Promise<SalesLeadCreateResponse> {
|
||||||
|
const response = await apiClient.post<SalesLeadCreateResponse>('/sales-leads', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default salesApi
|
||||||
23
frontend/src/api/usage.ts
Normal file
23
frontend/src/api/usage.ts
Normal 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
|
||||||
56
frontend/src/components/common/EmailVerificationGate.tsx
Normal file
56
frontend/src/components/common/EmailVerificationGate.tsx
Normal 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 1–6 unverified renders
|
||||||
|
* children and Day 7+ renders the wall.
|
||||||
|
*/
|
||||||
|
gracePeriodDays?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Whole days elapsed between two ISO timestamps (floored). */
|
||||||
|
function daysSince(iso: string, now: number = Date.now()): number {
|
||||||
|
const created = Date.parse(iso)
|
||||||
|
if (Number.isNaN(created)) {
|
||||||
|
// Defensive: bad timestamp — treat as just-signed-up so we don't
|
||||||
|
// accidentally lock anyone out.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return Math.floor((now - created) / MS_PER_DAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps protected content. While the current user is past the grace period
|
||||||
|
* without having verified their email, renders `<EmailVerificationWall />`
|
||||||
|
* instead of children.
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - No user (signed out): renders children (let route guards handle auth).
|
||||||
|
* - User has `email_verified_at`: renders children.
|
||||||
|
* - Day 1–6 unverified: renders children (banner is shown elsewhere).
|
||||||
|
* - Day 7+ unverified: renders the wall.
|
||||||
|
*/
|
||||||
|
export function EmailVerificationGate({
|
||||||
|
children,
|
||||||
|
gracePeriodDays = 6,
|
||||||
|
}: EmailVerificationGateProps) {
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
|
||||||
|
if (!user) return <>{children}</>
|
||||||
|
if (user.email_verified_at) return <>{children}</>
|
||||||
|
|
||||||
|
const elapsed = daysSince(user.created_at)
|
||||||
|
if (elapsed > gracePeriodDays) {
|
||||||
|
return <EmailVerificationWall />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmailVerificationGate
|
||||||
90
frontend/src/components/common/EmailVerificationWall.tsx
Normal file
90
frontend/src/components/common/EmailVerificationWall.tsx
Normal 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
|
||||||
42
frontend/src/components/common/FeatureGate.tsx
Normal file
42
frontend/src/components/common/FeatureGate.tsx
Normal 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
|
||||||
111
frontend/src/components/common/UpgradePrompt.tsx
Normal file
111
frontend/src/components/common/UpgradePrompt.tsx
Normal 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
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
169
frontend/src/components/dashboard/NextStepCard.tsx
Normal file
169
frontend/src/components/dashboard/NextStepCard.tsx
Normal 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
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
136
frontend/src/components/dashboard/SetupChecklist.tsx
Normal file
136
frontend/src/components/dashboard/SetupChecklist.tsx
Normal 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
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
147
frontend/src/components/layout/TrialPill.tsx
Normal file
147
frontend/src/components/layout/TrialPill.tsx
Normal 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
|
||||||
123
frontend/src/components/layout/__tests__/AppLayout.test.tsx
Normal file
123
frontend/src/components/layout/__tests__/AppLayout.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
155
frontend/src/components/layout/__tests__/TrialPill.test.tsx
Normal file
155
frontend/src/components/layout/__tests__/TrialPill.test.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
99
frontend/src/hooks/useAppConfig.ts
Normal file
99
frontend/src/hooks/useAppConfig.ts
Normal 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
|
||||||
32
frontend/src/hooks/useBillingPoll.ts
Normal file
32
frontend/src/hooks/useBillingPoll.ts
Normal 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
|
||||||
44
frontend/src/hooks/useFeature.test.ts
Normal file
44
frontend/src/hooks/useFeature.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
16
frontend/src/hooks/useFeature.ts
Normal file
16
frontend/src/hooks/useFeature.ts
Normal 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
|
||||||
112
frontend/src/hooks/useFeatureLimit.test.ts
Normal file
112
frontend/src/hooks/useFeatureLimit.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
125
frontend/src/hooks/useFeatureLimit.ts
Normal file
125
frontend/src/hooks/useFeatureLimit.ts
Normal 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
|
||||||
27
frontend/src/hooks/useOnboardingStatus.ts
Normal file
27
frontend/src/hooks/useOnboardingStatus.ts
Normal 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
|
||||||
131
frontend/src/hooks/useTrialBanner.test.ts
Normal file
131
frontend/src/hooks/useTrialBanner.test.ts
Normal 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 (1–3 days inclusive of 1).
|
||||||
|
setSubscription({
|
||||||
|
status: 'trialing',
|
||||||
|
current_period_end: '2026-05-07T00:00:00Z',
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useTrialBanner())
|
||||||
|
expect(result.current.stage).toBe('warning')
|
||||||
|
expect(result.current.daysRemaining).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trialing <1 day remaining -> urgent', () => {
|
||||||
|
// 12 hours from frozen now -> Math.ceil(0.5) = 1 day.
|
||||||
|
setSubscription({
|
||||||
|
status: 'trialing',
|
||||||
|
current_period_end: '2026-05-06T12:00:00Z',
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useTrialBanner())
|
||||||
|
expect(result.current.stage).toBe('urgent')
|
||||||
|
expect(result.current.daysRemaining).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trialing past period_end -> expired', () => {
|
||||||
|
setSubscription({
|
||||||
|
status: 'trialing',
|
||||||
|
current_period_end: '2026-05-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useTrialBanner())
|
||||||
|
expect(result.current.stage).toBe('expired')
|
||||||
|
expect(result.current.daysRemaining).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
86
frontend/src/hooks/useTrialBanner.ts
Normal file
86
frontend/src/hooks/useTrialBanner.ts
Normal 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
|
||||||
|
// 1–3 days → warning (inclusive of exactly 1)
|
||||||
|
// <1 day → urgent
|
||||||
|
let stage: TrialBannerStage = 'pristine'
|
||||||
|
if (fractionalDays < 1) stage = 'urgent'
|
||||||
|
else if (fractionalDays <= 3) stage = 'warning'
|
||||||
|
return { stage, daysRemaining }
|
||||||
|
}
|
||||||
|
case 'incomplete':
|
||||||
|
// Not in the spec's matrix; surface as null so the banner stays hidden
|
||||||
|
// until checkout actually resolves.
|
||||||
|
return { stage: null, daysRemaining: null }
|
||||||
|
default: {
|
||||||
|
// Defensive fallthrough for unknown statuses — keep the banner hidden.
|
||||||
|
const _exhaustive: never = subscription.status as never
|
||||||
|
void _exhaustive
|
||||||
|
return { stage: null, daysRemaining: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useTrialBanner
|
||||||
53
frontend/src/lib/oauthState.test.ts
Normal file
53
frontend/src/lib/oauthState.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
61
frontend/src/lib/oauthState.ts
Normal file
61
frontend/src/lib/oauthState.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
371
frontend/src/pages/AcceptInvitePage.tsx
Normal file
371
frontend/src/pages/AcceptInvitePage.tsx
Normal 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
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Clock,
|
Clock,
|
||||||
Copy,
|
Copy,
|
||||||
|
CreditCard,
|
||||||
Crown,
|
Crown,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -598,6 +599,12 @@ export function AccountSettingsPage() {
|
|||||||
title="Profile"
|
title="Profile"
|
||||||
description="Your name, email, and personal preferences"
|
description="Your name, email, and personal preferences"
|
||||||
/>
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
to="/account/billing"
|
||||||
|
icon={<CreditCard className="h-4 w-4" />}
|
||||||
|
title="Billing"
|
||||||
|
description="Subscription, payment method, and invoices"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAccountOwner && (
|
{isAccountOwner && (
|
||||||
|
|||||||
396
frontend/src/pages/ContactSalesPage.tsx
Normal file
396
frontend/src/pages/ContactSalesPage.tsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import { useMemo, useState, type FormEvent } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { salesApi, type SalesLeadSource } from '@/api/sales'
|
||||||
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
|
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||||
|
import '@/styles/landing.css'
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Source detection
|
||||||
|
*
|
||||||
|
* The backend `/sales-leads` endpoint requires a `source` enum. We classify
|
||||||
|
* by document.referrer: visitors landing on /contact-sales after viewing the
|
||||||
|
* pricing page get tagged `pricing_page`; everything else is `landing_page`.
|
||||||
|
* `register_footer` is reserved for the (future) sign-up footer CTA.
|
||||||
|
*
|
||||||
|
* Acceptable for v1 — server-side PostHog event uses this same `source` value.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
function detectSource(): SalesLeadSource {
|
||||||
|
if (typeof document === 'undefined') return 'landing_page'
|
||||||
|
const ref = document.referrer || ''
|
||||||
|
if (ref.includes('/pricing')) return 'pricing_page'
|
||||||
|
return 'landing_page'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEAM_SIZE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||||
|
{ value: '', label: 'Select team size' },
|
||||||
|
{ value: '1-2', label: '1–2' },
|
||||||
|
{ value: '3-5', label: '3–5' },
|
||||||
|
{ value: '6-10', label: '6–10' },
|
||||||
|
{ value: '11-25', label: '11–25' },
|
||||||
|
{ value: '26+', label: 'More than 26' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
company: string
|
||||||
|
team_size: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL: FormState = {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
company: '',
|
||||||
|
team_size: '',
|
||||||
|
message: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContactSalesNotFound() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="contact-sales-not-found"
|
||||||
|
style={{
|
||||||
|
minHeight: '60vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>Page not found</h1>
|
||||||
|
<p style={{ color: '#9198a8' }}>This page is not available.</p>
|
||||||
|
<Link to="/login" style={{ color: '#60a5fa', marginTop: '1rem' }}>
|
||||||
|
Go to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactSalesPage() {
|
||||||
|
const appConfig = useAppConfig()
|
||||||
|
const [form, setForm] = useState<FormState>(INITIAL)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const calendlyUrl = useMemo(() => {
|
||||||
|
const raw = import.meta.env.VITE_CALENDLY_URL
|
||||||
|
return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : ''
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Self-serve disabled: 404. (Same pattern as PricingPage — done after hooks.)
|
||||||
|
if (!appConfig.isLoading && !appConfig.self_serve_enabled) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageMeta title="Page not found" />
|
||||||
|
<ContactSalesNotFound />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange =
|
||||||
|
(field: keyof FormState) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: e.target.value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (submitting) return
|
||||||
|
|
||||||
|
const name = form.name.trim()
|
||||||
|
const email = form.email.trim()
|
||||||
|
const company = form.company.trim()
|
||||||
|
|
||||||
|
if (!name || !email || !company) {
|
||||||
|
setError('Please fill in name, work email, and company.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
setError('Enter a valid work email address.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await salesApi.createLead({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
company,
|
||||||
|
team_size: form.team_size || undefined,
|
||||||
|
message: form.message.trim() || undefined,
|
||||||
|
source: detectSource(),
|
||||||
|
})
|
||||||
|
setSubmitted(true)
|
||||||
|
} catch {
|
||||||
|
// The backend may rate-limit (429) or reject for validation; surface a
|
||||||
|
// generic message and allow retry. Don't leak internal errors.
|
||||||
|
setError('Something went wrong. Please try again or email hello@resolutionflow.com.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="landing-page">
|
||||||
|
<PageMeta
|
||||||
|
title="Talk to Sales"
|
||||||
|
description="Get in touch with the ResolutionFlow team about Enterprise plans, custom seats, SSO, and onboarding for your MSP."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main
|
||||||
|
className="landing-main"
|
||||||
|
style={{ paddingTop: '4rem', paddingBottom: '4rem' }}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
maxWidth: '640px',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '3rem 1.5rem 1.5rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
color: 'var(--lp-text-heading)',
|
||||||
|
fontSize: 'clamp(1.75rem, 3.5vw, 2.5rem)',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.15,
|
||||||
|
margin: '0 0 0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Talk to Sales
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: 'var(--lp-text-body)',
|
||||||
|
fontSize: '1.05rem',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tell us about your MSP. We’ll reach out within 1 business day.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
maxWidth: '560px',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '1rem 1.5rem 3rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitted ? (
|
||||||
|
<div
|
||||||
|
data-testid="contact-sales-confirmation"
|
||||||
|
style={{
|
||||||
|
background: 'var(--lp-card)',
|
||||||
|
border: '1px solid var(--lp-border)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '2rem 1.75rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: 'var(--lp-text-heading)',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: '0 0 0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Thanks — we’ll reach out within 1 business day.
|
||||||
|
</h2>
|
||||||
|
{calendlyUrl && (
|
||||||
|
<div data-testid="calendly-block">
|
||||||
|
<p style={{ color: 'var(--lp-text-body)', margin: '0 0 1rem' }}>
|
||||||
|
Want to skip ahead?
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
data-testid="calendly-link"
|
||||||
|
href={calendlyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.65rem 1.25rem',
|
||||||
|
background: 'var(--lp-accent)',
|
||||||
|
color: '#0d0f15',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Book a time
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
data-testid="contact-sales-form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
noValidate
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
background: 'var(--lp-card)',
|
||||||
|
border: '1px solid var(--lp-border)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '1.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Name" htmlFor="cs-name" required>
|
||||||
|
<input
|
||||||
|
id="cs-name"
|
||||||
|
data-testid="cs-name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={handleChange('name')}
|
||||||
|
autoComplete="name"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Work email" htmlFor="cs-email" required>
|
||||||
|
<input
|
||||||
|
id="cs-email"
|
||||||
|
data-testid="cs-email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={form.email}
|
||||||
|
onChange={handleChange('email')}
|
||||||
|
autoComplete="email"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Company" htmlFor="cs-company" required>
|
||||||
|
<input
|
||||||
|
id="cs-company"
|
||||||
|
data-testid="cs-company"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.company}
|
||||||
|
onChange={handleChange('company')}
|
||||||
|
autoComplete="organization"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Team size" htmlFor="cs-team-size">
|
||||||
|
<select
|
||||||
|
id="cs-team-size"
|
||||||
|
data-testid="cs-team-size"
|
||||||
|
value={form.team_size}
|
||||||
|
onChange={handleChange('team_size')}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
{TEAM_SIZE_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="What brought you here?" htmlFor="cs-message">
|
||||||
|
<textarea
|
||||||
|
id="cs-message"
|
||||||
|
data-testid="cs-message"
|
||||||
|
rows={4}
|
||||||
|
value={form.message}
|
||||||
|
onChange={handleChange('message')}
|
||||||
|
style={{ ...inputStyle, resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-testid="cs-error"
|
||||||
|
style={{ color: '#f87171', fontSize: '0.9rem' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
data-testid="cs-submit"
|
||||||
|
disabled={submitting}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.25rem',
|
||||||
|
background: 'var(--lp-accent)',
|
||||||
|
color: '#0d0f15',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: submitting ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? 'Sending…' : 'Submit'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
required,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
htmlFor: string
|
||||||
|
required?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
htmlFor={htmlFor}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--lp-text-heading)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<span aria-hidden="true" style={{ color: 'var(--lp-accent)', marginLeft: '0.25rem' }}>
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
padding: '0.6rem 0.75rem',
|
||||||
|
background: 'var(--lp-bg-alt, #1a1d26)',
|
||||||
|
border: '1px solid var(--lp-border)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'var(--lp-text-heading)',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
outline: 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContactSalesPage
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { PageMeta } from '@/components/common/PageMeta'
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
|
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||||
import '@/styles/landing.css'
|
import '@/styles/landing.css'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
|
||||||
|
|
||||||
const FAQ_ITEMS = [
|
const FAQ_ITEMS = [
|
||||||
{
|
{
|
||||||
q: 'How is this different from just using ChatGPT?',
|
q: 'How is this different from just using ChatGPT?',
|
||||||
@@ -29,11 +28,9 @@ const FAQ_ITEMS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
|
const appConfig = useAppConfig()
|
||||||
const [navScrolled, setNavScrolled] = useState(false)
|
const [navScrolled, setNavScrolled] = useState(false)
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const [betaEmail, setBetaEmail] = useState('')
|
|
||||||
const [betaStatus, setBetaStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
|
|
||||||
const [betaError, setBetaError] = useState('')
|
|
||||||
const [openFaq, setOpenFaq] = useState<number | null>(null)
|
const [openFaq, setOpenFaq] = useState<number | null>(null)
|
||||||
const mobileMenuRef = useRef<HTMLDivElement>(null)
|
const mobileMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -71,32 +68,6 @@ export default function LandingPage() {
|
|||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleBetaSubmit = useCallback(async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const trimmed = betaEmail.trim()
|
|
||||||
if (!trimmed || betaStatus === 'sending') return
|
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
|
||||||
setBetaStatus('error')
|
|
||||||
setBetaError('Enter a valid email address.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBetaStatus('sending')
|
|
||||||
setBetaError('')
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`${API_URL}/api/v1/beta-signup`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email: trimmed }),
|
|
||||||
})
|
|
||||||
if (!resp.ok) throw new Error('Signup failed')
|
|
||||||
setBetaStatus('sent')
|
|
||||||
setBetaEmail('')
|
|
||||||
} catch {
|
|
||||||
setBetaStatus('error')
|
|
||||||
setBetaError('Could not complete signup. Please try again or email hello@resolutionflow.com.')
|
|
||||||
}
|
|
||||||
}, [betaEmail, betaStatus])
|
|
||||||
|
|
||||||
const toggleFaq = (index: number) => {
|
const toggleFaq = (index: number) => {
|
||||||
setOpenFaq(prev => prev === index ? null : index)
|
setOpenFaq(prev => prev === index ? null : index)
|
||||||
}
|
}
|
||||||
@@ -174,6 +145,15 @@ export default function LandingPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="landing-hero-actions">
|
<div className="landing-hero-actions">
|
||||||
<Link to="/register" className="landing-btn-hero-primary">Start Free</Link>
|
<Link to="/register" className="landing-btn-hero-primary">Start Free</Link>
|
||||||
|
{appConfig.self_serve_enabled && (
|
||||||
|
<Link
|
||||||
|
to="/pricing"
|
||||||
|
className="landing-btn-hero-secondary"
|
||||||
|
data-testid="landing-see-pricing"
|
||||||
|
>
|
||||||
|
See pricing
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<a href="#how-it-works" className="landing-btn-hero-secondary">See How It Works</a>
|
<a href="#how-it-works" className="landing-btn-hero-secondary">See How It Works</a>
|
||||||
</div>
|
</div>
|
||||||
<p className="landing-hero-credibility">
|
<p className="landing-hero-credibility">
|
||||||
@@ -422,34 +402,10 @@ export default function LandingPage() {
|
|||||||
<section className="landing-cta-section landing-reveal">
|
<section className="landing-cta-section landing-reveal">
|
||||||
<div className="landing-cta-inner">
|
<div className="landing-cta-inner">
|
||||||
<h2>Ready to stop writing ticket notes?</h2>
|
<h2>Ready to stop writing ticket notes?</h2>
|
||||||
<p>Join the beta. Troubleshoot your next ticket with FlowPilot.</p>
|
<p>Get early access. Troubleshoot your next ticket with FlowPilot.</p>
|
||||||
<form className="landing-cta-email-form" onSubmit={handleBetaSubmit} noValidate>
|
<div className="landing-cta-actions">
|
||||||
<div className="landing-cta-input-wrap">
|
<Link to="/register?from=beta" className="landing-btn-hero-primary">Get started</Link>
|
||||||
<input
|
</div>
|
||||||
type="email"
|
|
||||||
className="landing-cta-email-input"
|
|
||||||
placeholder="you@yourmsp.com"
|
|
||||||
value={betaEmail}
|
|
||||||
onChange={e => {
|
|
||||||
setBetaEmail(e.target.value)
|
|
||||||
if (betaStatus === 'error') { setBetaStatus('idle'); setBetaError('') }
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
aria-describedby="beta-status"
|
|
||||||
/>
|
|
||||||
<button type="submit" className="landing-btn-hero-primary" disabled={betaStatus === 'sending'}>
|
|
||||||
{betaStatus === 'sending' ? 'Joining\u2026' : betaStatus === 'sent' ? 'Joined!' : 'Join Beta'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="beta-status" aria-live="polite" className="landing-cta-status">
|
|
||||||
{betaStatus === 'sent' && (
|
|
||||||
<p className="landing-cta-success">You're in. We'll send beta access details soon.</p>
|
|
||||||
)}
|
|
||||||
{betaStatus === 'error' && betaError && (
|
|
||||||
<p className="landing-cta-error">{betaError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
|
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
195
frontend/src/pages/OAuthCallbackPage.tsx
Normal file
195
frontend/src/pages/OAuthCallbackPage.tsx
Normal 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
|
||||||
440
frontend/src/pages/PricingPage.tsx
Normal file
440
frontend/src/pages/PricingPage.tsx
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { plansApi, type PublicPlanResponse } from '@/api/plans'
|
||||||
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
|
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||||
|
import '@/styles/landing.css'
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* v1 hardcoded comparison table
|
||||||
|
*
|
||||||
|
* The marketing /pricing page surfaces a small "what's in each plan" table.
|
||||||
|
* Long-term, the source of truth for "plan X has feature Y" should be a
|
||||||
|
* server-side feature-flag mapping (likely keyed off feature_flag.display_name
|
||||||
|
* + plan_features). For v1 we hardcode the well-known features so we can ship
|
||||||
|
* the page without a backend dependency. Replace this block when a server-side
|
||||||
|
* feature mapping endpoint exists.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
type PlanColumn = 'starter' | 'pro' | 'enterprise'
|
||||||
|
|
||||||
|
const COMPARISON_ROWS: Array<{
|
||||||
|
feature: string
|
||||||
|
values: Record<PlanColumn, boolean>
|
||||||
|
}> = [
|
||||||
|
{ feature: 'PSA Integration', values: { starter: false, pro: true, enterprise: true } },
|
||||||
|
{ feature: 'KB Accelerator', values: { starter: false, pro: true, enterprise: true } },
|
||||||
|
{ feature: 'AI Builder', values: { starter: true, pro: true, enterprise: true } },
|
||||||
|
{ feature: 'Custom Branding', values: { starter: false, pro: false, enterprise: true } },
|
||||||
|
{ feature: 'Priority Support', values: { starter: false, pro: true, enterprise: true } },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatPrice(cents: number | null | undefined): string {
|
||||||
|
if (cents == null) return ''
|
||||||
|
const dollars = cents / 100
|
||||||
|
// Whole dollars (no decimals) for marketing display.
|
||||||
|
return `$${Math.round(dollars).toLocaleString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function PricingNotFound() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="pricing-not-found"
|
||||||
|
style={{
|
||||||
|
minHeight: '60vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>Page not found</h1>
|
||||||
|
<p style={{ color: '#9198a8' }}>This page is not available.</p>
|
||||||
|
<Link to="/login" style={{ color: '#60a5fa', marginTop: '1rem' }}>
|
||||||
|
Go to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlanCardProps {
|
||||||
|
plan: PublicPlanResponse | null
|
||||||
|
fallback: {
|
||||||
|
plan: string
|
||||||
|
display_name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
recommended?: boolean
|
||||||
|
hidePrice?: boolean
|
||||||
|
ctaLabel: string
|
||||||
|
ctaHref: string
|
||||||
|
ctaTestId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanCard({ plan, fallback, recommended, hidePrice, ctaLabel, ctaHref, ctaTestId }: PlanCardProps) {
|
||||||
|
const displayName = plan?.display_name ?? fallback.display_name
|
||||||
|
const description = plan?.description ?? fallback.description
|
||||||
|
const monthlyCents = plan?.monthly_price_cents ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`plan-card-${fallback.plan}`}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
background: 'var(--lp-card)',
|
||||||
|
border: recommended
|
||||||
|
? '2px solid var(--lp-accent)'
|
||||||
|
: '1px solid var(--lp-border)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '2rem 1.75rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recommended && (
|
||||||
|
<span
|
||||||
|
data-testid="recommended-badge"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-12px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
padding: '4px 12px',
|
||||||
|
background: 'var(--lp-accent)',
|
||||||
|
color: '#0d0f15',
|
||||||
|
borderRadius: '999px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Recommended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
color: 'var(--lp-text-heading)',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0.5rem 0 0', color: 'var(--lp-text-secondary)', fontSize: '0.9rem' }}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ minHeight: '3rem' }}>
|
||||||
|
{hidePrice ? (
|
||||||
|
<div style={{ color: 'var(--lp-text-heading)', fontSize: '1.25rem', fontWeight: 600 }}>
|
||||||
|
Custom pricing
|
||||||
|
</div>
|
||||||
|
) : monthlyCents != null ? (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--lp-text-heading)', fontSize: '2.25rem', fontWeight: 700 }}>
|
||||||
|
{formatPrice(monthlyCents)}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--lp-text-secondary)', marginLeft: '0.35rem' }}>/ month</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: 'var(--lp-text-secondary)' }}>Contact us</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={ctaHref}
|
||||||
|
data-testid={ctaTestId}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '0.75rem 1.25rem',
|
||||||
|
background: recommended ? 'var(--lp-accent)' : 'transparent',
|
||||||
|
color: recommended ? '#0d0f15' : 'var(--lp-text-heading)',
|
||||||
|
border: recommended ? 'none' : '1px solid var(--lp-border-hover)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PricingPage() {
|
||||||
|
const appConfig = useAppConfig()
|
||||||
|
const [plans, setPlans] = useState<PublicPlanResponse[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Fetch plans on mount when self-serve is enabled.
|
||||||
|
useEffect(() => {
|
||||||
|
if (appConfig.isLoading) return
|
||||||
|
if (!appConfig.self_serve_enabled) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
plansApi
|
||||||
|
.getPublic()
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setPlans(data)
|
||||||
|
setError(null)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
// Non-fatal: page still renders with fallback descriptions and no
|
||||||
|
// server-driven prices. The CTA still works via /register?plan=...
|
||||||
|
setError('Unable to load live pricing.')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [appConfig.isLoading, appConfig.self_serve_enabled])
|
||||||
|
|
||||||
|
// Self-serve disabled: render a 404-style fallback. Done after hooks so
|
||||||
|
// the React rules-of-hooks invariant holds.
|
||||||
|
if (!appConfig.isLoading && !appConfig.self_serve_enabled) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageMeta title="Page not found" />
|
||||||
|
<PricingNotFound />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const planByName = (name: string) =>
|
||||||
|
plans?.find((p) => p.plan.toLowerCase() === name) ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="landing-page">
|
||||||
|
<PageMeta
|
||||||
|
title="Pricing"
|
||||||
|
description="ResolutionFlow plans for MSPs — Starter, Pro, and Enterprise. Try Pro free for 14 days, no credit card required."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="landing-main" style={{ paddingTop: '4rem', paddingBottom: '4rem' }}>
|
||||||
|
{/* ---- HERO ---- */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
maxWidth: '720px',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '4rem 1.5rem 2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
color: 'var(--lp-text-heading)',
|
||||||
|
fontSize: 'clamp(2rem, 4vw, 2.75rem)',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.15,
|
||||||
|
margin: '0 0 1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Simple pricing for MSPs of every size
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
data-testid="hero-trial-line"
|
||||||
|
style={{
|
||||||
|
color: 'var(--lp-text-body)',
|
||||||
|
fontSize: '1.125rem',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try Pro free for 14 days. No credit card required.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- PLAN CARDS ---- */}
|
||||||
|
<section
|
||||||
|
aria-label="Plans"
|
||||||
|
style={{
|
||||||
|
maxWidth: '1100px',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '2rem 1.5rem',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||||
|
gap: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlanCard
|
||||||
|
plan={planByName('starter')}
|
||||||
|
fallback={{
|
||||||
|
plan: 'starter',
|
||||||
|
display_name: 'Starter',
|
||||||
|
description: 'For solo techs getting structured.',
|
||||||
|
}}
|
||||||
|
ctaLabel="Start free trial"
|
||||||
|
ctaHref="/register?plan=starter"
|
||||||
|
ctaTestId="cta-starter"
|
||||||
|
/>
|
||||||
|
<PlanCard
|
||||||
|
plan={planByName('pro')}
|
||||||
|
recommended
|
||||||
|
fallback={{
|
||||||
|
plan: 'pro',
|
||||||
|
display_name: 'Pro',
|
||||||
|
description: 'For growing MSP teams. PSA integration + KB Accelerator.',
|
||||||
|
}}
|
||||||
|
ctaLabel="Start free trial"
|
||||||
|
ctaHref="/register?plan=pro"
|
||||||
|
ctaTestId="cta-pro"
|
||||||
|
/>
|
||||||
|
<PlanCard
|
||||||
|
plan={planByName('enterprise')}
|
||||||
|
hidePrice
|
||||||
|
fallback={{
|
||||||
|
plan: 'enterprise',
|
||||||
|
display_name: 'Enterprise',
|
||||||
|
description: 'Custom branding, custom seats, and a dedicated success contact.',
|
||||||
|
}}
|
||||||
|
ctaLabel="Talk to sales"
|
||||||
|
ctaHref="/contact-sales"
|
||||||
|
ctaTestId="cta-enterprise"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
style={{ textAlign: 'center', color: 'var(--lp-text-secondary)' }}
|
||||||
|
>
|
||||||
|
Loading pricing…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
style={{ textAlign: 'center', color: 'var(--lp-text-secondary)', marginTop: '0.5rem' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- COMPARISON TABLE ---- */}
|
||||||
|
<section
|
||||||
|
aria-label="Plan comparison"
|
||||||
|
style={{
|
||||||
|
maxWidth: '1000px',
|
||||||
|
margin: '3rem auto 2rem',
|
||||||
|
padding: '0 1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: 'var(--lp-text-heading)',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: '0 0 1rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Compare plans
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
overflowX: 'auto',
|
||||||
|
border: '1px solid var(--lp-border)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
color: 'var(--lp-text-body)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: 'var(--lp-bg-alt)' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: '0.75rem 1rem', fontWeight: 600 }}>
|
||||||
|
Feature
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Starter</th>
|
||||||
|
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Pro</th>
|
||||||
|
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Enterprise</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{COMPARISON_ROWS.map((row) => (
|
||||||
|
<tr key={row.feature} style={{ borderTop: '1px solid var(--lp-border)' }}>
|
||||||
|
<td style={{ padding: '0.75rem 1rem' }}>{row.feature}</td>
|
||||||
|
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
|
||||||
|
{row.values.starter ? '✓' : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
|
||||||
|
{row.values.pro ? '✓' : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
|
||||||
|
{row.values.enterprise ? '✓' : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- TESTIMONIAL SLOT (placeholder) ---- */}
|
||||||
|
<section
|
||||||
|
aria-label="Customer testimonial"
|
||||||
|
style={{
|
||||||
|
maxWidth: '720px',
|
||||||
|
margin: '3rem auto 2rem',
|
||||||
|
padding: '2rem 1.5rem',
|
||||||
|
background: 'var(--lp-card)',
|
||||||
|
border: '1px solid var(--lp-border)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
data-testid="testimonial-slot"
|
||||||
|
>
|
||||||
|
<blockquote
|
||||||
|
style={{
|
||||||
|
fontStyle: 'italic',
|
||||||
|
color: 'var(--lp-text-body)',
|
||||||
|
fontSize: '1.05rem',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
"Pilot testimonials coming soon."
|
||||||
|
</blockquote>
|
||||||
|
<div style={{ marginTop: '0.75rem', color: 'var(--lp-text-secondary)', fontSize: '0.9rem' }}>
|
||||||
|
ResolutionFlow pilot, 2026
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- TRUST STRIP ---- */}
|
||||||
|
<section
|
||||||
|
aria-label="Trust"
|
||||||
|
data-testid="trust-strip"
|
||||||
|
style={{
|
||||||
|
maxWidth: '900px',
|
||||||
|
margin: '2rem auto 0',
|
||||||
|
padding: '1rem 1.5rem',
|
||||||
|
color: 'var(--lp-text-secondary)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Built on Stripe + AWS · Encrypted in transit and at rest
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PricingPage
|
||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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're already verified
|
||||||
)}
|
</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
This account'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'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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
123
frontend/src/pages/__tests__/AcceptInvitePage.test.tsx
Normal file
123
frontend/src/pages/__tests__/AcceptInvitePage.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
146
frontend/src/pages/__tests__/ContactSalesPage.test.tsx
Normal file
146
frontend/src/pages/__tests__/ContactSalesPage.test.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
|
|
||||||
|
import { ContactSalesPage } from '../ContactSalesPage'
|
||||||
|
import { salesApi } from '@/api/sales'
|
||||||
|
import {
|
||||||
|
__resetAppConfigCache,
|
||||||
|
__setAppConfigCache,
|
||||||
|
} from '@/hooks/useAppConfig'
|
||||||
|
|
||||||
|
vi.mock('@/api/sales', () => ({
|
||||||
|
salesApi: {
|
||||||
|
createLead: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<HelmetProvider>
|
||||||
|
<MemoryRouter initialEntries={['/contact-sales']}>
|
||||||
|
<ContactSalesPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</HelmetProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillRequiredFields() {
|
||||||
|
fireEvent.change(screen.getByTestId('cs-name'), { target: { value: 'Jane Doe' } })
|
||||||
|
fireEvent.change(screen.getByTestId('cs-email'), { target: { value: 'jane@acme.com' } })
|
||||||
|
fireEvent.change(screen.getByTestId('cs-company'), { target: { value: 'Acme MSP' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ContactSalesPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetAppConfigCache()
|
||||||
|
__setAppConfigCache({
|
||||||
|
self_serve_enabled: true,
|
||||||
|
oauth_providers: [],
|
||||||
|
})
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits form and shows confirmation', async () => {
|
||||||
|
vi.stubEnv('VITE_CALENDLY_URL', 'https://calendly.com/resolutionflow/sales')
|
||||||
|
vi.mocked(salesApi.createLead).mockResolvedValue({
|
||||||
|
id: 'fake-uuid',
|
||||||
|
status: 'received',
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
fillRequiredFields()
|
||||||
|
fireEvent.change(screen.getByTestId('cs-team-size'), { target: { value: '11-25' } })
|
||||||
|
fireEvent.change(screen.getByTestId('cs-message'), {
|
||||||
|
target: { value: 'Looking at Enterprise pricing.' },
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('cs-submit'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(salesApi.createLead).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = vi.mocked(salesApi.createLead).mock.calls[0][0]
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
name: 'Jane Doe',
|
||||||
|
email: 'jane@acme.com',
|
||||||
|
company: 'Acme MSP',
|
||||||
|
team_size: '11-25',
|
||||||
|
message: 'Looking at Enterprise pricing.',
|
||||||
|
})
|
||||||
|
// Default source is landing_page (no /pricing in referrer in jsdom).
|
||||||
|
expect(payload.source).toBe('landing_page')
|
||||||
|
|
||||||
|
// Confirmation surface replaces the form.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.queryByTestId('contact-sales-form')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Thanks/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Calendly section when VITE_CALENDLY_URL unset', async () => {
|
||||||
|
vi.stubEnv('VITE_CALENDLY_URL', '')
|
||||||
|
vi.mocked(salesApi.createLead).mockResolvedValue({
|
||||||
|
id: 'fake-uuid',
|
||||||
|
status: 'received',
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
fillRequiredFields()
|
||||||
|
fireEvent.click(screen.getByTestId('cs-submit'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('calendly-block')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('calendly-link')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables submit button while in flight to prevent duplicate submissions', async () => {
|
||||||
|
let resolveSubmit: (() => void) | null = null
|
||||||
|
vi.mocked(salesApi.createLead).mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveSubmit = () => resolve({ id: 'fake-uuid', status: 'received' })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
fillRequiredFields()
|
||||||
|
|
||||||
|
const submit = screen.getByTestId('cs-submit') as HTMLButtonElement
|
||||||
|
fireEvent.click(submit)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submit.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A second click while in flight should be a no-op.
|
||||||
|
fireEvent.click(submit)
|
||||||
|
expect(salesApi.createLead).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
resolveSubmit?.()
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when self_serve_enabled is false', () => {
|
||||||
|
__resetAppConfigCache()
|
||||||
|
__setAppConfigCache({
|
||||||
|
self_serve_enabled: false,
|
||||||
|
oauth_providers: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('contact-sales-not-found')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('contact-sales-form')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
69
frontend/src/pages/__tests__/LandingPage.test.tsx
Normal file
69
frontend/src/pages/__tests__/LandingPage.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
|
|
||||||
|
import LandingPage from '../LandingPage'
|
||||||
|
import {
|
||||||
|
__resetAppConfigCache,
|
||||||
|
__setAppConfigCache,
|
||||||
|
} from '@/hooks/useAppConfig'
|
||||||
|
|
||||||
|
// jsdom does not provide IntersectionObserver. LandingPage uses it for
|
||||||
|
// scroll-reveal animations; stub a no-op so the page can mount.
|
||||||
|
beforeAll(() => {
|
||||||
|
// @ts-expect-error — test-only stub
|
||||||
|
globalThis.IntersectionObserver = class {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<HelmetProvider>
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<LandingPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</HelmetProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LandingPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetAppConfigCache()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows See pricing CTA when self_serve_enabled is true', async () => {
|
||||||
|
__setAppConfigCache({
|
||||||
|
self_serve_enabled: true,
|
||||||
|
oauth_providers: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('landing-see-pricing')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
const cta = screen.getByTestId('landing-see-pricing')
|
||||||
|
expect(cta).toHaveAttribute('href', '/pricing')
|
||||||
|
expect(cta).toHaveTextContent(/See pricing/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides See pricing CTA when self_serve_enabled is false', async () => {
|
||||||
|
__setAppConfigCache({
|
||||||
|
self_serve_enabled: false,
|
||||||
|
oauth_providers: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
// Hero "Start Free" still renders, but the gated /pricing CTA does not.
|
||||||
|
expect(screen.queryByTestId('landing-see-pricing')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
121
frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx
Normal file
121
frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
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(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockSetTokens = vi.fn()
|
||||||
|
const mockFetchUser = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
vi.mock('@/store/authStore', () => ({
|
||||||
|
useAuthStore: () => ({
|
||||||
|
setTokens: mockSetTokens,
|
||||||
|
fetchUser: mockFetchUser,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('OAuthCallbackPage successful callback', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists tokens via setTokens (which marks the store authenticated) and fetches the user', async () => {
|
||||||
|
sessionStorage.setItem('rf-oauth-state', 'csrf-value')
|
||||||
|
;(authApi.googleCallback as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
access_token: 'access-123',
|
||||||
|
refresh_token: 'refresh-456',
|
||||||
|
token_type: 'bearer',
|
||||||
|
is_new_user: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderAt('/auth/google/callback?code=auth-code-123&state=csrf-value')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSetTokens).toHaveBeenCalledWith({
|
||||||
|
access_token: 'access-123',
|
||||||
|
refresh_token: 'refresh-456',
|
||||||
|
token_type: 'bearer',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(mockFetchUser).toHaveBeenCalled()
|
||||||
|
// Tokens are also persisted for the apiClient interceptor.
|
||||||
|
expect(localStorage.getItem('access_token')).toBe('access-123')
|
||||||
|
expect(localStorage.getItem('refresh_token')).toBe('refresh-456')
|
||||||
|
})
|
||||||
|
})
|
||||||
162
frontend/src/pages/__tests__/PricingPage.test.tsx
Normal file
162
frontend/src/pages/__tests__/PricingPage.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
|
|
||||||
|
import { PricingPage } from '../PricingPage'
|
||||||
|
import { plansApi, type PublicPlanResponse } from '@/api/plans'
|
||||||
|
import {
|
||||||
|
__resetAppConfigCache,
|
||||||
|
__setAppConfigCache,
|
||||||
|
} from '@/hooks/useAppConfig'
|
||||||
|
|
||||||
|
vi.mock('@/api/plans', () => ({
|
||||||
|
plansApi: {
|
||||||
|
getPublic: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const STARTER: PublicPlanResponse = {
|
||||||
|
plan: 'starter',
|
||||||
|
display_name: 'Starter',
|
||||||
|
description: 'For solo techs.',
|
||||||
|
monthly_price_cents: 1900,
|
||||||
|
annual_price_cents: 19000,
|
||||||
|
max_seats: 3,
|
||||||
|
sort_order: 10,
|
||||||
|
is_public: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRO: PublicPlanResponse = {
|
||||||
|
plan: 'pro',
|
||||||
|
display_name: 'Pro',
|
||||||
|
description: 'For growing MSP teams.',
|
||||||
|
monthly_price_cents: 4900,
|
||||||
|
annual_price_cents: 49000,
|
||||||
|
max_seats: 10,
|
||||||
|
sort_order: 20,
|
||||||
|
is_public: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTERPRISE: PublicPlanResponse = {
|
||||||
|
plan: 'enterprise',
|
||||||
|
display_name: 'Enterprise',
|
||||||
|
description: 'Custom seats + branding.',
|
||||||
|
monthly_price_cents: null,
|
||||||
|
annual_price_cents: null,
|
||||||
|
max_seats: null,
|
||||||
|
sort_order: 30,
|
||||||
|
is_public: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<HelmetProvider>
|
||||||
|
<MemoryRouter initialEntries={['/pricing']}>
|
||||||
|
<PricingPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</HelmetProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PricingPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetAppConfigCache()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows three plan cards with prices from API', async () => {
|
||||||
|
__setAppConfigCache({
|
||||||
|
self_serve_enabled: true,
|
||||||
|
oauth_providers: [],
|
||||||
|
})
|
||||||
|
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(plansApi.getPublic).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Three plan cards present.
|
||||||
|
expect(screen.getByTestId('plan-card-starter')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('plan-card-enterprise')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Prices from API rendered.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('$19')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('$49')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Enterprise card hides price (shows "Custom pricing" instead).
|
||||||
|
expect(screen.getByText(/Custom pricing/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Pro is recommended.
|
||||||
|
expect(screen.getByTestId('recommended-badge')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Start free trial button navigates to /register?plan=pro', async () => {
|
||||||
|
__setAppConfigCache({
|
||||||
|
self_serve_enabled: true,
|
||||||
|
oauth_providers: [],
|
||||||
|
})
|
||||||
|
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
const proCta = await screen.findByTestId('cta-pro')
|
||||||
|
expect(proCta).toHaveAttribute('href', '/register?plan=pro')
|
||||||
|
expect(proCta).toHaveTextContent(/Start free trial/i)
|
||||||
|
|
||||||
|
const starterCta = screen.getByTestId('cta-starter')
|
||||||
|
expect(starterCta).toHaveAttribute('href', '/register?plan=starter')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Talk to sales button navigates to /contact-sales', async () => {
|
||||||
|
__setAppConfigCache({
|
||||||
|
self_serve_enabled: true,
|
||||||
|
oauth_providers: [],
|
||||||
|
})
|
||||||
|
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
const enterpriseCta = await screen.findByTestId('cta-enterprise')
|
||||||
|
expect(enterpriseCta).toHaveAttribute('href', '/contact-sales')
|
||||||
|
expect(enterpriseCta).toHaveTextContent(/Talk to sales/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when self_serve_enabled is false', async () => {
|
||||||
|
__setAppConfigCache({
|
||||||
|
self_serve_enabled: false,
|
||||||
|
oauth_providers: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('pricing-not-found')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByText(/Page not found/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
// No plan cards rendered, no API call made.
|
||||||
|
expect(screen.queryByTestId('plan-card-starter')).not.toBeInTheDocument()
|
||||||
|
expect(plansApi.getPublic).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses softer trust language (no SOC2/DPA claim yet)', async () => {
|
||||||
|
__setAppConfigCache({
|
||||||
|
self_serve_enabled: true,
|
||||||
|
oauth_providers: [],
|
||||||
|
})
|
||||||
|
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
const trust = await screen.findByTestId('trust-strip')
|
||||||
|
expect(trust).toHaveTextContent(/Built on Stripe \+ AWS/i)
|
||||||
|
expect(trust).toHaveTextContent(/Encrypted in transit and at rest/i)
|
||||||
|
expect(trust).not.toHaveTextContent(/SOC ?2/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
141
frontend/src/pages/__tests__/QuickStartPage.test.tsx
Normal file
141
frontend/src/pages/__tests__/QuickStartPage.test.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
121
frontend/src/pages/__tests__/RegisterPage.test.tsx
Normal file
121
frontend/src/pages/__tests__/RegisterPage.test.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
174
frontend/src/pages/__tests__/VerifyEmailPage.test.tsx
Normal file
174
frontend/src/pages/__tests__/VerifyEmailPage.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
267
frontend/src/pages/account/BillingPage.tsx
Normal file
267
frontend/src/pages/account/BillingPage.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { CreditCard, AlertCircle, Loader2, ExternalLink, Crown } from 'lucide-react'
|
||||||
|
|
||||||
|
import { billingApi } from '@/api/billing'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
|
import { BillingPortalError } from '@/types/billing'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return '—'
|
||||||
|
return new Date(value).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'trialing':
|
||||||
|
return 'Trialing'
|
||||||
|
case 'active':
|
||||||
|
return 'Active'
|
||||||
|
case 'past_due':
|
||||||
|
return 'Past due'
|
||||||
|
case 'canceled':
|
||||||
|
return 'Canceled'
|
||||||
|
case 'incomplete':
|
||||||
|
return 'Incomplete'
|
||||||
|
case 'complimentary':
|
||||||
|
return 'Complimentary'
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusToneClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
case 'complimentary':
|
||||||
|
return 'text-success'
|
||||||
|
case 'trialing':
|
||||||
|
return 'text-info'
|
||||||
|
case 'past_due':
|
||||||
|
case 'incomplete':
|
||||||
|
return 'text-warning'
|
||||||
|
case 'canceled':
|
||||||
|
return 'text-danger'
|
||||||
|
default:
|
||||||
|
return 'text-muted-foreground'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BillingPage() {
|
||||||
|
const subscription = useBillingStore((s) => s.subscription)
|
||||||
|
const planBilling = useBillingStore((s) => s.planBilling)
|
||||||
|
const isLoading = useBillingStore((s) => s.isLoading)
|
||||||
|
|
||||||
|
const [openingPortal, setOpeningPortal] = useState(false)
|
||||||
|
|
||||||
|
const status = subscription?.status ?? null
|
||||||
|
const isComplimentary = status === 'complimentary'
|
||||||
|
const isTrialing = status === 'trialing'
|
||||||
|
const isPastDue = status === 'past_due'
|
||||||
|
const isCanceled = status === 'canceled'
|
||||||
|
|
||||||
|
const handleOpenPortal = async () => {
|
||||||
|
setOpeningPortal(true)
|
||||||
|
try {
|
||||||
|
const { url } = await billingApi.getPortalSession()
|
||||||
|
window.location.href = url
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BillingPortalError) {
|
||||||
|
if (err.code === 'no_stripe_customer') {
|
||||||
|
toast.error('Complete checkout first to access billing portal.')
|
||||||
|
} else {
|
||||||
|
toast.error('Billing portal is not available right now.')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to open billing portal.')
|
||||||
|
}
|
||||||
|
setOpeningPortal(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading && !subscription) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageMeta title="Billing" />
|
||||||
|
<div>
|
||||||
|
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CreditCard className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
|
||||||
|
Billing
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Manage your subscription, payment method, and billing history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Past-due banner ────────────────────────────────────────────── */}
|
||||||
|
{isPastDue && (
|
||||||
|
<div
|
||||||
|
data-testid="past-due-banner"
|
||||||
|
className={cn(
|
||||||
|
'mb-6 flex flex-wrap items-start gap-3 rounded-lg border border-warning/30',
|
||||||
|
'bg-warning-dim p-4 text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">Your last payment failed.</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Update your payment method to keep access to ResolutionFlow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
loading={openingPortal}
|
||||||
|
onClick={handleOpenPortal}
|
||||||
|
data-testid="past-due-update-payment"
|
||||||
|
>
|
||||||
|
Update payment method
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Subscription summary card ──────────────────────────────────── */}
|
||||||
|
<div className="card-flat max-w-xl space-y-5 p-6">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Crown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{planBilling?.display_name ?? 'No active plan'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{subscription && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mt-1 text-xs',
|
||||||
|
statusToneClass(subscription.status),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel(subscription.status)}
|
||||||
|
{subscription.cancel_at_period_end && ' · cancels at period end'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{subscription?.seat_limit != null && (
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-muted-foreground">Seats</div>
|
||||||
|
<div className="text-sm tabular-nums text-foreground">
|
||||||
|
{subscription.seat_limit}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 border-t border-border pt-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{isCanceled ? 'Ends' : isTrialing ? 'Trial ends' : 'Next renewal'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm tabular-nums text-foreground">
|
||||||
|
{isComplimentary ? '—' : formatDate(subscription?.current_period_end)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Plan started</div>
|
||||||
|
<div className="text-sm tabular-nums text-foreground">
|
||||||
|
{formatDate(subscription?.current_period_start)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* State-specific messaging ------------------------------------ */}
|
||||||
|
{isComplimentary && (
|
||||||
|
<div
|
||||||
|
data-testid="complimentary-message"
|
||||||
|
className="rounded-md bg-success-dim p-3 text-xs text-success"
|
||||||
|
>
|
||||||
|
Complimentary Pro — no billing required.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isTrialing && (
|
||||||
|
<div
|
||||||
|
data-testid="trial-message"
|
||||||
|
className="rounded-md bg-info-dim p-3 text-xs text-info"
|
||||||
|
>
|
||||||
|
Trial ends {formatDate(subscription?.current_period_end)} — pick a plan
|
||||||
|
to continue.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCanceled && (
|
||||||
|
<div
|
||||||
|
data-testid="canceled-message"
|
||||||
|
className="rounded-md bg-muted p-3 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Subscription canceled. Reactivate by picking a plan.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Actions ────────────────────────────────────────────────────── */}
|
||||||
|
{!isComplimentary && (
|
||||||
|
<div className="mt-6 flex max-w-xl flex-wrap gap-3">
|
||||||
|
{(isTrialing || isCanceled) && (
|
||||||
|
<Link
|
||||||
|
to="/account/billing/select-plan"
|
||||||
|
data-testid="select-plan-link"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2',
|
||||||
|
'text-sm font-semibold text-white hover:brightness-110',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Pick a plan
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isTrialing && !isCanceled && (
|
||||||
|
<Link
|
||||||
|
to="/account/billing/select-plan"
|
||||||
|
data-testid="change-plan-link"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 rounded-lg border border-border',
|
||||||
|
'bg-input px-4 py-2 text-sm font-medium text-foreground',
|
||||||
|
'hover:border-border-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Change plan
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
loading={openingPortal}
|
||||||
|
onClick={handleOpenPortal}
|
||||||
|
data-testid="manage-billing-button"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Manage billing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BillingPage
|
||||||
354
frontend/src/pages/account/SelectPlanPage.tsx
Normal file
354
frontend/src/pages/account/SelectPlanPage.tsx
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Check, CreditCard, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { billingApi } from '@/api/billing'
|
||||||
|
import { plansApi, type PublicPlanResponse } from '@/api/plans'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { BillingInterval, CheckoutPlan } from '@/types/billing'
|
||||||
|
|
||||||
|
function formatPrice(cents: number | null | undefined): string {
|
||||||
|
if (cents == null) return ''
|
||||||
|
const dollars = cents / 100
|
||||||
|
return `$${Math.round(dollars).toLocaleString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAN_FALLBACK_FEATURES: Record<string, string[]> = {
|
||||||
|
starter: ['AI Builder', 'Up to 1 seat', 'Email support'],
|
||||||
|
pro: [
|
||||||
|
'PSA Integration',
|
||||||
|
'KB Accelerator',
|
||||||
|
'AI Builder',
|
||||||
|
'Priority support',
|
||||||
|
],
|
||||||
|
team: [
|
||||||
|
'Everything in Pro',
|
||||||
|
'Multi-seat collaboration',
|
||||||
|
'Shared categories',
|
||||||
|
],
|
||||||
|
enterprise: [
|
||||||
|
'Custom seats and SSO',
|
||||||
|
'Custom branding',
|
||||||
|
'Dedicated success contact',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlanCardProps {
|
||||||
|
plan: PublicPlanResponse
|
||||||
|
interval: BillingInterval
|
||||||
|
isCurrent: boolean
|
||||||
|
isEnterprise: boolean
|
||||||
|
onSelect: (planKey: CheckoutPlan) => void
|
||||||
|
isSubmitting: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanCard({
|
||||||
|
plan,
|
||||||
|
interval,
|
||||||
|
isCurrent,
|
||||||
|
isEnterprise,
|
||||||
|
onSelect,
|
||||||
|
isSubmitting,
|
||||||
|
}: PlanCardProps) {
|
||||||
|
const planKey = plan.plan.toLowerCase() as CheckoutPlan
|
||||||
|
const cents =
|
||||||
|
interval === 'annual' ? plan.annual_price_cents : plan.monthly_price_cents
|
||||||
|
const features = PLAN_FALLBACK_FEATURES[planKey] ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`plan-card-${planKey}`}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-4 rounded-xl border p-6',
|
||||||
|
isCurrent
|
||||||
|
? 'border-primary/40 bg-primary/5'
|
||||||
|
: 'border-border bg-card hover:border-border-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
|
{plan.display_name}
|
||||||
|
</h3>
|
||||||
|
{isCurrent && (
|
||||||
|
<span
|
||||||
|
data-testid={`plan-current-${planKey}`}
|
||||||
|
className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
|
||||||
|
>
|
||||||
|
Current plan
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{plan.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-[3rem]">
|
||||||
|
{isEnterprise ? (
|
||||||
|
<div className="text-base font-medium text-foreground">
|
||||||
|
Custom pricing
|
||||||
|
</div>
|
||||||
|
) : cents != null ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-3xl font-bold text-foreground">
|
||||||
|
{formatPrice(cents)}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 text-sm text-muted-foreground">
|
||||||
|
/ {interval === 'annual' ? 'year' : 'month'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">Contact us</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-1.5 text-sm text-muted-foreground">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<li key={feature} className="flex items-start gap-2">
|
||||||
|
<Check className="mt-0.5 h-4 w-4 shrink-0 text-success" />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-2">
|
||||||
|
{isEnterprise ? (
|
||||||
|
<Link
|
||||||
|
to="/contact-sales"
|
||||||
|
data-testid={`plan-cta-${planKey}`}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex w-full items-center justify-center rounded-lg border border-border',
|
||||||
|
'bg-input px-4 py-2 text-sm font-medium text-foreground',
|
||||||
|
'hover:border-border-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Talk to sales
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
data-testid={`plan-cta-${planKey}`}
|
||||||
|
disabled={isCurrent || isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={() => onSelect(planKey)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isCurrent ? 'Current plan' : 'Continue to checkout'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectPlanPage() {
|
||||||
|
const subscription = useBillingStore((s) => s.subscription)
|
||||||
|
const currentPlan = subscription?.plan ?? null
|
||||||
|
const isCurrentActive =
|
||||||
|
subscription?.status === 'active' || subscription?.status === 'trialing'
|
||||||
|
|
||||||
|
const [plans, setPlans] = useState<PublicPlanResponse[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
const [interval, setInterval] = useState<BillingInterval>('monthly')
|
||||||
|
const [seats, setSeats] = useState<number>(1)
|
||||||
|
const [submittingPlan, setSubmittingPlan] = useState<CheckoutPlan | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
plansApi
|
||||||
|
.getPublic()
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return
|
||||||
|
// Sort by sort_order so the layout is stable.
|
||||||
|
const sorted = [...data].sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
setPlans(sorted)
|
||||||
|
setLoadError(null)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setLoadError('Unable to load plans. Please try again.')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const seedSeats = useMemo(() => {
|
||||||
|
return subscription?.seat_limit && subscription.seat_limit > 0
|
||||||
|
? subscription.seat_limit
|
||||||
|
: 1
|
||||||
|
}, [subscription?.seat_limit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSeats(seedSeats)
|
||||||
|
}, [seedSeats])
|
||||||
|
|
||||||
|
const handleSelectPlan = async (planKey: CheckoutPlan) => {
|
||||||
|
if (planKey === 'enterprise') return
|
||||||
|
setSubmittingPlan(planKey)
|
||||||
|
try {
|
||||||
|
const { url } = await billingApi.createCheckoutSession({
|
||||||
|
plan: planKey,
|
||||||
|
seats: Math.max(1, Math.floor(seats)),
|
||||||
|
billing_interval: interval,
|
||||||
|
})
|
||||||
|
window.location.href = url
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not start checkout. Please try again.')
|
||||||
|
setSubmittingPlan(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageMeta title="Pick a plan" />
|
||||||
|
<div>
|
||||||
|
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CreditCard className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
|
||||||
|
Pick a plan
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Choose the plan that fits your team. You can change or cancel any
|
||||||
|
time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Controls ───────────────────────────────────────────────────── */}
|
||||||
|
<div className="mb-6 flex flex-wrap items-end gap-6">
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs font-medium text-muted-foreground">
|
||||||
|
Billing interval
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Billing interval"
|
||||||
|
className="mt-2 inline-flex rounded-lg border border-border bg-card p-1 text-sm"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={interval === 'monthly'}
|
||||||
|
data-testid="interval-monthly"
|
||||||
|
onClick={() => setInterval('monthly')}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-3 py-1.5 font-medium',
|
||||||
|
interval === 'monthly'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={interval === 'annual'}
|
||||||
|
data-testid="interval-annual"
|
||||||
|
onClick={() => setInterval('annual')}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-3 py-1.5 font-medium',
|
||||||
|
interval === 'annual'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Annual
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="seats-input"
|
||||||
|
className="block text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
Seats
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="seats-input"
|
||||||
|
data-testid="seats-input"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={seats}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = Number.parseInt(e.target.value, 10)
|
||||||
|
if (Number.isFinite(next) && next >= 1) {
|
||||||
|
setSeats(next)
|
||||||
|
} else if (e.target.value === '') {
|
||||||
|
setSeats(1)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 w-24 rounded-lg border border-border bg-card px-3 py-1.5',
|
||||||
|
'text-sm text-foreground',
|
||||||
|
'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Plan cards ─────────────────────────────────────────────────── */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadError && !loading && (
|
||||||
|
<div className="rounded-md border border-danger/20 bg-danger-dim p-4 text-sm text-danger">
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !loadError && plans && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const planKey = plan.plan.toLowerCase()
|
||||||
|
const isEnterprise = planKey === 'enterprise'
|
||||||
|
const isCurrent = !!(
|
||||||
|
isCurrentActive &&
|
||||||
|
currentPlan &&
|
||||||
|
currentPlan.toLowerCase() === planKey
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<PlanCard
|
||||||
|
key={plan.plan}
|
||||||
|
plan={plan}
|
||||||
|
interval={interval}
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
isEnterprise={isEnterprise}
|
||||||
|
onSelect={handleSelectPlan}
|
||||||
|
isSubmitting={submittingPlan === planKey}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link
|
||||||
|
to="/account/billing"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
← Back to billing
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectPlanPage
|
||||||
206
frontend/src/pages/account/__tests__/BillingPage.test.tsx
Normal file
206
frontend/src/pages/account/__tests__/BillingPage.test.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { BillingPage } from '../BillingPage'
|
||||||
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
|
import { BillingPortalError } from '@/types/billing'
|
||||||
|
import type { SubscriptionState, PlanBillingState } from '@/types/billing'
|
||||||
|
|
||||||
|
vi.mock('@/api/billing', () => ({
|
||||||
|
billingApi: {
|
||||||
|
getPortalSession: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/toast', () => ({
|
||||||
|
toast: {
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { billingApi } from '@/api/billing'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
|
const getPortalSession = billingApi.getPortalSession as unknown as ReturnType<typeof vi.fn>
|
||||||
|
const toastError = toast.error as unknown as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
function setBilling(opts: {
|
||||||
|
subscription: SubscriptionState | null
|
||||||
|
planBilling?: PlanBillingState | null
|
||||||
|
}) {
|
||||||
|
useBillingStore.setState({
|
||||||
|
subscription: opts.subscription,
|
||||||
|
planBilling:
|
||||||
|
opts.planBilling ??
|
||||||
|
({
|
||||||
|
display_name: 'Pro',
|
||||||
|
description: 'Pro plan',
|
||||||
|
monthly_price_cents: 4900,
|
||||||
|
annual_price_cents: 49000,
|
||||||
|
} as PlanBillingState),
|
||||||
|
planLimits: {},
|
||||||
|
enabledFeatures: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<BillingPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BillingPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getPortalSession.mockReset()
|
||||||
|
toastError.mockReset()
|
||||||
|
useBillingStore.setState({
|
||||||
|
subscription: null,
|
||||||
|
planBilling: null,
|
||||||
|
planLimits: {},
|
||||||
|
enabledFeatures: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders subscription summary from useBillingStore', () => {
|
||||||
|
setBilling({
|
||||||
|
subscription: {
|
||||||
|
status: 'active',
|
||||||
|
plan: 'pro',
|
||||||
|
current_period_start: '2026-04-01T00:00:00Z',
|
||||||
|
current_period_end: '2026-05-01T00:00:00Z',
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
seat_limit: 5,
|
||||||
|
has_pro_entitlement: true,
|
||||||
|
is_paid: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: 'Billing' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Pro')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||||
|
// Seats shown
|
||||||
|
expect(screen.getByText('5')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows trial-ends message + Pick a plan CTA when trialing', () => {
|
||||||
|
setBilling({
|
||||||
|
subscription: {
|
||||||
|
status: 'trialing',
|
||||||
|
plan: 'pro',
|
||||||
|
current_period_start: '2026-04-22T00:00:00Z',
|
||||||
|
current_period_end: '2026-05-06T00:00:00Z',
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
seat_limit: 5,
|
||||||
|
has_pro_entitlement: true,
|
||||||
|
is_paid: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('trial-message').textContent).toMatch(/Trial ends/)
|
||||||
|
const pickPlan = screen.getByTestId('select-plan-link')
|
||||||
|
expect(pickPlan.getAttribute('href')).toBe('/account/billing/select-plan')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows past-due banner with update payment CTA when status=past_due', () => {
|
||||||
|
setBilling({
|
||||||
|
subscription: {
|
||||||
|
status: 'past_due',
|
||||||
|
plan: 'pro',
|
||||||
|
current_period_start: '2026-04-01T00:00:00Z',
|
||||||
|
current_period_end: '2026-05-01T00:00:00Z',
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
seat_limit: 5,
|
||||||
|
has_pro_entitlement: false,
|
||||||
|
is_paid: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('past-due-banner')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('past-due-update-payment')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders complimentary message and hides CTAs when complimentary', () => {
|
||||||
|
setBilling({
|
||||||
|
subscription: {
|
||||||
|
status: 'complimentary',
|
||||||
|
plan: 'pro',
|
||||||
|
current_period_start: '2026-04-01T00:00:00Z',
|
||||||
|
current_period_end: null,
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
seat_limit: null,
|
||||||
|
has_pro_entitlement: true,
|
||||||
|
is_paid: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('complimentary-message')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('manage-billing-button')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('select-plan-link')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('change-plan-link')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders canceled message + Pick a plan CTA when canceled', () => {
|
||||||
|
setBilling({
|
||||||
|
subscription: {
|
||||||
|
status: 'canceled',
|
||||||
|
plan: 'pro',
|
||||||
|
current_period_start: '2026-03-01T00:00:00Z',
|
||||||
|
current_period_end: '2026-04-01T00:00:00Z',
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
seat_limit: 5,
|
||||||
|
has_pro_entitlement: false,
|
||||||
|
is_paid: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('canceled-message')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('select-plan-link')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows toast when portal session fails with no_stripe_customer', async () => {
|
||||||
|
setBilling({
|
||||||
|
subscription: {
|
||||||
|
status: 'active',
|
||||||
|
plan: 'pro',
|
||||||
|
current_period_start: '2026-04-01T00:00:00Z',
|
||||||
|
current_period_end: '2026-05-01T00:00:00Z',
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
seat_limit: 5,
|
||||||
|
has_pro_entitlement: true,
|
||||||
|
is_paid: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
getPortalSession.mockRejectedValueOnce(
|
||||||
|
new BillingPortalError('no_stripe_customer'),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
fireEvent.click(screen.getByTestId('manage-billing-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toastError).toHaveBeenCalledWith(
|
||||||
|
'Complete checkout first to access billing portal.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
178
frontend/src/pages/account/__tests__/SelectPlanPage.test.tsx
Normal file
178
frontend/src/pages/account/__tests__/SelectPlanPage.test.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { SelectPlanPage } from '../SelectPlanPage'
|
||||||
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
|
|
||||||
|
vi.mock('@/api/billing', () => ({
|
||||||
|
billingApi: {
|
||||||
|
createCheckoutSession: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('@/api/plans', () => ({
|
||||||
|
plansApi: {
|
||||||
|
getPublic: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/toast', () => ({
|
||||||
|
toast: {
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { billingApi } from '@/api/billing'
|
||||||
|
import { plansApi } from '@/api/plans'
|
||||||
|
|
||||||
|
const createCheckoutSession = billingApi.createCheckoutSession as unknown as ReturnType<typeof vi.fn>
|
||||||
|
const getPublic = plansApi.getPublic as unknown as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
const PLAN_FIXTURE = [
|
||||||
|
{
|
||||||
|
plan: 'starter',
|
||||||
|
display_name: 'Starter',
|
||||||
|
description: 'For solo techs.',
|
||||||
|
monthly_price_cents: 1900,
|
||||||
|
annual_price_cents: 19000,
|
||||||
|
max_seats: 1,
|
||||||
|
sort_order: 1,
|
||||||
|
is_public: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
plan: 'pro',
|
||||||
|
display_name: 'Pro',
|
||||||
|
description: 'For growing teams.',
|
||||||
|
monthly_price_cents: 4900,
|
||||||
|
annual_price_cents: 49000,
|
||||||
|
max_seats: 5,
|
||||||
|
sort_order: 2,
|
||||||
|
is_public: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
plan: 'enterprise',
|
||||||
|
display_name: 'Enterprise',
|
||||||
|
description: 'Custom.',
|
||||||
|
monthly_price_cents: null,
|
||||||
|
annual_price_cents: null,
|
||||||
|
max_seats: null,
|
||||||
|
sort_order: 3,
|
||||||
|
is_public: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<SelectPlanPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SelectPlanPage', () => {
|
||||||
|
// Stub window.location.href setter so we can assert without a real navigation.
|
||||||
|
let assignedHref: string | null = null
|
||||||
|
const originalLocation = window.location
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getPublic.mockReset()
|
||||||
|
createCheckoutSession.mockReset()
|
||||||
|
assignedHref = null
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
...originalLocation,
|
||||||
|
get href() {
|
||||||
|
return assignedHref ?? originalLocation.href
|
||||||
|
},
|
||||||
|
set href(v: string) {
|
||||||
|
assignedHref = v
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
useBillingStore.setState({
|
||||||
|
subscription: null,
|
||||||
|
planBilling: null,
|
||||||
|
planLimits: {},
|
||||||
|
enabledFeatures: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders plan cards from plansApi', async () => {
|
||||||
|
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('plan-card-starter')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('plan-card-enterprise')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Continue to checkout calls createCheckoutSession and redirects', async () => {
|
||||||
|
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
||||||
|
createCheckoutSession.mockResolvedValueOnce({ url: 'https://checkout.stripe.com/abc' })
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bump seats and switch to annual.
|
||||||
|
fireEvent.change(screen.getByTestId('seats-input'), { target: { value: '3' } })
|
||||||
|
fireEvent.click(screen.getByTestId('interval-annual'))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('plan-cta-pro'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createCheckoutSession).toHaveBeenCalledWith({
|
||||||
|
plan: 'pro',
|
||||||
|
seats: 3,
|
||||||
|
billing_interval: 'annual',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(assignedHref).toBe('https://checkout.stripe.com/abc')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Talk to sales links to /contact-sales for enterprise', async () => {
|
||||||
|
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('plan-cta-enterprise')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
const cta = screen.getByTestId('plan-cta-enterprise') as HTMLAnchorElement
|
||||||
|
expect(cta.getAttribute('href')).toBe('/contact-sales')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the active current plan as Current plan and disables its CTA', async () => {
|
||||||
|
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
||||||
|
useBillingStore.setState({
|
||||||
|
subscription: {
|
||||||
|
status: 'active',
|
||||||
|
plan: 'pro',
|
||||||
|
current_period_start: '2026-04-01T00:00:00Z',
|
||||||
|
current_period_end: '2026-05-01T00:00:00Z',
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
seat_limit: 5,
|
||||||
|
has_pro_entitlement: true,
|
||||||
|
is_paid: true,
|
||||||
|
},
|
||||||
|
planBilling: null,
|
||||||
|
planLimits: {},
|
||||||
|
enabledFeatures: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('plan-current-pro')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
const cta = screen.getByTestId('plan-cta-pro') as HTMLButtonElement
|
||||||
|
expect(cta).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user