feat: self-serve signup Phase 2 (frontend cutover) #162
Reference in New Issue
Block a user
Delete Branch "feat/self-serve-signup-phase-2"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Implements Phase 2 of the self-serve signup plan (
docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md) — Tasks 27–44. Phase O (Tasks 45–47) is manual operational work (Stripe live setup, internal validation, flag flip) and is the next step after merge.25 commits across 7 phases:
BillingService.open_customer_portal+GET /billing/portal-session;PATCH /users/me/onboarding-step+ dismiss-rest; publicPOST /sales-leads;/admin/plan-limitsround-tripsplan_billingin one transaction;GET /config/public; register-endpoint gate nowREQUIRE_INVITE_CODE and not SELF_SERVE_ENABLED and not invite_code.useBillingStore(polled 60s viauseBillingPollmounted inAppLayout);useFeature/useFeatureLimit/useTrialBannerhooks;FeatureGate/UpgradePrompt/EmailVerificationGatecomponents.RegisterPageredesign with OAuth buttons + invite-code conditional;OAuthCallbackPagewith CSRF state validation (UTF-8-safe base64url);useAppConfighook;AcceptInvitePageat/accept-invite(locked email, 3 sign-in options);EmailVerificationBannerrefactored to design-system tokens;EmailVerificationWallpolished;VerifyEmailPageat/verify-email(single-fire ref guard)./welcome/step-1|2|3(Your shop / Your PSA / Invite team); persists each step viaPATCH /users/me/onboarding-step; bulk invite emails per row.TrialPillin topbar (8 stages);NextStepCard+SetupChecklist(replaces orphanedOnboardingChecklist, no SOLO/TEAM split, no Script Builder).PricingPageat/pricing(B-style);ContactSalesPageat/contact-sales;LandingPagegot See-pricing CTA; beta-signup deprecated to 307 →/register?from=beta.setTokensflipsisAuthenticated; Stripe webhook idempotency made atomic; built missing/account/billing+/account/billing/select-planpages.All new endpoints are in both
_SUBSCRIPTION_GUARD_ALLOWLISTand_EMAIL_VERIFICATION_ALLOWLISTwhere appropriate; public endpoints registered without tenant deps. No new alembic migrations (Phase 1 owns the schema; single headc6cbfc534fad).New env vars
Backend:
SELF_SERVE_ENABLED(defaultfalse),SALES_LEAD_RECIPIENT_EMAIL(defaultsales@resolutionflow.com).Frontend (Vite ARG/ENV in Dockerfile +
.env.example):VITE_SELF_SERVE_ENABLED,VITE_GOOGLE_CLIENT_ID,VITE_MS_CLIENT_ID,VITE_OAUTH_REDIRECT_BASE,VITE_CALENDLY_URL.Followups (deferred, see
.ai/HANDOFF.md)/sales-leadsevent is a no-op stub).GET /api/v1/usage/{field}endpoint missing (useFeatureLimitsilently falls back toused=0).INTERNAL_TESTER_EMAILSper-email allowlist (Task 46) — backend not yet implemented.authStore.subscriptionlegacy vsbillingStore.subscriptionnew). Old type union missing'complimentary'.SelectPlanPagedoesn't client-side cap seats against planmax_seats.Test plan
docker exec resolutionflow_backend pytest --override-ini="addopts="— full backend suite passes (~1300 tests).docker exec -w /app resolutionflow_frontend npm test --run— full frontend Vitest suite passes.docker exec -w /app resolutionflow_frontend npx tsc -b— clean./registerwithVITE_SELF_SERVE_ENABLED=true→ email signup → land on/welcome/step-1./welcome→ wizard → dashboard./auth/refresh, expect 200 (not 401 revoked).subscriptions.current_period_end, refresh dashboard, seeTrial expired — pick a planpill, click →/account/billing/select-plan, complete Stripe Checkout (test card4242 4242 4242 4242).4000 0000 0000 0341) → status changes topast_due→ topbar pill clickable →/account/billing→ Manage billing → portal redirect./accept-invite?code=...→ joins via password OR matching-email OAuth._handle_subscription_updated) → confirm StripeEvent row NOT persisted → Stripe retry succeeds.After merge, proceed to Phase O (Stripe live mode setup, internal validation, flag flip).
🤖 Generated with Claude Code
Authed users can now request a Stripe-hosted Customer Portal URL for card updates and cancellation via GET /api/v1/billing/portal-session. The path is already in both _SUBSCRIPTION_GUARD_ALLOWLIST and _EMAIL_VERIFICATION_ALLOWLIST so canceled or unverified-past-grace users can still update billing. - Returns 503 with {"error": "stripe_not_configured"} when STRIPE_SECRET_KEY unset. - Returns 400 with {"error": "no_stripe_customer"} when account has no stripe_customer_id (must complete checkout first). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Phase 2 Task 31. Single flag now controls whether the public-facing self-serve flow is exposed. - New public endpoint GET /api/v1/config/public returns {self_serve_enabled, oauth_providers}. oauth_providers includes "google" if GOOGLE_CLIENT_ID is set and "microsoft" if MS_CLIENT_ID is set. No auth required; consumed once by the frontend at load. - POST /auth/register: when SELF_SERVE_ENABLED=true the platform invite-code requirement is bypassed even with REQUIRE_INVITE_CODE=true. invite_code stays in the schema for backward compat and still applies when supplied. With the flag off, the gate behaves exactly as before. - Adds backend/app/schemas/config.py with PublicConfigResponse and registers the new router in the public/unauthenticated section. - Adds 3 integration tests in tests/test_config_public.py covering the flag round-trip, the regression case (flag off keeps the 400), and the new behavior (flag on bypasses the gate, creates user + Pro trial). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Phase 2 Task 33. Components can now ask "is this feature on?", "how many sessions left?", and "what stage is the trial in?" without re-implementing the read against useBillingStore. - useFeature(flagKey): boolean — reads enabledFeatures from store - useFeatureLimit(field): { used, limit, percentage, isAtLimit, isLoading } with non-blocking 60s module-level cache and graceful 404 degradation - useTrialBanner(): derives stage from subscription status + trial countdown, returns null on initial load to prevent flicker - usageApi.getCount(field) — calls /api/v1/usage/{field}; backend endpoint is not yet implemented (planned), so the hook degrades to used=0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Adds the invitee-side flow for self-serve signup Phase 2 (Task 36): Backend - Public GET /accounts/invites/{code}/lookup returns {account_name, inviter_name, invited_email, role} for a valid invite, 404 invite_invalid_or_expired_or_revoked otherwise (collapses unknown / expired / revoked / used into one anti-enumeration response). Mounted in a new account_invite_lookup endpoints module on the public route list, uses get_admin_db (BYPASSRLS) since the caller has no tenant. - OAuthCallbackPayload gains optional account_invite_code + invited_email. _sign_in_or_register honors them: a new OAuth user with a valid invite joins the invited account (no personal account, no Pro trial), the invite is marked used, and OAuth-profile-email vs invite-email mismatch raises invite_email_mismatch (matching the email+password register contract). Frontend - New public route /accept-invite -> AcceptInvitePage. Reads ?code=, calls inviteApi.lookupAccountInvite, renders "Join {account} on ResolutionFlow" with the invited email locked (rendered as a div, not an input), three sign-in options (set password, Google, Microsoft), and a clear "ask {inviter} to resend" + mailto: fallback for invalid codes. - OAuth state for invitees is base64url(JSON({csrf, accountInviteCode, invitedEmail})). OAuthCallbackPage decodes both shapes, forwards the invite fields to the backend, and surfaces invite_email_mismatch / invite_invalid_or_expired_or_revoked errors with friendly text. Successful invite-OAuth lands on /?welcome=teammate (suppresses the welcome wizard for invitees per spec). - UserCreate type + invite/auth API clients extended for the new fields. Tests - Backend: invite lookup happy path + four invalid-state collapse, OAuth callback links invite when supplied + rejects on email mismatch. - Frontend Vitest: AcceptInvitePage renders account name + locked email + accept buttons; resend message + mailto on invalid code. All 43 backend auth/account/invite/email-verification tests green; frontend Vitest 120/120 green; tsc -b clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Step 2 (`/welcome/step-2`): four PSA tiles (ConnectWise / Autotask / HaloPSA / No PSA yet). Selecting a real PSA reveals a quiet inline "Connect now" link to `/account/integrations` — credential entry is intentionally OUT of the wizard. Continue persists `primary_psa`, Skip advances without writing. Step 3 (`/welcome/step-3`): up to 10 email/role rows (default 3, "+ Add another" extends, role defaults to Tech / engineer with Viewer alt). "Send invites and continue" filters empty rows, POSTs `/accounts/me/invites/bulk`, then PATCHes onboarding-step `{step:3, action:"complete"}` and navigates to `/?welcome=true`. Per-row `failed[]` errors render inline next to the email and the wizard does NOT auto-advance — user can fix-and-retry or click "Continue anyway" to mark step complete. Empty + Skip / empty + Send both advance without sending. Adds `accountsApi.bulkInvite` and registers `/welcome/step-{2,3}` in the router. Vitest: 5 named tests (selecting PSA persists, Skip advances without primary_psa, valid emails create invites, partial-failure inline error, empty + Skip no-op) + 5 incidental coverage tests. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>OAuth callbacks (POST /auth/google/callback, POST /auth/microsoft/callback) issued refresh tokens via create_refresh_token() but never persisted the JTI in the refresh_tokens table. The /auth/refresh rotation logic does a conditional UPDATE that requires a matching unrevoked row; without it the first refresh attempt 401s with "Refresh token has been revoked" and OAuth users get effectively logged out after the ~5 minute access-token expiry. - Promote _store_refresh_token to module-public store_refresh_token in app.api.endpoints.auth (existing callers in /login, /login/json, /refresh updated in-place — same module, just renamed). - OAuth callbacks now call store_refresh_token(...) + db.commit() after _sign_in_or_register returns. _sign_in_or_register already commits the user/account/identity rows; the refresh-token row gets its own commit. - Tests: - test_oauth_google_callback_stores_refresh_token_jti — asserts the JTI hash is in refresh_tokens after a Google callback. - test_oauth_refresh_works_after_oauth_signup — full e2e: callback -> use returned refresh token at /auth/refresh -> 200 with rotated tokens. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>setTokens() previously only set { token } without flipping isAuthenticated, so after the OAuth callback exchange the store had fresh tokens but ProtectedRoute still saw isAuthenticated === false and bounced the user to /landing before fetchUser() could complete. Storing tokens implies an active session, so set isAuthenticated: true inside setTokens. The other caller (refresh interceptor in api/client.ts) runs from an already-authenticated session, so the flag flip is a no-op there. Tests: - new src/store/authStore.test.ts covers the setTokens contract - src/pages/__tests__/OAuthCallbackPage.test.tsx adds a successful- callback case asserting setTokens + fetchUser are invoked with the exchanged tokens Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>