feat: self-serve signup Phase 2 (frontend cutover) (#162)
Co-authored-by: Michael Chihlas <michael@resolutionflow.com> Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
This commit was merged in pull request #162.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
# CURRENT_TASK.md
|
||||
|
||||
**Active task:** None — pick next from `.ai/TODO.md` or `03-DEVELOPMENT-ROADMAP.md`.
|
||||
**Active task:** Self-serve signup Phase 2 — PR #162 is open on `feat/self-serve-signup-phase-2`. Current focus is resolving its failing Gitea checks. Phase O manual ops (Stripe live setup, internal validation, flag flip) remain pending after review/merge. See `.ai/HANDOFF.md` for the resume point.
|
||||
|
||||
## 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-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.
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-07 — Standardize backend Python on 3.12
|
||||
|
||||
**Context:** Runtime facts had drifted from docs. The backend Dockerfiles and running dev container were already on Python 3.12, GitHub CI had just been updated to 3.12, but project docs still said Python 3.11 and Gitea CI relied on the runner's ambient Python.
|
||||
|
||||
**Decision:** Treat Python 3.12 as the backend standard. Pin local pyenv via `.python-version` to 3.12.13, matching the current `python:3.12-slim` container patch level. Add explicit Python 3.12 setup to Gitea CI and keep GitHub CI on Python 3.12.
|
||||
|
||||
**Rejected:** Moving Docker/runtime back to Python 3.11. The application was already building and running on 3.12, so reverting the runtime would add churn without a product or dependency reason.
|
||||
|
||||
**Consequences:** Native backend work should use `backend/venv` created from Python 3.12.13. Future docs/CI/runtime changes should preserve Python 3.12 unless a deliberate upgrade decision is recorded.
|
||||
|
||||
## 2026-04-30 — Add `applied_pending` non-terminal status to suggested fixes
|
||||
|
||||
**Context:** The verifying banner forces a synchronous verdict — worked / didn't / partial — but a lot of real MSP fixes are async. Engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. With only the existing outcomes, the engineer either leaves the banner stale (eroding the verifying signal) or guesses wrong (corrupting outcome data). User flagged the gap directly. Today's `NudgeBanner` "Still checking" button just silences the nudge — it doesn't tell the system anything.
|
||||
|
||||
@@ -2,35 +2,56 @@
|
||||
|
||||
# HANDOFF.md
|
||||
|
||||
**Last updated:** 2026-05-06 (Phase 1 backend complete on `feat/self-serve-signup-spec`)
|
||||
**Last updated:** 2026-05-07 (PR #162 CI investigation/fixes)
|
||||
|
||||
**Active task:** Phase 1 self-serve signup backend foundation — DONE on branch. PR not yet opened.
|
||||
**Active task:** PR #162 (`feat/self-serve-signup-phase-2`) is open in Gitea. Current session is resolving its failing checks.
|
||||
|
||||
## 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`.
|
||||
PR #162 originally failed quickly in Gitea CI. Public Gitea status metadata was available, but job logs redirected to login and no `GITEA_TOKEN` was present. The branch was pushed over SSH.
|
||||
|
||||
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.
|
||||
Fixed environment drift first:
|
||||
|
||||
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).
|
||||
- Standardized backend native/dev/CI Python on 3.12.13 to match Docker.
|
||||
- Added `.python-version`.
|
||||
- Rebuilt `backend/venv` from pyenv Python 3.12.13 and verified native `pytest --version` / `alembic --version` with explicit local env.
|
||||
- Updated Gitea CI backend/e2e Python setup to 3.12.
|
||||
|
||||
## Resume point — DO THIS NEXT
|
||||
Fixed Gitea runner assumptions 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).
|
||||
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.
|
||||
- Added `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs.
|
||||
- Pushed `fix(ci): set up node in gitea workflow`.
|
||||
|
||||
## Followups deferred from this session
|
||||
Local frontend validation then exposed real lint failures in Phase 2 React code under the current lint stack. The current WIP fixes:
|
||||
|
||||
- **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`.
|
||||
- **`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.
|
||||
- **`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.
|
||||
- **Pilot backfill migration `c6cbfc534fad` has not been applied to prod yet.** It runs once at deploy time and is forward-only.
|
||||
- `react-refresh/only-export-components` for exported pure helpers used by tests/shared invite OAuth code.
|
||||
- `react-hooks/set-state-in-effect` warnings where local state intentionally mirrors route/config/cache state.
|
||||
- `react-hooks/purity` warnings from `Date.now()` during render.
|
||||
- Redundant loading-state write in pricing page.
|
||||
|
||||
## Environment notes (carry-forward)
|
||||
Validation after those frontend changes:
|
||||
|
||||
- Code-server LXC has bun + docker but no native python/node/npm. Use `docker exec resolutionflow_{backend,frontend} ...` for build/test commands.
|
||||
- Pytest WORKDIR is `/app` — test paths in pytest commands are `tests/<file>`, NOT `backend/tests/<file>`.
|
||||
- Backend pytest cmd: `docker exec resolutionflow_backend pytest tests/<path> -v --override-ini="addopts="`. The full run takes ~25 min.
|
||||
- Alembic via `docker exec -w /app resolutionflow_backend alembic ...`. Never pass `--rev-id`.
|
||||
- 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.
|
||||
- Headless Chromium (`/qa`, `/browse`) needs `CONTAINER=1` in the env launching the browse server (LXC namespace constraint).
|
||||
- `docker exec -w /app resolutionflow_frontend npm run lint` passed.
|
||||
- `docker exec -w /app resolutionflow_frontend npm run test:coverage` passed (`198` tests).
|
||||
- `docker exec -w /app -e NODE_OPTIONS=--max-old-space-size=4096 resolutionflow_frontend npm run build` passed.
|
||||
|
||||
Known local noise:
|
||||
|
||||
- React `act(...)` warnings appeared in existing tests during coverage but did not fail the suite.
|
||||
- Vite emitted large chunk warnings during build.
|
||||
- Unrelated dirty/untracked files remain and should not be staged unless explicitly requested: `docker-compose.dev.yml`, `.env.example`, `abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`, `core.*`, `docs/architecture/`, `docs/tutorials/`.
|
||||
|
||||
## Resume point
|
||||
|
||||
1. Commit the frontend lint fixes and `.ai/` handoff updates with the required Codex trailer.
|
||||
2. Push `feat/self-serve-signup-phase-2`.
|
||||
3. Poll Gitea PR #162 statuses for the new head SHA:
|
||||
`curl -fsSL https://gitea.resolutionflow.com/api/v1/repos/chihlasm/resolutionflow/statuses/<sha> | python -m json.tool`
|
||||
4. If statuses are still pending, report that local frontend CI is green and Gitea runner work is queued/running. If a check fails, public statuses may show only the context/description; logs require authenticated Gitea access.
|
||||
|
||||
## Carry-forward
|
||||
|
||||
- Phase O manual ops remain pending after PR review/merge: Stripe live setup, internal validation, feature-flag flip.
|
||||
- Backend env: `SALES_LEAD_RECIPIENT_EMAIL`.
|
||||
- Frontend env: `VITE_SELF_SERVE_ENABLED`, `VITE_GOOGLE_CLIENT_ID`, `VITE_MS_CLIENT_ID`, `VITE_OAUTH_REDIRECT_BASE`, `VITE_CALENDLY_URL`.
|
||||
- Single alembic head remains `c6cbfc534fad`; Phase 2 added no migrations.
|
||||
|
||||
@@ -26,7 +26,7 @@ Go-to-Market Validation (pre-PMF). Backend feature-complete (55+ endpoints, 100+
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Backend:** Python 3.11 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
|
||||
- **Backend:** Python 3.12 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
|
||||
- **Frontend:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config in `index.css`), Zustand (immer + zundo), React Router v7, Axios (token-refresh interceptor), Lucide.
|
||||
- **DB:** PostgreSQL 16 (RLS enabled Phase 4, pgvector).
|
||||
|
||||
|
||||
@@ -12,6 +12,41 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-07 11:45 EDT — Codex — Push PR #162 CI runner setup fixes
|
||||
|
||||
- Inspected Gitea PR #162 via public API. PR head was `380fcf7` and all CI jobs failed quickly; pushed local commits through `4a37a47`, including Python 3.12 setup for Gitea backend/e2e jobs.
|
||||
- New run on `4a37a47` showed frontend still failed quickly while backend/e2e remained pending. Root cause likely same class of runner drift: Gitea frontend/e2e jobs used `npm` without setting up Node.
|
||||
- Added explicit `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs. This keeps CI from relying on runner ambient Node/npm.
|
||||
- Files touched: `.gitea/workflows/ci.yml`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-07 11:30 EDT — Codex — Standardize backend Python on 3.12
|
||||
|
||||
- Standardized repo declarations around Python 3.12: added `.python-version` pinned to 3.12.13, updated stale Python 3.11 docs, and added explicit Python 3.12 setup steps to Gitea CI. GitHub CI was already updated to Python 3.12 by the user.
|
||||
- Installed pyenv Python 3.12.13 and created `backend/venv` from that interpreter. Installed `backend/requirements-dev.txt` into the venv.
|
||||
- Verified native `python --version` and venv `python --version` both report 3.12.13. Verified native `pytest 8.4.2` and `alembic 1.18.3` with explicit safe test env vars; plain pytest import still depends on local `.env` values being valid.
|
||||
- Rebuilt and restarted the dev backend container with `docker compose -f docker-compose.dev.yml build backend` and `up -d backend`; confirmed `docker exec resolutionflow_backend python --version` reports 3.12.13.
|
||||
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `README.md`, `DEV-ENV.md`, `.ai/PROJECT_CONTEXT.md`, `.ai/DECISIONS.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-07 11:14 EDT — Codex — Recheck native Python availability
|
||||
|
||||
- Re-ran the startup ritual and checked the host Python state after the user reported fixing the missing native Python issue.
|
||||
- Verified `python` and `python3` resolve to `/config/.pyenv/shims/*` and run Python 3.12.10. `pip` and `pip3` are available as pip 25.0.1 under the same pyenv install.
|
||||
- Confirmed there is no native `python3.11`, pyenv currently lists only `3.12.10`, no repo virtualenv exists under `backend/venv`, `backend/.venv`, or root `.venv`, and `python -m pytest --version` from `backend/` fails with `No module named pytest`.
|
||||
- Conclusion: native Python is present, but it is not yet a ready backend dev/test environment for ResolutionFlow. Docker remains the reliable path for pytest/alembic until a Python 3.11 virtualenv with `backend/requirements*.txt` is installed.
|
||||
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 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)
|
||||
|
||||
- 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`.
|
||||
@@ -301,3 +336,13 @@
|
||||
- Files touched: `.ai/*.md` (created), `CLAUDE.md` (rewritten), `AGENTS.md` (created), `SESSION-HANDOFF.md` (deleted).
|
||||
- Follow-up (same day): Codex review pass flagged stale SaaS-role claim and incomplete file-listings carried over from the pre-migration CLAUDE.md. Verified against `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`, `backend/app/api/deps.py`, `backend/app/api/router.py`, and `backend/app/services/psa/`. Corrected PROJECT_CONTEXT.md role hierarchy (`super_admin > owner > engineer > viewer`, not `team_admin`), added `require_account_owner` / `require_team_admin` to deps list, replaced stale endpoint comment with a summary pointing at `api/router.py`, added `exceptions.py` + `ticket_context.py` to the PSA file list. Also replaced seed-example content in `CURRENT_TASK.md` and `TODO.md` with clearer empty-state sentinels.
|
||||
- Branch cleanup (same day): committed pending test-isolation work as `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`, new Phase 9 review doc as `b3506b5 docs(pilot): phase 9 review issues`, and `.remember/` gitignore entry as `b3be1e0 chore: ignore .remember/ skill runtime state`. Deleted `docs/landing-handoff/` (prepared for external design work, not meant to live in the repo). Working tree clean; 3 cleanup commits unpushed.
|
||||
|
||||
## 2026-05-07 UTC — Codex — Resolve PR #162 CI failures
|
||||
|
||||
- Investigated Gitea PR #162 failing checks for `feat/self-serve-signup-phase-2`. Public status metadata was available, but job logs required Gitea login and no token was present.
|
||||
- Standardized backend development/CI Python on 3.12.13 to match the Docker image: added `.python-version`, updated Gitea CI Python setup, rebuilt the local backend virtualenv, and verified native `pytest` / `alembic` command availability with explicit local env.
|
||||
- Added explicit Node 20 setup to Gitea frontend and e2e jobs so CI no longer depends on the runner's ambient Node installation.
|
||||
- Reproduced the remaining frontend failure locally. Lint failed on Phase 2 React code because the current eslint stack flags exported pure helpers, render-time `Date.now()`, and effect-driven state synchronization.
|
||||
- Patched the affected frontend surfaces narrowly: dashboard helper exports, app-config cache handling, feature-limit cache/fetch state, trial-banner time capture, invite/OAuth route error state, pricing loading state, and OAuth authorize URL helper export.
|
||||
- Verified sequential frontend CI locally in Docker: `npm run lint` passed, `npm run test:coverage` passed (`198` tests), and `npm run build` passed with only Vite chunk-size warnings.
|
||||
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `.ai/*`, `README.md`, `DEV-ENV.md`, and the frontend lint-fix files under `frontend/src/components/dashboard`, `frontend/src/hooks`, and `frontend/src/pages`.
|
||||
|
||||
@@ -46,6 +46,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -105,6 +110,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -171,6 +181,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -37,10 +37,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python 3.11
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
backend/requirements.txt
|
||||
@@ -143,10 +143,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python 3.11
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
backend/requirements.txt
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12.13
|
||||
@@ -108,7 +108,7 @@ Run these in order. Stop at the first failure and investigate.
|
||||
# Ubuntu / Debian
|
||||
sudo apt update && sudo apt install -y \
|
||||
git curl build-essential \
|
||||
python3.11 python3.11-venv python3-pip \
|
||||
python3.12 python3.12-venv python3-pip \
|
||||
postgresql-client # not the server — only if running Postgres natively
|
||||
|
||||
# Node 20 via nvm (survives container rebuilds if stored in a volume)
|
||||
@@ -236,7 +236,7 @@ REPO_ROOT=/absolute/path/to/resolutionflow
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3.11 -m venv venv
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Prerequisites: Docker, Python 3.11+, Node.js 20+
|
||||
# Prerequisites: Docker, Python 3.12, Node.js 20+
|
||||
|
||||
# Start PostgreSQL
|
||||
docker start patherly_postgres
|
||||
|
||||
@@ -22,3 +22,11 @@ VOYAGE_API_KEY=
|
||||
|
||||
# ConnectWise PSA Integration
|
||||
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/users/me",
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
"/api/v1/users/me/onboarding-dismiss-rest",
|
||||
}
|
||||
|
||||
|
||||
@@ -298,6 +299,8 @@ _EMAIL_VERIFICATION_ALLOWLIST = {
|
||||
"/api/v1/auth/email/verify",
|
||||
"/api/v1/auth/password/change",
|
||||
"/api/v1/users/me",
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
"/api/v1/users/me/onboarding-dismiss-rest",
|
||||
"/api/v1/billing/state",
|
||||
"/api/v1/billing/checkout-session",
|
||||
"/api/v1/billing/portal-session",
|
||||
|
||||
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.models.user import User
|
||||
from app.models.plan_limits import PlanLimits
|
||||
from app.models.plan_billing import PlanBilling
|
||||
from app.models.account import Account
|
||||
from app.models.account_limit_override import AccountLimitOverride
|
||||
from app.models.subscription import Subscription
|
||||
from app.schemas.admin import (
|
||||
PlanLimitResponse, PlanLimitUpdate,
|
||||
PlanLimitResponse, PlanLimitUpdate, PlanLimitWithBillingResponse,
|
||||
AccountOverrideCreate, AccountOverrideUpdate, AccountOverrideResponse,
|
||||
)
|
||||
from app.api.deps import require_admin
|
||||
from app.services.billing import BillingService
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin-plan-limits"])
|
||||
|
||||
|
||||
@router.get("/plan-limits", response_model=list[PlanLimitResponse])
|
||||
# Fields on PlanLimitUpdate that map to plan_billing (not plan_limits).
|
||||
_PLAN_BILLING_FIELDS = (
|
||||
"display_name",
|
||||
"description",
|
||||
"monthly_price_cents",
|
||||
"annual_price_cents",
|
||||
"stripe_product_id",
|
||||
"stripe_monthly_price_id",
|
||||
"stripe_annual_price_id",
|
||||
"is_public",
|
||||
"is_archived",
|
||||
"sort_order",
|
||||
)
|
||||
|
||||
# Subset of _PLAN_BILLING_FIELDS that are NOT NULL on the PlanBilling model.
|
||||
# These are Optional[...] on PlanLimitUpdate, so a caller sending an explicit
|
||||
# null for any of them would otherwise trigger a NOT NULL violation at commit.
|
||||
_PLAN_BILLING_NOT_NULL_FIELDS = frozenset({
|
||||
"display_name",
|
||||
"is_public",
|
||||
"is_archived",
|
||||
"sort_order",
|
||||
})
|
||||
|
||||
|
||||
def _merge_plan_with_billing(
|
||||
plan: PlanLimits, billing: PlanBilling | None
|
||||
) -> PlanLimitWithBillingResponse:
|
||||
"""Build a merged response. Billing fields are None when no plan_billing row
|
||||
exists for the plan."""
|
||||
payload = {
|
||||
"plan": plan.plan,
|
||||
"max_trees": plan.max_trees,
|
||||
"max_sessions_per_month": plan.max_sessions_per_month,
|
||||
"max_users": plan.max_users,
|
||||
"custom_branding": plan.custom_branding,
|
||||
"priority_support": plan.priority_support,
|
||||
"export_formats": plan.export_formats or [],
|
||||
}
|
||||
if billing is not None:
|
||||
payload.update({
|
||||
"display_name": billing.display_name,
|
||||
"description": billing.description,
|
||||
"monthly_price_cents": billing.monthly_price_cents,
|
||||
"annual_price_cents": billing.annual_price_cents,
|
||||
"stripe_product_id": billing.stripe_product_id,
|
||||
"stripe_monthly_price_id": billing.stripe_monthly_price_id,
|
||||
"stripe_annual_price_id": billing.stripe_annual_price_id,
|
||||
"is_public": billing.is_public,
|
||||
"is_archived": billing.is_archived,
|
||||
"sort_order": billing.sort_order,
|
||||
})
|
||||
return PlanLimitWithBillingResponse(**payload)
|
||||
|
||||
|
||||
@router.get("/plan-limits", response_model=list[PlanLimitWithBillingResponse])
|
||||
async def list_plan_limits(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""List all plan limit configurations."""
|
||||
result = await db.execute(select(PlanLimits))
|
||||
return result.scalars().all()
|
||||
"""List all plan limit configurations, merged with plan_billing fields
|
||||
where present. Plans without a plan_billing row return None for the
|
||||
billing fields."""
|
||||
rows = (await db.execute(
|
||||
select(PlanLimits, PlanBilling)
|
||||
.outerjoin(PlanBilling, PlanLimits.plan == PlanBilling.plan)
|
||||
)).all()
|
||||
return [_merge_plan_with_billing(pl, pb) for pl, pb in rows]
|
||||
|
||||
|
||||
@router.put("/plan-limits", response_model=PlanLimitResponse)
|
||||
@router.put("/plan-limits", response_model=PlanLimitWithBillingResponse)
|
||||
async def update_plan_limits(
|
||||
data: PlanLimitUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Update a plan's limits."""
|
||||
"""Update a plan's limits and (if any plan_billing field is included)
|
||||
upsert the matching plan_billing row in the same transaction. After
|
||||
commit, invalidates the in-process billing cache for accounts on this
|
||||
plan (currently a no-op — see BillingService.invalidate_billing_cache).
|
||||
"""
|
||||
result = await db.execute(select(PlanLimits).where(PlanLimits.plan == data.plan))
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
@@ -48,10 +115,50 @@ async def update_plan_limits(
|
||||
plan.priority_support = data.priority_support
|
||||
plan.export_formats = data.export_formats
|
||||
|
||||
await log_audit(db, current_user.id, "plan_limits.update", "plan_limits", details={"plan": data.plan})
|
||||
# Did the request include any plan_billing field? (Pydantic gives us
|
||||
# `model_fields_set` to distinguish "user passed null" from "field omitted".)
|
||||
billing_fields_set = data.model_fields_set & set(_PLAN_BILLING_FIELDS)
|
||||
billing: PlanBilling | None = None
|
||||
if billing_fields_set:
|
||||
billing = (await db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == data.plan)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if billing is None:
|
||||
# Create. display_name is required on the model — derive from the
|
||||
# plan name when the caller didn't supply one (e.g. "pro" → "Pro").
|
||||
display_name = data.display_name or data.plan.capitalize()
|
||||
billing = PlanBilling(plan=data.plan, display_name=display_name)
|
||||
db.add(billing)
|
||||
|
||||
# Apply only the fields the caller actually included. Allows partial
|
||||
# updates without clobbering existing values.
|
||||
for field in billing_fields_set:
|
||||
value = getattr(data, field)
|
||||
if value is None and field in _PLAN_BILLING_NOT_NULL_FIELDS:
|
||||
# Don't NULL out a NOT NULL column on update.
|
||||
continue
|
||||
setattr(billing, field, value)
|
||||
|
||||
await log_audit(
|
||||
db, current_user.id, "plan_limits.update", "plan_limits",
|
||||
details={"plan": data.plan, "updated_billing": bool(billing_fields_set)},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(plan)
|
||||
return plan
|
||||
if billing is not None:
|
||||
await db.refresh(billing)
|
||||
|
||||
# Invalidate any in-process billing cache for accounts on this plan.
|
||||
# TODO: invalidate app.state.billing_cache when added.
|
||||
account_ids = [
|
||||
row[0] for row in (await db.execute(
|
||||
select(Subscription.account_id).where(Subscription.plan == data.plan)
|
||||
)).all()
|
||||
]
|
||||
await BillingService.invalidate_billing_cache(account_ids)
|
||||
|
||||
return _merge_plan_with_billing(plan, billing)
|
||||
|
||||
|
||||
@router.get("/account-overrides", response_model=list[AccountOverrideResponse])
|
||||
|
||||
@@ -47,8 +47,16 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
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."""
|
||||
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.
|
||||
|
||||
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)
|
||||
if payload and payload.get("jti"):
|
||||
token_record = RefreshToken(
|
||||
@@ -136,7 +144,15 @@ async def register(
|
||||
# Validate platform invite code (skip if account invite was provided)
|
||||
invite_code_record = None
|
||||
if not account_invite_record:
|
||||
if settings.REQUIRE_INVITE_CODE and not user_data.invite_code:
|
||||
# When SELF_SERVE_ENABLED is on, the platform invite gate is bypassed
|
||||
# entirely — public self-serve signup is the whole point. The
|
||||
# invite_code field stays in the schema for backward compatibility
|
||||
# and so paid/trial-bearing codes still apply when supplied.
|
||||
if (
|
||||
settings.REQUIRE_INVITE_CODE
|
||||
and not settings.SELF_SERVE_ENABLED
|
||||
and not user_data.invite_code
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invite code is required"
|
||||
@@ -312,7 +328,7 @@ async def login(
|
||||
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# 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()
|
||||
|
||||
return Token(
|
||||
@@ -347,7 +363,7 @@ async def login_json(
|
||||
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# 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()
|
||||
|
||||
return Token(
|
||||
@@ -405,7 +421,7 @@ async def refresh_token(
|
||||
new_refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# 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()
|
||||
|
||||
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
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from app.core.email import EmailService
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/beta-signup", tags=["beta"])
|
||||
|
||||
|
||||
class BetaSignupRequest(BaseModel):
|
||||
email: EmailStr
|
||||
# Local-dev fallback when FRONTEND_URL isn't configured. The redirect must
|
||||
# be absolute — a relative URL would resolve against the API origin
|
||||
# (api.resolutionflow.com), which has no /register page.
|
||||
_DEFAULT_FRONTEND_URL = "http://localhost:5173"
|
||||
|
||||
|
||||
class BetaSignupResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
@router.post("", include_in_schema=False)
|
||||
async def beta_signup_redirect() -> RedirectResponse:
|
||||
"""Redirect legacy beta-signup POST to the public register page.
|
||||
|
||||
|
||||
@router.post("", response_model=BetaSignupResponse)
|
||||
async def beta_signup(data: BetaSignupRequest):
|
||||
"""Collect beta interest — sends notification to beta@resolutionflow.com."""
|
||||
sent = await EmailService.send_beta_signup_notification(data.email)
|
||||
if not sent:
|
||||
logger.warning("Beta signup recorded (email delivery skipped): %s", data.email)
|
||||
return BetaSignupResponse(
|
||||
success=True,
|
||||
message="Thanks! We'll be in touch with beta access details.",
|
||||
Returns 307 so any client following the redirect preserves the HTTP
|
||||
method; the frontend treats `/register?from=beta` as the canonical
|
||||
entry point and reads the `from` query param for analytics.
|
||||
"""
|
||||
frontend_url = settings.FRONTEND_URL or _DEFAULT_FRONTEND_URL
|
||||
return RedirectResponse(
|
||||
url=f"{frontend_url}/register?from=beta",
|
||||
status_code=307,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.core.config import settings
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.schemas.billing import (
|
||||
BillingPortalSessionResponse,
|
||||
BillingStateResponse,
|
||||
CheckoutSessionCreate,
|
||||
CheckoutSessionResponse,
|
||||
@@ -50,3 +51,26 @@ async def get_billing_state(
|
||||
)).scalar_one()
|
||||
state = await BillingService.get_billing_state(db, account)
|
||||
return BillingStateResponse(**state)
|
||||
|
||||
|
||||
@router.get("/portal-session", response_model=BillingPortalSessionResponse)
|
||||
async def get_billing_portal_session(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> BillingPortalSessionResponse:
|
||||
"""Return a Stripe-hosted Customer Portal URL for the account so the user
|
||||
can update card / cancel. Allowlisted from the subscription + email-verify
|
||||
guards (a canceled or unverified-past-grace user must still be able to
|
||||
update billing)."""
|
||||
if not settings.stripe_enabled:
|
||||
raise HTTPException(status_code=503, detail={"error": "stripe_not_configured"})
|
||||
|
||||
account = (await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)).scalar_one()
|
||||
|
||||
try:
|
||||
url = await BillingService.open_customer_portal(account)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail={"error": "no_stripe_customer"})
|
||||
return BillingPortalSessionResponse(url=url)
|
||||
|
||||
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.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.config import settings
|
||||
from app.core.security import create_access_token, create_refresh_token
|
||||
from app.models.account import Account
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
from app.models.user import User
|
||||
from app.schemas.oauth import OAuthCallbackPayload, OAuthCallbackResponse
|
||||
@@ -31,9 +33,21 @@ def _generate_display_code(length: int = 8) -> str:
|
||||
|
||||
|
||||
async def _sign_in_or_register(
|
||||
db: AsyncSession, provider: str, profile: OAuthProfile
|
||||
db: AsyncSession,
|
||||
provider: str,
|
||||
profile: OAuthProfile,
|
||||
*,
|
||||
account_invite_code: str | None = None,
|
||||
invited_email: str | None = None,
|
||||
) -> tuple[User, bool]:
|
||||
"""Returns (user, is_new_user). Idempotent on (provider, provider_subject)."""
|
||||
"""Returns (user, is_new_user). Idempotent on (provider, provider_subject).
|
||||
|
||||
When ``account_invite_code`` is supplied (from the /accept-invite flow),
|
||||
a brand-new user is created inside the invited account instead of getting
|
||||
a personal account + Pro trial. Mismatch between the OAuth profile email
|
||||
and ``invited_email`` raises ``invite_email_mismatch`` per the spec
|
||||
contract that mirrors the email+password register path.
|
||||
"""
|
||||
identity = (
|
||||
await db.execute(
|
||||
select(OAuthIdentity).where(
|
||||
@@ -53,28 +67,96 @@ async def _sign_in_or_register(
|
||||
await db.execute(select(User).where(User.email == profile.email))
|
||||
).scalar_one_or_none()
|
||||
is_new_user = user is None
|
||||
|
||||
# If the user arrived via an invite link but already has a ResolutionFlow
|
||||
# account (e.g., previously signed up with email+password), silently
|
||||
# linking the OAuth identity to that existing account would bypass the
|
||||
# invite — they'd stay in their personal account and the invite would
|
||||
# never be consumed. Fail loud instead so they can sign in and accept the
|
||||
# invite from the dashboard. The "invited user wants to transfer accounts"
|
||||
# case is a v2 concern.
|
||||
if account_invite_code and not is_new_user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "email_already_registered_use_login",
|
||||
"message": (
|
||||
"An account already exists for this email. Please sign in "
|
||||
"instead, then accept the invite from your dashboard."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
invite_record: AccountInvite | None = None
|
||||
if is_new_user and account_invite_code:
|
||||
# SELECT FOR UPDATE so two concurrent OAuth callbacks can't both
|
||||
# consume the same invite code.
|
||||
invite_record = (
|
||||
await db.execute(
|
||||
select(AccountInvite)
|
||||
.where(AccountInvite.code == account_invite_code)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if invite_record is None or not invite_record.is_valid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invite_invalid_or_expired_or_revoked"},
|
||||
)
|
||||
# Verify the OAuth profile email matches what was invited. We compare
|
||||
# against the invite row directly (source of truth), but also accept
|
||||
# the client-supplied invited_email as a defensive equality check.
|
||||
if invite_record.email.lower() != profile.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invite_email_mismatch"},
|
||||
)
|
||||
if invited_email and invited_email.lower() != invite_record.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invite_email_mismatch"},
|
||||
)
|
||||
|
||||
if is_new_user:
|
||||
account = Account(
|
||||
name=f"{profile.name}'s Account",
|
||||
display_code=_generate_display_code(),
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email=profile.email,
|
||||
name=profile.name,
|
||||
password_hash=None,
|
||||
account_id=account.id,
|
||||
account_role="owner",
|
||||
role="engineer",
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
account.owner_id = user.id
|
||||
await db.flush()
|
||||
# start_trial commits internally; flushed account/user above.
|
||||
await BillingService.start_trial(db, account.id)
|
||||
if invite_record is not None:
|
||||
# Join the invited account directly — no personal account, no
|
||||
# trial creation.
|
||||
user = User(
|
||||
email=profile.email,
|
||||
name=profile.name,
|
||||
password_hash=None,
|
||||
account_id=invite_record.account_id,
|
||||
account_role=invite_record.role,
|
||||
role="engineer",
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
invite_record.accepted_by_id = user.id
|
||||
invite_record.used_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
else:
|
||||
account = Account(
|
||||
name=f"{profile.name}'s Account",
|
||||
display_code=_generate_display_code(),
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email=profile.email,
|
||||
name=profile.name,
|
||||
password_hash=None,
|
||||
account_id=account.id,
|
||||
account_role="owner",
|
||||
role="engineer",
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
account.owner_id = user.id
|
||||
await db.flush()
|
||||
# start_trial commits internally; flushed account/user above.
|
||||
await BillingService.start_trial(db, account.id)
|
||||
|
||||
db.add(
|
||||
OAuthIdentity(
|
||||
@@ -98,10 +180,23 @@ async def google_callback(
|
||||
raise HTTPException(status_code=503, detail="Google sign-in not configured")
|
||||
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/google/callback"
|
||||
profile = await google_exchange_code(payload.code, redirect_uri)
|
||||
user, is_new = await _sign_in_or_register(db, "google", profile)
|
||||
user, is_new = await _sign_in_or_register(
|
||||
db,
|
||||
"google",
|
||||
profile,
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
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(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -115,9 +210,22 @@ async def microsoft_callback(
|
||||
raise HTTPException(status_code=503, detail="Microsoft sign-in not configured")
|
||||
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/microsoft/callback"
|
||||
profile = await microsoft_exchange_code(payload.code, redirect_uri)
|
||||
user, is_new = await _sign_in_or_register(db, "microsoft", profile)
|
||||
user, is_new = await _sign_in_or_register(
|
||||
db,
|
||||
"microsoft",
|
||||
profile,
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
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(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -2,19 +2,24 @@
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.database import get_db
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.models.account import Account
|
||||
from app.models.assistant_chat import AssistantChat
|
||||
from app.models.psa_connection import PsaConnection
|
||||
from app.models.session import Session
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.schemas.onboarding import OnboardingStatus
|
||||
from app.schemas.onboarding import (
|
||||
OnboardingStatus,
|
||||
OnboardingStepRequest,
|
||||
OnboardingStepResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["onboarding"])
|
||||
|
||||
@@ -85,6 +90,10 @@ async def get_onboarding_status(
|
||||
)
|
||||
connected_psa = (psa_q.scalar() or 0) > 0
|
||||
|
||||
# New (Phase 2 — Task 41)
|
||||
email_verified = current_user.email_verified_at is not None
|
||||
shop_setup_done = (current_user.onboarding_step_completed or 0) >= 1
|
||||
|
||||
return OnboardingStatus(
|
||||
created_flow=created_flow,
|
||||
ran_session=ran_session,
|
||||
@@ -94,6 +103,8 @@ async def get_onboarding_status(
|
||||
connected_psa=connected_psa,
|
||||
is_team_user=is_team_user,
|
||||
dismissed=current_user.onboarding_dismissed,
|
||||
email_verified=email_verified,
|
||||
shop_setup_done=shop_setup_done,
|
||||
)
|
||||
|
||||
|
||||
@@ -109,3 +120,98 @@ async def dismiss_onboarding(
|
||||
|
||||
# Return updated status (reuse the GET logic)
|
||||
return await get_onboarding_status(db=db, current_user=current_user)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Welcome wizard endpoints (Phase 2)
|
||||
#
|
||||
# These persist Step 1/2/3 progress for the post-signup welcome wizard.
|
||||
# Mounted on /users/me/* (the parent router prefix is /users) so the wizard
|
||||
# can run before email verification and during trial.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/me/onboarding-step", response_model=OnboardingStepResponse)
|
||||
async def patch_onboarding_step(
|
||||
body: OnboardingStepRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> OnboardingStepResponse:
|
||||
"""Persist welcome-wizard progress for the current user.
|
||||
|
||||
Contract:
|
||||
- step=1 + complete writes accounts.name, accounts.team_size_bucket,
|
||||
users.role_at_signup, then sets users.onboarding_step_completed=1.
|
||||
- step=2 + complete writes accounts.primary_psa, then sets
|
||||
users.onboarding_step_completed=2.
|
||||
- step=3 + complete just sets users.onboarding_step_completed=3
|
||||
(invites are POSTed separately).
|
||||
- action="skip" ignores `data` entirely and only advances the step.
|
||||
- The new step must be >= current onboarding_step_completed (None=>0);
|
||||
otherwise 400. Idempotent re-PATCH of the same step succeeds.
|
||||
"""
|
||||
current_step = current_user.onboarding_step_completed or 0
|
||||
if body.step < current_step:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": "step_cannot_decrease",
|
||||
"current_step": current_step,
|
||||
"requested_step": body.step,
|
||||
},
|
||||
)
|
||||
|
||||
if body.action == "complete" and body.data is not None and body.step in (1, 2):
|
||||
# Load the user's account for field writes. Step 3 has no data writes.
|
||||
account_result = await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)
|
||||
account = account_result.scalar_one_or_none()
|
||||
if account is None:
|
||||
# Should never happen — user is required to have an account_id.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="account_not_found",
|
||||
)
|
||||
|
||||
if body.step == 1:
|
||||
data = body.data
|
||||
if data.company_name is not None:
|
||||
account.name = data.company_name
|
||||
if data.team_size_bucket is not None:
|
||||
account.team_size_bucket = data.team_size_bucket
|
||||
if data.role_at_signup is not None:
|
||||
current_user.role_at_signup = data.role_at_signup
|
||||
elif body.step == 2:
|
||||
data = body.data
|
||||
if data.primary_psa is not None:
|
||||
account.primary_psa = data.primary_psa
|
||||
|
||||
current_user.onboarding_step_completed = body.step
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return OnboardingStepResponse(
|
||||
onboarding_step_completed=current_user.onboarding_step_completed,
|
||||
onboarding_dismissed=current_user.onboarding_dismissed,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/me/onboarding-dismiss-rest", response_model=OnboardingStepResponse)
|
||||
async def dismiss_onboarding_rest(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> OnboardingStepResponse:
|
||||
"""Set users.onboarding_dismissed=TRUE — backs the wizard's "Skip the rest" button.
|
||||
|
||||
Returns the same shape as the step PATCH so the frontend can update its
|
||||
local store from a single response.
|
||||
"""
|
||||
current_user.onboarding_dismissed = True
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return OnboardingStepResponse(
|
||||
onboarding_step_completed=current_user.onboarding_step_completed,
|
||||
onboarding_dismissed=current_user.onboarding_dismissed,
|
||||
)
|
||||
|
||||
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,
|
||||
beta_feedback,
|
||||
beta_signup,
|
||||
sales_leads,
|
||||
branding,
|
||||
categories,
|
||||
config as config_endpoints,
|
||||
copilot,
|
||||
device_types,
|
||||
draft_templates,
|
||||
@@ -43,6 +45,7 @@ from app.api.endpoints import (
|
||||
notifications,
|
||||
oauth as oauth_endpoints,
|
||||
onboarding,
|
||||
plans_public,
|
||||
public_templates,
|
||||
ratings,
|
||||
scripts,
|
||||
@@ -68,6 +71,7 @@ from app.api.endpoints import (
|
||||
uploads,
|
||||
webhooks,
|
||||
accounts,
|
||||
account_invite_lookup,
|
||||
)
|
||||
|
||||
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(shares.public_router) # Public session share links (optional auth)
|
||||
api_router.include_router(beta_signup.router)
|
||||
api_router.include_router(sales_leads.router) # Talk-to-Sales (no auth, rate-limited)
|
||||
api_router.include_router(webhooks.router) # Stripe webhook receiver
|
||||
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited)
|
||||
api_router.include_router(survey.router) # Public survey flow (no auth, rate-limited)
|
||||
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
|
||||
|
||||
@@ -84,6 +84,7 @@ class Settings(BaseSettings):
|
||||
RESEND_API_KEY: Optional[str] = None
|
||||
FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>"
|
||||
FEEDBACK_EMAIL: Optional[str] = None
|
||||
SALES_LEAD_RECIPIENT_EMAIL: str = "sales@resolutionflow.com"
|
||||
|
||||
@property
|
||||
def email_enabled(self) -> bool:
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.sales_lead import SalesLead
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -484,6 +489,99 @@ class EmailService:
|
||||
logger.exception("Failed to send beta signup notification for %s", signup_email)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def send_sales_lead_notification(
|
||||
to_email: str,
|
||||
lead: "SalesLead",
|
||||
) -> bool:
|
||||
"""Notify the sales recipient about a new Talk-to-Sales submission.
|
||||
|
||||
Fire-and-forget. Returns False (and logs) on any failure; never raises.
|
||||
"""
|
||||
if not settings.email_enabled:
|
||||
logger.warning(
|
||||
"Sales lead email not sent — RESEND_API_KEY not configured (lead %s)",
|
||||
lead.id,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
import resend
|
||||
import html as html_mod
|
||||
from datetime import datetime, timezone
|
||||
|
||||
resend.api_key = settings.RESEND_API_KEY
|
||||
|
||||
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
safe_email = html_mod.escape(lead.email)
|
||||
safe_name = html_mod.escape(lead.name)
|
||||
safe_company = html_mod.escape(lead.company)
|
||||
safe_team_size = html_mod.escape(lead.team_size or "—")
|
||||
safe_source = html_mod.escape(lead.source)
|
||||
safe_message = html_mod.escape(lead.message or "(no message)")
|
||||
subject = f"[ResolutionFlow Sales] New lead — {safe_company} ({safe_email})"
|
||||
|
||||
email_html = f"""<!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
|
||||
async def send_notification_email(
|
||||
to_email: str,
|
||||
|
||||
@@ -172,6 +172,21 @@ class PlanLimitResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PlanLimitWithBillingResponse(PlanLimitResponse):
|
||||
"""PlanLimits + plan_billing fields merged. Billing fields are None when no
|
||||
plan_billing row exists for the plan yet."""
|
||||
display_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
monthly_price_cents: Optional[int] = None
|
||||
annual_price_cents: Optional[int] = None
|
||||
stripe_product_id: Optional[str] = None
|
||||
stripe_monthly_price_id: Optional[str] = None
|
||||
stripe_annual_price_id: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
is_archived: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class PlanLimitUpdate(BaseModel):
|
||||
plan: str
|
||||
max_trees: Optional[int] = None
|
||||
@@ -180,6 +195,19 @@ class PlanLimitUpdate(BaseModel):
|
||||
custom_branding: bool = False
|
||||
priority_support: bool = False
|
||||
export_formats: list = Field(default_factory=lambda: ["markdown", "text"])
|
||||
# plan_billing fields — all optional, partial-update semantics. If any are
|
||||
# set in the body, the admin endpoint upserts the plan_billing row in the
|
||||
# same transaction.
|
||||
display_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
monthly_price_cents: Optional[int] = None
|
||||
annual_price_cents: Optional[int] = None
|
||||
stripe_product_id: Optional[str] = None
|
||||
stripe_monthly_price_id: Optional[str] = None
|
||||
stripe_annual_price_id: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
is_archived: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class AccountOverrideCreate(BaseModel):
|
||||
|
||||
@@ -13,6 +13,10 @@ class CheckoutSessionResponse(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class BillingPortalSessionResponse(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class SubscriptionState(BaseModel):
|
||||
status: str
|
||||
plan: str
|
||||
@@ -38,3 +42,23 @@ class BillingStateResponse(BaseModel):
|
||||
plan_billing: Optional[PlanBillingState]
|
||||
plan_limits: Dict[str, Any]
|
||||
enabled_features: Dict[str, bool]
|
||||
|
||||
|
||||
class PublicPlanResponse(BaseModel):
|
||||
"""Public-safe view of a billable plan, used by the marketing /pricing page.
|
||||
|
||||
Sourced from `plan_billing` joined with `plan_limits.max_users` (exposed
|
||||
here as `max_seats`). Always filtered server-side to is_public=True and
|
||||
is_archived=False, so `is_public` is a constant True for any row returned
|
||||
here — included for clarity and forward compatibility.
|
||||
"""
|
||||
plan: str
|
||||
display_name: str
|
||||
description: Optional[str] = None
|
||||
monthly_price_cents: Optional[int] = None
|
||||
annual_price_cents: Optional[int] = None
|
||||
max_seats: Optional[int] = None
|
||||
sort_order: int
|
||||
is_public: bool = True
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
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):
|
||||
code: str
|
||||
state: str | None = None
|
||||
# When the OAuth flow originated from /accept-invite, the frontend round-trips
|
||||
# the invite code + invited email so the backend can link the new user to the
|
||||
# invited account instead of creating a personal one.
|
||||
account_invite_code: str | None = None
|
||||
invited_email: str | None = None
|
||||
|
||||
|
||||
class OAuthCallbackResponse(BaseModel):
|
||||
@@ -11,3 +16,17 @@ class OAuthCallbackResponse(BaseModel):
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
is_new_user: bool
|
||||
|
||||
|
||||
class InviteLookupResponse(BaseModel):
|
||||
"""Public response surface for GET /accounts/invites/{code}/lookup.
|
||||
|
||||
Returns the minimum context needed for the AcceptInvitePage:
|
||||
account name (so we can title the card), inviter name (for the resend
|
||||
fallback message), invited email (locked into the form), and role.
|
||||
"""
|
||||
|
||||
account_name: str
|
||||
inviter_name: str
|
||||
invited_email: str
|
||||
role: str
|
||||
|
||||
@@ -1,12 +1,55 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OnboardingStatus(BaseModel):
|
||||
created_flow: bool
|
||||
ran_session: bool
|
||||
exported_session: bool
|
||||
# Kept for backward-compat during deploy; new code paths should not branch on this.
|
||||
tried_ai_assistant: bool
|
||||
invited_teammate: bool
|
||||
connected_psa: bool
|
||||
is_team_user: bool
|
||||
dismissed: bool
|
||||
# New (Phase 2 — Task 41) — drive the unified next-step card + checklist.
|
||||
email_verified: bool
|
||||
shop_setup_done: bool
|
||||
|
||||
|
||||
# --- Welcome wizard (Phase 2) ----------------------------------------------
|
||||
|
||||
|
||||
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"
|
||||
avatar_url: Optional[str] = None
|
||||
email_verified_at: Optional[datetime] = None
|
||||
onboarding_step_completed: Optional[int] = None
|
||||
onboarding_dismissed: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Single billing service module. Stripe is the only impl — no provider
|
||||
abstraction. Account row is canonical local state; Stripe is canonical
|
||||
remote state; the webhook handler bridges the two."""
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import stripe
|
||||
@@ -17,8 +18,32 @@ from app.models.subscription import Subscription
|
||||
|
||||
TRIAL_DAYS = 14
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BillingService:
|
||||
@staticmethod
|
||||
async def invalidate_billing_cache(account_ids) -> None:
|
||||
"""No-op stub for future in-process billing cache invalidation.
|
||||
|
||||
Today there is no `app.state.billing_cache` — `BillingService.get_billing_state`
|
||||
always reads fresh from the DB. Call sites that mutate plan/feature data
|
||||
invoke this hook so that wiring is in place when an in-process cache is
|
||||
added later. Until then, this just logs.
|
||||
|
||||
TODO: when an in-process billing cache (e.g. `app.state.billing_cache`)
|
||||
is introduced, evict entries for the given account_ids here.
|
||||
"""
|
||||
try:
|
||||
count = len(list(account_ids))
|
||||
except TypeError:
|
||||
count = -1
|
||||
logger.debug(
|
||||
"BillingService.invalidate_billing_cache called for %d account(s) "
|
||||
"(no-op stub — wire to app.state.billing_cache when added)",
|
||||
count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def start_trial(db: AsyncSession, account_id) -> Subscription:
|
||||
"""Idempotent. Creates a trialing Subscription on Pro for the account if
|
||||
@@ -105,6 +130,25 @@ class BillingService:
|
||||
)
|
||||
return session.url
|
||||
|
||||
@staticmethod
|
||||
async def open_customer_portal(account: Account) -> str:
|
||||
"""Create a Stripe-hosted Customer Portal session and return the URL.
|
||||
|
||||
Raises RuntimeError if Stripe isn't configured (endpoint maps to 503).
|
||||
Raises ValueError if the account has no stripe_customer_id yet — the
|
||||
user must complete a checkout first (endpoint maps to 400).
|
||||
"""
|
||||
if not settings.stripe_enabled:
|
||||
raise RuntimeError("Stripe not configured")
|
||||
if account.stripe_customer_id is None:
|
||||
raise ValueError("no_stripe_customer")
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
session = stripe.billing_portal.Session.create(
|
||||
customer=account.stripe_customer_id,
|
||||
return_url=f"{settings.FRONTEND_URL}/account/billing",
|
||||
)
|
||||
return session.url
|
||||
|
||||
@staticmethod
|
||||
async def get_billing_state(db: AsyncSession, account):
|
||||
"""Aggregate Subscription + PlanLimits + PlanBilling + resolved feature
|
||||
@@ -166,28 +210,44 @@ class BillingService:
|
||||
) -> bool:
|
||||
"""Idempotent. Returns True if the event was applied; False if it had
|
||||
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:
|
||||
db.add(StripeEvent(
|
||||
id=event_id,
|
||||
event_type=event_type,
|
||||
payload_excerpt=_excerpt(payload),
|
||||
))
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
# Duplicate event_id — already processed (or in flight). Ack with False.
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
if event_type == "checkout.session.completed":
|
||||
await _handle_checkout_completed(db, payload)
|
||||
elif event_type == "customer.subscription.updated":
|
||||
await _handle_subscription_updated(db, payload)
|
||||
elif event_type == "customer.subscription.deleted":
|
||||
await _handle_subscription_deleted(db, payload)
|
||||
elif event_type == "invoice.payment_failed":
|
||||
await _handle_payment_failed(db, payload)
|
||||
elif event_type == "invoice.payment_succeeded":
|
||||
await _handle_payment_succeeded(db, payload)
|
||||
try:
|
||||
if event_type == "checkout.session.completed":
|
||||
await _handle_checkout_completed(db, payload)
|
||||
elif event_type == "customer.subscription.updated":
|
||||
await _handle_subscription_updated(db, payload)
|
||||
elif event_type == "customer.subscription.deleted":
|
||||
await _handle_subscription_deleted(db, payload)
|
||||
elif event_type == "invoice.payment_failed":
|
||||
await _handle_payment_failed(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
|
||||
|
||||
|
||||
@@ -238,7 +298,7 @@ async def _handle_checkout_completed(db: AsyncSession, payload: dict):
|
||||
)).scalar_one_or_none()
|
||||
if pb is not None:
|
||||
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):
|
||||
@@ -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.cancel_at_period_end = obj.get("cancel_at_period_end", False)
|
||||
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):
|
||||
@@ -264,7 +324,7 @@ async def _handle_subscription_deleted(db: AsyncSession, payload: dict):
|
||||
if sub is None:
|
||||
return
|
||||
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):
|
||||
@@ -278,7 +338,7 @@ async def _handle_payment_failed(db: AsyncSession, payload: dict):
|
||||
if sub is None:
|
||||
return
|
||||
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):
|
||||
@@ -293,4 +353,4 @@ async def _handle_payment_succeeded(db: AsyncSession, payload: dict):
|
||||
return
|
||||
if sub.status == "past_due":
|
||||
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."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.plan_billing import PlanBilling
|
||||
|
||||
|
||||
class TestAdminPlanLimits:
|
||||
@@ -56,3 +61,204 @@ class TestAdminPlanLimits:
|
||||
"""Non-admin gets 403."""
|
||||
response = await client.get("/api/v1/admin/plan-limits", headers=auth_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_plan_limits_get_includes_plan_billing_fields_when_present(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_db
|
||||
):
|
||||
"""GET /admin/plan-limits returns plan_billing fields when a row exists,
|
||||
and None for plans that don't have one yet."""
|
||||
# Seed a plan_billing row for "pro".
|
||||
existing = (await test_db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == "pro")
|
||||
)).scalar_one_or_none()
|
||||
if existing is None:
|
||||
test_db.add(PlanBilling(
|
||||
plan="pro",
|
||||
display_name="Pro",
|
||||
description="For working teams",
|
||||
monthly_price_cents=4900,
|
||||
annual_price_cents=49000,
|
||||
stripe_product_id="prod_seed",
|
||||
stripe_monthly_price_id="price_seed_m",
|
||||
stripe_annual_price_id="price_seed_a",
|
||||
is_public=True,
|
||||
is_archived=False,
|
||||
sort_order=10,
|
||||
))
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/plan-limits", headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
plans_by_name = {p["plan"]: p for p in response.json()}
|
||||
|
||||
assert "pro" in plans_by_name
|
||||
pro = plans_by_name["pro"]
|
||||
assert pro["display_name"] == "Pro"
|
||||
assert pro["monthly_price_cents"] == 4900
|
||||
assert pro["stripe_monthly_price_id"] == "price_seed_m"
|
||||
assert pro["is_public"] is True
|
||||
assert pro["is_archived"] is False
|
||||
assert pro["sort_order"] == 10
|
||||
|
||||
# A plan without a plan_billing row should still return, with None
|
||||
# billing fields.
|
||||
if "free" in plans_by_name:
|
||||
free = plans_by_name["free"]
|
||||
# free has no plan_billing row in the seed → fields are None.
|
||||
no_billing_row = (await test_db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == "free")
|
||||
)).scalar_one_or_none() is None
|
||||
if no_billing_row:
|
||||
assert free["display_name"] is None
|
||||
assert free["monthly_price_cents"] is None
|
||||
assert free["stripe_product_id"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_plan_limits_put_creates_plan_billing_row(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_db
|
||||
):
|
||||
"""PUT /admin/plan-limits upserts a plan_billing row when billing
|
||||
fields are included in the body."""
|
||||
# Ensure no plan_billing row exists for "team" yet.
|
||||
existing = (await test_db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == "team")
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
await test_db.delete(existing)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.put(
|
||||
"/api/v1/admin/plan-limits",
|
||||
json={
|
||||
"plan": "team",
|
||||
"max_trees": None,
|
||||
"max_sessions_per_month": None,
|
||||
"max_users": None,
|
||||
"custom_branding": True,
|
||||
"priority_support": True,
|
||||
"export_formats": ["markdown", "text", "pdf"],
|
||||
"display_name": "Team",
|
||||
"description": "For growing shops",
|
||||
"monthly_price_cents": 9900,
|
||||
"annual_price_cents": 99000,
|
||||
"stripe_product_id": "prod_team_test",
|
||||
"stripe_monthly_price_id": "price_team_m",
|
||||
"stripe_annual_price_id": "price_team_a",
|
||||
"is_public": True,
|
||||
"is_archived": False,
|
||||
"sort_order": 20,
|
||||
},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["display_name"] == "Team"
|
||||
assert body["monthly_price_cents"] == 9900
|
||||
assert body["stripe_product_id"] == "prod_team_test"
|
||||
assert body["sort_order"] == 20
|
||||
|
||||
# Confirm the row was actually persisted.
|
||||
await test_db.commit() # ensure session sees other-session writes
|
||||
pb = (await test_db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == "team")
|
||||
)).scalar_one_or_none()
|
||||
assert pb is not None
|
||||
assert pb.display_name == "Team"
|
||||
assert pb.monthly_price_cents == 9900
|
||||
assert pb.stripe_monthly_price_id == "price_team_m"
|
||||
assert pb.is_public is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_plan_limits_put_does_not_null_out_required_fields(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_db
|
||||
):
|
||||
"""PUT /admin/plan-limits must not NULL out NOT NULL columns on the
|
||||
plan_billing row when the caller passes explicit nulls. The set of
|
||||
guarded fields is {display_name, is_public, is_archived, sort_order}.
|
||||
"""
|
||||
# Seed a plan_billing row for "team" with non-default values for every
|
||||
# NOT NULL field so we can detect any clobbering.
|
||||
existing = (await test_db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == "team")
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
await test_db.delete(existing)
|
||||
await test_db.commit()
|
||||
|
||||
seeded = PlanBilling(
|
||||
plan="team",
|
||||
display_name="Team Seeded",
|
||||
is_public=False,
|
||||
is_archived=True,
|
||||
sort_order=5,
|
||||
)
|
||||
test_db.add(seeded)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.put(
|
||||
"/api/v1/admin/plan-limits",
|
||||
json={
|
||||
"plan": "team",
|
||||
"max_trees": None,
|
||||
"max_sessions_per_month": None,
|
||||
"max_users": None,
|
||||
"custom_branding": True,
|
||||
"priority_support": True,
|
||||
"export_formats": ["markdown", "text"],
|
||||
# Explicit nulls for every NOT NULL plan_billing field.
|
||||
"display_name": None,
|
||||
"is_public": None,
|
||||
"is_archived": None,
|
||||
"sort_order": None,
|
||||
},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
# Confirm the seeded NOT NULL values were preserved.
|
||||
await test_db.commit() # ensure session sees writes from the request
|
||||
pb = (await test_db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == "team")
|
||||
)).scalar_one_or_none()
|
||||
assert pb is not None
|
||||
assert pb.display_name == "Team Seeded"
|
||||
assert pb.is_public is False
|
||||
assert pb.is_archived is True
|
||||
assert pb.sort_order == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_plan_limits_put_invalidates_billing_cache(
|
||||
self, client: AsyncClient, admin_auth_headers: dict
|
||||
):
|
||||
"""PUT /admin/plan-limits calls BillingService.invalidate_billing_cache
|
||||
with the account_ids on the affected plan."""
|
||||
# Patch the staticmethod on the class. The endpoint imports
|
||||
# BillingService at module load, so patch the symbol on the class
|
||||
# itself — both the import and the dotted reference resolve to it.
|
||||
with patch(
|
||||
"app.api.endpoints.admin_plan_limits.BillingService.invalidate_billing_cache",
|
||||
new_callable=AsyncMock,
|
||||
) as spy:
|
||||
response = await client.put(
|
||||
"/api/v1/admin/plan-limits",
|
||||
json={
|
||||
"plan": "pro",
|
||||
"max_trees": 25,
|
||||
"max_sessions_per_month": 500,
|
||||
"max_users": 10,
|
||||
"custom_branding": True,
|
||||
"priority_support": True,
|
||||
"export_formats": ["markdown", "text"],
|
||||
},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
spy.assert_awaited_once()
|
||||
(account_ids_arg,) = spy.await_args.args
|
||||
# admin fixture seeds an active Pro Subscription, so we expect at
|
||||
# least one account_id in the invalidation list.
|
||||
assert isinstance(account_ids_arg, list)
|
||||
assert len(account_ids_arg) >= 1
|
||||
|
||||
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
|
||||
from unittest.mock import patch
|
||||
from sqlalchemy import select
|
||||
from app.core.security import decode_token, hash_token
|
||||
from app.models.user import User
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
from app.models.refresh_token import RefreshToken
|
||||
from app.models.subscription import Subscription
|
||||
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)
|
||||
)).scalar_one()
|
||||
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."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -21,6 +26,42 @@ async def test_onboarding_status_fresh_user(client, auth_headers):
|
||||
assert data["connected_psa"] is False
|
||||
assert data["is_team_user"] is False
|
||||
assert data["dismissed"] is False
|
||||
# Phase 2 fields default to false on a fresh, unverified user with no wizard progress.
|
||||
assert data["email_verified"] is False
|
||||
assert data["shop_setup_done"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_status_includes_email_verified_and_shop_setup_done(
|
||||
client, auth_headers, test_user, test_db
|
||||
):
|
||||
"""email_verified flips when email_verified_at is set; shop_setup_done flips at step >= 1."""
|
||||
# Sanity-check baseline.
|
||||
response = await client.get(
|
||||
"/api/v1/users/onboarding-status",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email_verified"] is False
|
||||
assert data["shop_setup_done"] is False
|
||||
|
||||
# Mutate the underlying user, then re-fetch.
|
||||
user_email = test_user["email"]
|
||||
result = await test_db.execute(select(User).where(User.email == user_email))
|
||||
user = result.scalar_one()
|
||||
user.email_verified_at = datetime.now(tz=timezone.utc)
|
||||
user.onboarding_step_completed = 1
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/users/onboarding-status",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email_verified"] is True
|
||||
assert data["shop_setup_done"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
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 r1.json()["applied"] is True
|
||||
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)
|
||||
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_PUBLIC_POSTHOG_KEY
|
||||
ARG VITE_PUBLIC_POSTHOG_HOST
|
||||
ARG VITE_STRIPE_PUBLISHABLE_KEY
|
||||
ARG VITE_GOOGLE_CLIENT_ID
|
||||
ARG VITE_MS_CLIENT_ID
|
||||
ARG VITE_OAUTH_REDIRECT_BASE
|
||||
ARG VITE_SELF_SERVE_ENABLED
|
||||
ARG VITE_CALENDLY_URL
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
|
||||
ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY
|
||||
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
|
||||
RUN npm run build
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import apiClient from './client'
|
||||
import type { Account, SubscriptionDetails, AccountMember, AccountInvite } from '@/types'
|
||||
|
||||
export interface BulkInviteRow {
|
||||
email: string
|
||||
role: 'engineer' | 'viewer'
|
||||
expires_in_days?: number
|
||||
}
|
||||
|
||||
export interface BulkInviteFailure {
|
||||
email: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface BulkInviteResponse {
|
||||
created: AccountInvite[]
|
||||
failed: BulkInviteFailure[]
|
||||
}
|
||||
|
||||
export const accountsApi = {
|
||||
async getMyAccount(): Promise<Account> {
|
||||
const response = await apiClient.get<Account>('/accounts/me')
|
||||
@@ -39,6 +55,18 @@ export const accountsApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Create multiple invites in one call (used by the welcome wizard step 3).
|
||||
* Per-row failures land in `failed[]`; successes in `created[]`.
|
||||
*/
|
||||
async bulkInvite(invites: BulkInviteRow[]): Promise<BulkInviteResponse> {
|
||||
const response = await apiClient.post<BulkInviteResponse>(
|
||||
'/accounts/me/invites/bulk',
|
||||
{ invites },
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getInvites(): Promise<AccountInvite[]> {
|
||||
const response = await apiClient.get<AccountInvite[]>('/accounts/me/invites')
|
||||
return response.data
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import apiClient from './client'
|
||||
import type { Token, User, UserCreate, UserLogin, UserUpdate } from '@/types'
|
||||
|
||||
export interface OAuthCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
is_new_user: boolean
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
async register(data: UserCreate): Promise<User> {
|
||||
const response = await apiClient.post<User>('/auth/register', data)
|
||||
@@ -71,6 +78,36 @@ export const authApi = {
|
||||
async verifyEmail(token: string): Promise<void> {
|
||||
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
|
||||
|
||||
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 stepCategoriesApi } from './stepCategories'
|
||||
export { default as accountsApi } from './accounts'
|
||||
export { default as billingApi } from './billing'
|
||||
export { default as plansApi } from './plans'
|
||||
export type { PublicPlanResponse } from './plans'
|
||||
export { default as salesApi } from './sales'
|
||||
export type {
|
||||
SalesLeadCreatePayload,
|
||||
SalesLeadCreateResponse,
|
||||
SalesLeadSource,
|
||||
} from './sales'
|
||||
export { default as usageApi } from './usage'
|
||||
export { default as adminApi } from './admin'
|
||||
export { treeMarkdownApi } from './treeMarkdown'
|
||||
export { default as analyticsApi } from './analytics'
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import apiClient from './client'
|
||||
import type { InviteCodeValidation } from '@/types'
|
||||
|
||||
/** Public response from GET /accounts/invites/{code}/lookup. */
|
||||
export interface AccountInviteLookup {
|
||||
account_name: string
|
||||
inviter_name: string
|
||||
invited_email: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export const inviteApi = {
|
||||
async validateCode(code: string): Promise<InviteCodeValidation> {
|
||||
const response = await apiClient.get<InviteCodeValidation>(`/invites/validate/${code}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Public lookup of an account invite code — no auth required. Used by
|
||||
* /accept-invite to render the "Join {account} on ResolutionFlow" card.
|
||||
* Resolves to 404 with `invite_invalid_or_expired_or_revoked` for any
|
||||
* invalid state. */
|
||||
async lookupAccountInvite(code: string): Promise<AccountInviteLookup> {
|
||||
const response = await apiClient.get<AccountInviteLookup>(
|
||||
`/accounts/invites/${encodeURIComponent(code)}/lookup`,
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default inviteApi
|
||||
|
||||
@@ -4,11 +4,15 @@ export interface OnboardingStatus {
|
||||
created_flow: boolean
|
||||
ran_session: boolean
|
||||
exported_session: boolean
|
||||
/** @deprecated Phase 2 — kept for backward-compat. New UI no longer branches on this. */
|
||||
tried_ai_assistant: boolean
|
||||
invited_teammate: boolean
|
||||
connected_psa: boolean
|
||||
is_team_user: boolean
|
||||
dismissed: boolean
|
||||
// Phase 2 (Task 41) — drive the unified next-step card + checklist.
|
||||
email_verified: boolean
|
||||
shop_setup_done: boolean
|
||||
}
|
||||
|
||||
export async function getOnboardingStatus(): Promise<OnboardingStatus> {
|
||||
@@ -19,3 +23,51 @@ export async function getOnboardingStatus(): Promise<OnboardingStatus> {
|
||||
export async function dismissOnboarding(): Promise<void> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
170
frontend/src/components/dashboard/NextStepCard.tsx
Normal file
170
frontend/src/components/dashboard/NextStepCard.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
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.
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- pure helper exported for focused unit tests
|
||||
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>
|
||||
)
|
||||
}
|
||||
137
frontend/src/components/dashboard/SetupChecklist.tsx
Normal file
137
frontend/src/components/dashboard/SetupChecklist.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
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',
|
||||
]
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- pure helper exported for focused unit tests
|
||||
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 { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { useBillingPoll } from '@/hooks/useBillingPoll'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { TopBar } from './TopBar'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { EmailVerificationBanner } from './EmailVerificationBanner'
|
||||
import { EmailVerificationGate } from '@/components/common/EmailVerificationGate'
|
||||
import { ViewTransitionOutlet } from './ViewTransitionOutlet'
|
||||
import { FeedbackWidget } from '@/components/common/FeedbackWidget'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AppLayout() {
|
||||
// Poll /billing/state every 60s while authenticated. Hook no-ops when logged out.
|
||||
useBillingPoll()
|
||||
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
@@ -169,7 +174,9 @@ export function AppLayout() {
|
||||
{/* Main Content */}
|
||||
<main className="main-content flex flex-col overflow-hidden min-h-0">
|
||||
<EmailVerificationBanner />
|
||||
<ViewTransitionOutlet />
|
||||
<EmailVerificationGate>
|
||||
<ViewTransitionOutlet />
|
||||
</EmailVerificationGate>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,39 @@ import { useAuthStore } from '@/store/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export function EmailVerificationBanner() {
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Whole days elapsed between an ISO timestamp and now (floored).
|
||||
*
|
||||
* Mirrors the helper in `EmailVerificationGate` — keep the two in sync so the
|
||||
* banner hides on the same day the wall appears (Day 7+ unverified). Defensive
|
||||
* on bad timestamps: treats unparseable input as "just signed up" so we never
|
||||
* accidentally hide the banner on a real unverified user.
|
||||
*/
|
||||
function daysSince(iso: string, now: number = Date.now()): number {
|
||||
const created = Date.parse(iso)
|
||||
if (Number.isNaN(created)) return 0
|
||||
return Math.floor((now - created) / MS_PER_DAY)
|
||||
}
|
||||
|
||||
interface EmailVerificationBannerProps {
|
||||
/**
|
||||
* Override the grace period (in days). Day `gracePeriodDays + 1` and beyond
|
||||
* suppress the banner — `EmailVerificationGate` shows the wall instead.
|
||||
* Defaults to 6 (matches the gate).
|
||||
*/
|
||||
gracePeriodDays?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-of-dashboard bar shown to users who signed up but haven't verified their
|
||||
* email yet. Hides itself once the grace period expires (the wall takes over)
|
||||
* and once the user dismisses it for the session.
|
||||
*/
|
||||
export function EmailVerificationBanner({
|
||||
gracePeriodDays = 6,
|
||||
}: EmailVerificationBannerProps = {}) {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
@@ -19,6 +51,11 @@ export function EmailVerificationBanner() {
|
||||
|
||||
if (!user || user.email_verified_at || dismissed || !verificationEnabled) return null
|
||||
|
||||
// Past grace period: the wall takes over inside <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 () => {
|
||||
setIsSending(true)
|
||||
try {
|
||||
@@ -32,22 +69,29 @@ export function EmailVerificationBanner() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-amber-400/20 bg-amber-400/5 px-4 py-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-400" />
|
||||
<span className="text-amber-200">
|
||||
<div
|
||||
data-testid="email-verification-banner"
|
||||
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.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={isSending}
|
||||
data-testid="banner-resend-button"
|
||||
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'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label="Dismiss"
|
||||
className="ml-auto text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import { NotificationsPanel } from './NotificationsPanel'
|
||||
import { TrialPill } from './TrialPill'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function TopBar() {
|
||||
@@ -110,6 +111,9 @@ export function TopBar() {
|
||||
{/* Spacer - push actions to right */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Billing-state pill (trial countdown / paid tier / past_due / etc.) */}
|
||||
<TrialPill />
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<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')
|
||||
})
|
||||
})
|
||||
96
frontend/src/hooks/useAppConfig.ts
Normal file
96
frontend/src/hooks/useAppConfig.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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) 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)
|
||||
})
|
||||
})
|
||||
104
frontend/src/hooks/useFeatureLimit.ts
Normal file
104
frontend/src/hooks/useFeatureLimit.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, 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]))
|
||||
|
||||
const [state, setState] = useState(() => {
|
||||
const existing = cache.get(field)
|
||||
const fresh = existing && Date.now() - existing.timestamp < CACHE_TTL_MS
|
||||
return {
|
||||
field,
|
||||
used: fresh ? existing.used : 0,
|
||||
isLoading: !fresh,
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const existing = cache.get(field)
|
||||
if (existing && Date.now() - existing.timestamp < CACHE_TTL_MS) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync local hook state with fresh module cache on field change
|
||||
setState({ field, used: existing.used, isLoading: false })
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setState({ field, used: 0, isLoading: true })
|
||||
usageApi
|
||||
.getCount(field)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
cache.set(field, { used: result.used, timestamp: Date.now() })
|
||||
setState({ field, used: result.used, isLoading: false })
|
||||
})
|
||||
.catch(() => {
|
||||
// TODO: backend /usage/{field} endpoint not yet implemented (planned).
|
||||
// 404s and other errors degrade to used=0 silently — no toast.
|
||||
if (cancelled) return
|
||||
setState({ field, used: 0, isLoading: false })
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [field])
|
||||
|
||||
const used = state.field === field ? state.used : 0
|
||||
const isLoading = state.field === field ? state.isLoading : true
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
87
frontend/src/hooks/useTrialBanner.ts
Normal file
87
frontend/src/hooks/useTrialBanner.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
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)
|
||||
const [now] = useState(() => Date.now())
|
||||
|
||||
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 }
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
372
frontend/src/pages/AcceptInvitePage.tsx
Normal file
372
frontend/src/pages/AcceptInvitePage.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
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) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- route changes without a code should replace stale lookup state
|
||||
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,
|
||||
Clock,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Crown,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
@@ -598,6 +599,12 @@ export function AccountSettingsPage() {
|
||||
title="Profile"
|
||||
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>
|
||||
|
||||
{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 { PageMeta } from '@/components/common/PageMeta'
|
||||
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||
import '@/styles/landing.css'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
q: 'How is this different from just using ChatGPT?',
|
||||
@@ -29,11 +28,9 @@ const FAQ_ITEMS = [
|
||||
]
|
||||
|
||||
export default function LandingPage() {
|
||||
const appConfig = useAppConfig()
|
||||
const [navScrolled, setNavScrolled] = 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 mobileMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -71,32 +68,6 @@ export default function LandingPage() {
|
||||
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) => {
|
||||
setOpenFaq(prev => prev === index ? null : index)
|
||||
}
|
||||
@@ -174,6 +145,15 @@ export default function LandingPage() {
|
||||
</p>
|
||||
<div className="landing-hero-actions">
|
||||
<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>
|
||||
</div>
|
||||
<p className="landing-hero-credibility">
|
||||
@@ -422,34 +402,10 @@ export default function LandingPage() {
|
||||
<section className="landing-cta-section landing-reveal">
|
||||
<div className="landing-cta-inner">
|
||||
<h2>Ready to stop writing ticket notes?</h2>
|
||||
<p>Join the beta. Troubleshoot your next ticket with FlowPilot.</p>
|
||||
<form className="landing-cta-email-form" onSubmit={handleBetaSubmit} noValidate>
|
||||
<div className="landing-cta-input-wrap">
|
||||
<input
|
||||
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>Get early access. Troubleshoot your next ticket with FlowPilot.</p>
|
||||
<div className="landing-cta-actions">
|
||||
<Link to="/register?from=beta" className="landing-btn-hero-primary">Get started</Link>
|
||||
</div>
|
||||
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
196
frontend/src/pages/OAuthCallbackPage.tsx
Normal file
196
frontend/src/pages/OAuthCallbackPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
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) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- callback URL validation maps directly into local error state
|
||||
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
|
||||
439
frontend/src/pages/PricingPage.tsx
Normal file
439
frontend/src/pages/PricingPage.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
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
|
||||
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 { useAuthStore } from '@/store/authStore'
|
||||
import { StartSessionInput } from '@/components/dashboard/StartSessionInput'
|
||||
@@ -7,6 +8,10 @@ import { TicketQueue } from '@/components/dashboard/TicketQueue'
|
||||
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
||||
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
||||
import { TeamSummary } from '@/components/dashboard/TeamSummary'
|
||||
import { NextStepCard, pickNextStep } from '@/components/dashboard/NextStepCard'
|
||||
import { SetupChecklist } from '@/components/dashboard/SetupChecklist'
|
||||
import { useOnboardingStatus } from '@/hooks/useOnboardingStatus'
|
||||
import { useTrialBanner } from '@/hooks/useTrialBanner'
|
||||
|
||||
function SectionLabel({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
@@ -22,6 +27,17 @@ function SectionLabel({ children, action }: { children: React.ReactNode; action?
|
||||
|
||||
export function QuickStartPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const [showAllSetupSteps, setShowAllSetupSteps] = useState(false)
|
||||
const onboardingStatus = useOnboardingStatus()
|
||||
const { stage: trialStage } = useTrialBanner()
|
||||
|
||||
// Onboarding section is visible when there's still something to nudge on.
|
||||
// We check the same priority list NextStepCard uses so the toggle row
|
||||
// disappears cleanly once everything is done OR the user dismissed.
|
||||
const onboardingVisible =
|
||||
onboardingStatus !== null &&
|
||||
!onboardingStatus.dismissed &&
|
||||
pickNextStep(onboardingStatus, trialStage) !== null
|
||||
|
||||
const now = new Date()
|
||||
const greeting = now.getHours() < 12
|
||||
@@ -47,6 +63,29 @@ export function QuickStartPage() {
|
||||
</h1>
|
||||
</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 */}
|
||||
<StartSessionInput />
|
||||
|
||||
|
||||
@@ -1,18 +1,78 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { inviteApi } from '@/api/invite'
|
||||
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { PasswordInput } from '@/components/common/PasswordInput'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
const MICROSOFT_AUTH_URL =
|
||||
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'
|
||||
|
||||
function getRedirectBase(): string {
|
||||
const fromEnv = import.meta.env.VITE_OAUTH_REDIRECT_BASE
|
||||
if (fromEnv) return fromEnv as string
|
||||
// Falls back to current origin in dev so feature works without explicit env.
|
||||
if (typeof window !== 'undefined') return window.location.origin
|
||||
return ''
|
||||
}
|
||||
|
||||
function randomState(): string {
|
||||
// Lightweight random state — used only to harden against CSRF on the OAuth
|
||||
// round-trip. Not a security boundary; backend independently authenticates
|
||||
// via the authorization code exchange.
|
||||
const buf = new Uint8Array(16)
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(buf)
|
||||
} else {
|
||||
for (let i = 0; i < buf.length; i++) buf[i] = Math.floor(Math.random() * 256)
|
||||
}
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/** Build provider authorize URL. Exported for tests and invite OAuth handoff. */
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- pure helper shared with AcceptInvitePage and unit tests
|
||||
export function buildOAuthAuthorizeUrl(
|
||||
provider: 'google' | 'microsoft',
|
||||
state: string,
|
||||
): string {
|
||||
const redirectUri = `${getRedirectBase()}/auth/${provider}/callback`
|
||||
if (provider === 'google') {
|
||||
const params = new URLSearchParams({
|
||||
client_id: (import.meta.env.VITE_GOOGLE_CLIENT_ID as string) || '',
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid email profile',
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
state,
|
||||
})
|
||||
return `${GOOGLE_AUTH_URL}?${params.toString()}`
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
client_id: (import.meta.env.VITE_MS_CLIENT_ID as string) || '',
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid email profile offline_access',
|
||||
response_mode: 'query',
|
||||
state,
|
||||
})
|
||||
return `${MICROSOFT_AUTH_URL}?${params.toString()}`
|
||||
}
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { register, isLoading, error, clearError } = useAuthStore()
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
const [inviteCode, setInviteCode] = useState('')
|
||||
const [inviteCodeStatus, setInviteCodeStatus] = useState<'idle' | 'checking' | 'valid' | 'invalid'>('idle')
|
||||
const [inviteCodeStatus, setInviteCodeStatus] = useState<
|
||||
'idle' | 'checking' | 'valid' | 'invalid'
|
||||
>('idle')
|
||||
const [inviteCodeMessage, setInviteCodeMessage] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -20,6 +80,32 @@ export function RegisterPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [localError, setLocalError] = useState('')
|
||||
|
||||
// Capture ?plan=pro into localStorage so the in-app flow / start_trial
|
||||
// can later read it. One-shot on mount.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const plan = params.get('plan')
|
||||
if (plan) localStorage.setItem('rf-intended-plan', plan)
|
||||
}, [location.search])
|
||||
|
||||
const showOAuthButtons = appConfig.self_serve_enabled
|
||||
const showInviteCode = !appConfig.self_serve_enabled
|
||||
const googleAvailable =
|
||||
showOAuthButtons && appConfig.oauth_providers.includes('google')
|
||||
const microsoftAvailable =
|
||||
showOAuthButtons && appConfig.oauth_providers.includes('microsoft')
|
||||
|
||||
const handleOAuth = (provider: 'google' | 'microsoft') => {
|
||||
const state = randomState()
|
||||
try {
|
||||
sessionStorage.setItem('rf-oauth-state', state)
|
||||
} catch {
|
||||
// ignore — non-fatal
|
||||
}
|
||||
const url = buildOAuthAuthorizeUrl(provider, state)
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
const validateInviteCode = async (code: string) => {
|
||||
if (!code.trim()) {
|
||||
setInviteCodeStatus('idle')
|
||||
@@ -43,8 +129,8 @@ export function RegisterPage() {
|
||||
setLocalError('')
|
||||
clearError()
|
||||
|
||||
// Only validate invite code if one was entered
|
||||
if (inviteCode.trim() && inviteCodeStatus === 'invalid') {
|
||||
// Only validate invite code when the field is shown (legacy invite flow).
|
||||
if (showInviteCode && inviteCode.trim() && inviteCodeStatus === 'invalid') {
|
||||
setLocalError('Please enter a valid invite code')
|
||||
return
|
||||
}
|
||||
@@ -65,12 +151,15 @@ export function RegisterPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Only include invite_code if provided
|
||||
const userData = inviteCode.trim()
|
||||
? { email, password, name, invite_code: inviteCode.trim() }
|
||||
: { email, password, name }
|
||||
const userData =
|
||||
showInviteCode && inviteCode.trim()
|
||||
? { email, password, name, invite_code: inviteCode.trim() }
|
||||
: { email, password, name }
|
||||
await register(userData)
|
||||
navigate('/', { replace: true })
|
||||
// New users land on the welcome wizard. The /welcome route is
|
||||
// materialized by Task 38; until that lands, this redirect falls
|
||||
// through to the catch-all 404 — acceptable per spec.
|
||||
navigate('/welcome', { replace: true })
|
||||
} catch {
|
||||
// Error is set in the store
|
||||
}
|
||||
@@ -78,28 +167,30 @@ export function RegisterPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Create Account" description="Create your ResolutionFlow account to start building guided troubleshooting flows" />
|
||||
<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%)]" />
|
||||
<PageMeta
|
||||
title="Create Account"
|
||||
description="Create your ResolutionFlow account to start building guided troubleshooting flows"
|
||||
/>
|
||||
<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="text-center">
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<BrandLogo size="lg" />
|
||||
<div className="relative w-full max-w-md space-y-8">
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
{(error || localError) && (
|
||||
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
|
||||
@@ -107,140 +198,217 @@ export function RegisterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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'
|
||||
{showOAuthButtons && (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',
|
||||
// TODO(brand): swap to white-on-black with Google "G" mark
|
||||
// when brand assets are imported. Neutral fallback for now.
|
||||
'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>
|
||||
)}
|
||||
placeholder="ABCD1234"
|
||||
/>
|
||||
{inviteCodeStatus === 'checking' && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Validating...</p>
|
||||
{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 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>
|
||||
<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)}
|
||||
<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="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(
|
||||
'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'
|
||||
'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',
|
||||
)}
|
||||
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(
|
||||
'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>
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
@@ -249,9 +417,8 @@ export function RegisterPage() {
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,73 +1,221 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, Link } from 'react-router-dom'
|
||||
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||
import { CheckCircle2, XCircle, Loader2, MailCheck } from 'lucide-react'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { toast } from '@/lib/toast'
|
||||
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() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
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(() => {
|
||||
if (status !== 'loading') return
|
||||
if (!token) return
|
||||
if (hasFiredRef.current) return
|
||||
hasFiredRef.current = true
|
||||
|
||||
authApi.verifyEmail(token)
|
||||
.then(() => setStatus('success'))
|
||||
.catch((err) => {
|
||||
setStatus('error')
|
||||
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
setErrorMessage(detail ?? 'Verification failed')
|
||||
let cancelled = false
|
||||
|
||||
authApi
|
||||
.verifyEmail(token)
|
||||
.then(async () => {
|
||||
// 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 (
|
||||
<>
|
||||
<PageMeta title="Verify Email" description="Verify your ResolutionFlow email address" />
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="card-flat w-full max-w-md p-8 text-center">
|
||||
{status === 'loading' && (
|
||||
<>
|
||||
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-4 text-foreground">Verifying your email...</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<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>
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-white',
|
||||
'hover:brightness-110'
|
||||
)}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<XCircle className="mx-auto h-12 w-12 text-rose-500" />
|
||||
<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
|
||||
to="/"
|
||||
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'
|
||||
)}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<PageMeta
|
||||
title="Verify Email"
|
||||
description="Verify your ResolutionFlow email address"
|
||||
/>
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="card-flat w-full max-w-md p-8 text-center">
|
||||
{status === 'loading' && (
|
||||
<>
|
||||
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-4 text-foreground">Verifying your email…</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<CheckCircle2 className="mx-auto h-12 w-12 text-success" />
|
||||
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
|
||||
Email verified
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Redirecting you to the dashboard…
|
||||
</p>
|
||||
<Link
|
||||
to="/?verified=1"
|
||||
replace
|
||||
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 === 'already-verified' && (
|
||||
<>
|
||||
<MailCheck className="mx-auto h-12 w-12 text-success" />
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user