From 2c33c2a6243bba3f3b385c4add996a63ad55f44a Mon Sep 17 00:00:00 2001 From: Yusufa09 Date: Sun, 2 Aug 2026 19:40:12 -0400 Subject: [PATCH 1/4] Add plan / account-type onboarding on top of the account controls --- PanTS-Demo/src/App.tsx | 48 ++- PanTS-Demo/src/components/AuthButton.tsx | 2 +- PanTS-Demo/src/components/AuthModal.tsx | 53 +-- PanTS-Demo/src/contexts/authContext.tsx | 83 ++-- PanTS-Demo/src/helpers/accountProfile.test.ts | 88 ++++ PanTS-Demo/src/helpers/accountProfile.ts | 171 ++++++++ PanTS-Demo/src/routes/AccountPage.css | 46 ++ PanTS-Demo/src/routes/AccountPage.tsx | 91 +++- PanTS-Demo/src/routes/LegalPage.css | 91 ++++ PanTS-Demo/src/routes/LegalPage.tsx | 110 +++++ PanTS-Demo/src/routes/SignupPage.css | 401 ++++++++++++++++++ PanTS-Demo/src/routes/SignupPage.tsx | 331 +++++++++++++++ PanTS-Demo/src/routes/UploadPage.tsx | 10 +- PanTS-Demo/src/test/accounts.test.tsx | 134 ++++++ PanTS-Demo/src/test/routes.smoke.test.tsx | 27 +- 15 files changed, 1608 insertions(+), 78 deletions(-) create mode 100644 PanTS-Demo/src/helpers/accountProfile.test.ts create mode 100644 PanTS-Demo/src/helpers/accountProfile.ts create mode 100644 PanTS-Demo/src/routes/LegalPage.css create mode 100644 PanTS-Demo/src/routes/LegalPage.tsx create mode 100644 PanTS-Demo/src/routes/SignupPage.css create mode 100644 PanTS-Demo/src/routes/SignupPage.tsx create mode 100644 PanTS-Demo/src/test/accounts.test.tsx diff --git a/PanTS-Demo/src/App.tsx b/PanTS-Demo/src/App.tsx index 7233695..8af358e 100644 --- a/PanTS-Demo/src/App.tsx +++ b/PanTS-Demo/src/App.tsx @@ -1,9 +1,10 @@ -import { lazy, Suspense } from "react"; -import { BrowserRouter, Navigate, Route, Routes } from "react-router"; +import { lazy, Suspense, useEffect } from "react"; +import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router"; import "./App.css"; import AuthModal from "./components/AuthModal"; import { AnnotationProvider } from "./contexts/annotationContexts"; -import { AuthProvider } from "./contexts/authContext"; +import { AuthProvider, useAuth } from "./contexts/authContext"; +import { needsOnboarding } from "./helpers/accountProfile"; import { FileProvider } from "./contexts/fileContexts"; import LandingPage from "./routes/LandingPage"; import ComparePage from "./routes/ComparePage"; @@ -18,6 +19,8 @@ const VisualizationPage = lazy(() => import("./routes/VisualizationPage")); const CompareViewerPage = lazy(() => import("./routes/CompareViewerPage")); const UploadPage = lazy(() => import("./routes/UploadPage")); const AccountPage = lazy(() => import("./routes/AccountPage")); +const SignupPage = lazy(() => import("./routes/SignupPage")); +const LegalPage = lazy(() => import("./routes/LegalPage")); const RotatingHeartLoader = lazy(() => import("./components/Loading")); const BASENAME = import.meta.env.VITE_BASENAME; @@ -49,6 +52,34 @@ function RouteFallback() { ); } +// Routes that must stay reachable while onboarding is incomplete — otherwise the +// gate below would bounce the signup flow off its own page. +const ONBOARDING_EXEMPT = ["/signup", "/terms", "/privacy"]; + +// Sends a signed-in user who never finished signup into the account-type / plan / +// terms steps. This is what catches OAuth first-timers: the provider callback +// lands them back in the app with an account but no answers. +// +// MOCK LIMITATION: "never finished" is read from this browser's localStorage +// (helpers/accountProfile.needsOnboarding), so the same OAuth user on a new +// device is asked again. A user_account.onboarding_completed_at column returned +// by /api/auth/me is the real fix. +function OnboardingGate() { + const { user, isAuthenticated, loading } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + if (loading || !isAuthenticated || !user) return; + if (ONBOARDING_EXEMPT.some((p) => location.pathname.startsWith(p))) return; + if (needsOnboarding(user.id)) { + navigate("/signup?step=type", { replace: true }); + } + }, [loading, isAuthenticated, user, location.pathname, navigate]); + + return null; +} + function App() { return ( @@ -57,6 +88,7 @@ function App() {
+ }> } /> @@ -80,9 +112,12 @@ function App() { /> } /> } /> - {/* Sign in/up is a popup, so old /login links just land on home. */} + {/* Sign in is a popup; sign up is a page. Old /login links land on home. */} } /> + } /> } /> + } /> + } /> } @@ -95,10 +130,11 @@ function App() { /> + {/* Global sign-in popup, above all routes. Inside the router so it + can link out to /signup. */} +
- {/* Global sign-in / sign-up popup, above all routes. */} -
diff --git a/PanTS-Demo/src/components/AuthButton.tsx b/PanTS-Demo/src/components/AuthButton.tsx index 4aca888..7b0fa49 100644 --- a/PanTS-Demo/src/components/AuthButton.tsx +++ b/PanTS-Demo/src/components/AuthButton.tsx @@ -31,7 +31,7 @@ export default function AuthButton() { if (!isAuthenticated || !user) { return ( - ); diff --git a/PanTS-Demo/src/components/AuthModal.tsx b/PanTS-Demo/src/components/AuthModal.tsx index f5fbdb6..fe75df8 100644 --- a/PanTS-Demo/src/components/AuthModal.tsx +++ b/PanTS-Demo/src/components/AuthModal.tsx @@ -1,34 +1,37 @@ import { IconBrandGithub, IconBrandGoogle } from "@tabler/icons-react"; import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; import { useAuth } from "../contexts/authContext"; import "./AuthModal.css"; -// Global sign-in / sign-up popup (light theme, World Labs layout). Opened via +// Global sign-IN popup (light theme, World Labs layout). Opened via // authContext.promptAuth() from the header and from gated upload actions. // Email/password posts to the API; the provider buttons hand the browser to the // backend's OAuth redirect (and are disabled if that provider isn't configured). +// +// Signing UP is a full page (routes/SignupPage) — creating an account now +// involves picking an account type and a plan, which doesn't belong in a popup. +// This modal only links there. const AuthModal: React.FC = () => { const { - authPrompt, promptAuth, closeAuthPrompt, signIn, signUp, + authPrompt, closeAuthPrompt, signIn, signInWithProvider, oauthProviders, oauthError, clearOauthError, } = useAuth(); - const isSignup = authPrompt.mode === "signup"; // "email mode" reveals the email/password form (World Labs' "Continue with email"). const [emailMode, setEmailMode] = useState(false); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [confirm, setConfirm] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); - // Reset transient form state whenever the popup opens/closes or flips mode. + // Reset transient form state whenever the popup opens/closes. useEffect(() => { if (!authPrompt.open) { setEmailMode(false); - setEmail(""); setPassword(""); setConfirm(""); setError(""); setBusy(false); + setEmail(""); setPassword(""); setError(""); setBusy(false); } - }, [authPrompt.open, authPrompt.mode]); + }, [authPrompt.open]); // Surface an error the OAuth callback redirected back with. useEffect(() => { @@ -55,15 +58,13 @@ const AuthModal: React.FC = () => { e.preventDefault(); setError(""); if (!email.trim() || !password) { setError("Enter an email and password."); return; } - if (isSignup && password !== confirm) { setError("Passwords don't match."); return; } setBusy(true); try { - if (isSignup) await signUp(email, password); - else await signIn(email, password); + await signIn(email, password); // authContext auto-closes the popup once the user is set. } catch (err) { - // Surface the API's message ("Invalid email or password", "An account - // with that email already exists", ...) rather than a generic string. + // Surface the API's message ("Invalid email or password", ...) rather + // than a generic string. setError(err instanceof Error && err.message ? err.message : "Something went wrong. Try again."); } finally { setBusy(false); @@ -72,11 +73,11 @@ const AuthModal: React.FC = () => { return (
-
e.stopPropagation()}> +
e.stopPropagation()}> -

{isSignup ? "Create account" : "Sign in"}

+

Sign in

{!emailMode ? ( <> @@ -89,7 +90,7 @@ const AuthModal: React.FC = () => { onClick={() => signInWithProvider("google")} > - {isSignup ? "Sign up with Google" : "Sign in with Google"} + Sign in with Google {/* Errors bounced back from the OAuth callback land here. */} @@ -120,19 +121,12 @@ const AuthModal: React.FC = () => { - {isSignup && ( - - )} {error &&
{error}
} - ) : ( - <>Don't have an account?{" "} - - )} + Don't have an account?{" "} + Sign up
diff --git a/PanTS-Demo/src/contexts/authContext.tsx b/PanTS-Demo/src/contexts/authContext.tsx index 63ae331..a945ce8 100644 --- a/PanTS-Demo/src/contexts/authContext.tsx +++ b/PanTS-Demo/src/contexts/authContext.tsx @@ -17,6 +17,8 @@ // cancels it. // // Not wired yet: emailNotifications -> B3, still a client-only localStorage pref. +// Also client-only: account type and plan (helpers/accountProfile.ts). The name +// is NOT part of that mock — it has a real column and goes through updateName. import { createContext, useCallback, @@ -26,6 +28,12 @@ import { useState, type ReactNode, } from "react"; +import { + DEFAULT_PROFILE, + loadProfile, + updateProfile as persistProfilePatch, + type AccountProfile, +} from "../helpers/accountProfile"; import { API_BASE } from "../helpers/constants"; export type AuthUser = { @@ -36,6 +44,8 @@ export type AuthUser = { /** True when `name` is the user's own rather than derived from the email. */ hasCustomName: boolean; emailNotifications: boolean; // client-only preference until B3 + /** Account type + plan. Client-only mock until the backend grows the columns. */ + profile: AccountProfile; }; export type AuthProvider2 = "google" | "github"; @@ -46,7 +56,8 @@ type AuthContextValue = { /** True until the initial /me check resolves (avoids a signed-out flash). */ loading: boolean; signIn: (email: string, password: string) => Promise; - signUp: (email: string, password: string) => Promise; + /** `name` is optional and is stored server-side on user_account.name. */ + signUp: (email: string, password: string, name?: string) => Promise; /** Full-page redirect into the provider's consent screen. Never returns. */ signInWithProvider: (provider: AuthProvider2) => void; /** Which providers the server has credentials for (null until loaded). */ @@ -64,9 +75,12 @@ type AuthContextValue = { * in — resolves with the deadline and the grace period, so the UI can say so. */ deleteAccount: () => Promise<{ restoreBy: string; graceDays: number }>; - // Global auth popup, opened from the header or any gated action. - authPrompt: { open: boolean; mode: "signin" | "signup" }; - promptAuth: (mode?: "signin" | "signup") => void; + /** Patch the account-type / plan / onboarding mock. */ + updateAccountProfile: (patch: Partial) => void; + // Global sign-in popup, opened from the header or any gated action. Signing + // up is a page (/signup), so this has no signup mode. + authPrompt: { open: boolean }; + promptAuth: () => void; closeAuthPrompt: () => void; /** Error surfaced by the OAuth callback redirect (?auth_error=...), if any. */ oauthError: string | null; @@ -116,6 +130,7 @@ const mapApiUser = (u: ApiUser): AuthUser => { name: custom || nameFromEmail(u.email), hasCustomName: custom.length > 0, emailNotifications: loadPref(u.id), + profile: loadProfile(u.id) ?? DEFAULT_PROFILE, }; }; @@ -131,10 +146,7 @@ const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - const [authPrompt, setAuthPrompt] = useState<{ open: boolean; mode: "signin" | "signup" }>({ - open: false, - mode: "signin", - }); + const [authPrompt, setAuthPrompt] = useState<{ open: boolean }>({ open: false }); const [oauthProviders, setOauthProviders] = useState | null>(null); // The OAuth callback redirects back with ?auth_error=... on failure (e.g. an // unverified provider email colliding with an existing account). Read it once @@ -189,7 +201,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { // If we came back from a failed OAuth attempt, show the popup with the error. useEffect(() => { - if (oauthError) setAuthPrompt({ open: true, mode: "signin" }); + if (oauthError) setAuthPrompt({ open: true }); }, [oauthError]); // Cross-tab: another tab signed in/out -> re-check. @@ -209,22 +221,30 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }; - const authAction = useCallback(async (path: string, email: string, password: string) => { - const res = await authFetch(path, { method: "POST", body: JSON.stringify({ email, password }) }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data.error || "Something went wrong. Try again."); - const mapped = mapApiUser(data.user); - setUser(mapped); - pingOtherTabs(); - return mapped; - }, []); + const authAction = useCallback( + async (path: string, email: string, password: string, name?: string) => { + const body: Record = { email, password }; + if (name?.trim()) body.name = name.trim(); + const res = await authFetch(path, { method: "POST", body: JSON.stringify(body) }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "Something went wrong. Try again."); + const mapped = mapApiUser(data.user); + setUser(mapped); + pingOtherTabs(); + return mapped; + }, + [] + ); const signIn = useCallback( (email: string, password: string) => authAction("/api/auth/login", email, password), [authAction] ); + // The name goes to the server with the registration — /auth/register takes it, + // so a signup name lands in user_account.name rather than in local storage. const signUp = useCallback( - (email: string, password: string) => authAction("/api/auth/register", email, password), + (email: string, password: string, name?: string) => + authAction("/api/auth/register", email, password, name), [authAction] ); @@ -247,16 +267,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []); - const promptAuth = useCallback((mode: "signin" | "signup" = "signin") => { - setAuthPrompt({ open: true, mode }); - }, []); - const closeAuthPrompt = useCallback(() => { - setAuthPrompt((p) => ({ ...p, open: false })); - }, []); + const promptAuth = useCallback(() => setAuthPrompt({ open: true }), []); + const closeAuthPrompt = useCallback(() => setAuthPrompt({ open: false }), []); // Auto-close the popup once a user is established. useEffect(() => { - if (user) setAuthPrompt((p) => (p.open ? { ...p, open: false } : p)); + if (user) setAuthPrompt((p) => (p.open ? { open: false } : p)); }, [user]); const updatePreferences = useCallback( @@ -312,6 +328,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { return { restoreBy: data.restore_by as string, graceDays: Number(data.grace_days) }; }, []); + // Writes storage first, then state — deliberately not inside the setUser + // updater, which StrictMode invokes twice. + const updateAccountProfile = useCallback( + (patch: Partial) => { + if (!user) return; + const profile = persistProfilePatch(user.id, patch); + setUser((prev) => (prev ? { ...prev, profile } : prev)); + }, + [user] + ); + const clearOauthError = useCallback(() => setOauthError(null), []); const value = useMemo( @@ -329,6 +356,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { exportData, deleteScanHistory, deleteAccount, + updateAccountProfile, authPrompt, promptAuth, closeAuthPrompt, @@ -337,7 +365,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { }), [user, loading, signIn, signUp, signInWithProvider, oauthProviders, signOut, updatePreferences, updateName, exportData, deleteScanHistory, deleteAccount, - authPrompt, promptAuth, closeAuthPrompt, oauthError, clearOauthError] + updateAccountProfile, authPrompt, promptAuth, closeAuthPrompt, oauthError, + clearOauthError] ); return {children}; diff --git a/PanTS-Demo/src/helpers/accountProfile.test.ts b/PanTS-Demo/src/helpers/accountProfile.test.ts new file mode 100644 index 0000000..1242ffa --- /dev/null +++ b/PanTS-Demo/src/helpers/accountProfile.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearProfile, + DEFAULT_PROFILE, + loadProfile, + needsOnboarding, + persistProfile, + PROFILE_KEY_PREFIX, + updateProfile, +} from "./accountProfile"; + +const USER = "user-1"; + +beforeEach(() => { + localStorage.clear(); +}); + +describe("loadProfile", () => { + it("returns null when the user has no stored profile", () => { + expect(loadProfile(USER)).toBeNull(); + }); + + it("returns null when storage holds malformed JSON", () => { + localStorage.setItem(`${PROFILE_KEY_PREFIX}${USER}`, "{not json"); + expect(loadProfile(USER)).toBeNull(); + }); + + it("fills in fields missing from a profile written by an older build", () => { + localStorage.setItem( + `${PROFILE_KEY_PREFIX}${USER}`, + JSON.stringify({ plan: "pro" }) + ); + expect(loadProfile(USER)).toEqual({ ...DEFAULT_PROFILE, plan: "pro" }); + }); + + it("keeps profiles for different users separate", () => { + persistProfile(USER, { ...DEFAULT_PROFILE, plan: "pro" }); + persistProfile("user-2", { ...DEFAULT_PROFILE, plan: "team" }); + expect(loadProfile(USER)?.plan).toBe("pro"); + expect(loadProfile("user-2")?.plan).toBe("team"); + }); +}); + +describe("updateProfile", () => { + it("starts from the defaults when nothing is stored yet", () => { + expect(updateProfile(USER, { accountType: "clinician" })).toEqual({ + ...DEFAULT_PROFILE, + accountType: "clinician", + }); + }); + + it("merges into the existing profile rather than replacing it", () => { + updateProfile(USER, { plan: "pro" }); + const next = updateProfile(USER, { accountType: "researcher" }); + expect(next).toMatchObject({ plan: "pro", accountType: "researcher" }); + }); + + it("persists across a reload", () => { + updateProfile(USER, { plan: "enterprise" }); + expect(loadProfile(USER)?.plan).toBe("enterprise"); + }); +}); + +describe("clearProfile", () => { + it("removes only the target user's profile", () => { + persistProfile(USER, { ...DEFAULT_PROFILE, plan: "pro" }); + persistProfile("user-2", { ...DEFAULT_PROFILE, plan: "team" }); + clearProfile(USER); + expect(loadProfile(USER)).toBeNull(); + expect(loadProfile("user-2")?.plan).toBe("team"); + }); +}); + +describe("needsOnboarding", () => { + it("is true for a user with no profile at all", () => { + expect(needsOnboarding(USER)).toBe(true); + }); + + it("stays true while onboarding is only partly done", () => { + updateProfile(USER, { accountType: "clinician", plan: "pro" }); + expect(needsOnboarding(USER)).toBe(true); + }); + + it("is false once onboarding is marked complete", () => { + updateProfile(USER, { onboardingCompletedAt: new Date().toISOString() }); + expect(needsOnboarding(USER)).toBe(false); + }); +}); diff --git a/PanTS-Demo/src/helpers/accountProfile.ts b/PanTS-Demo/src/helpers/accountProfile.ts new file mode 100644 index 0000000..d403300 --- /dev/null +++ b/PanTS-Demo/src/helpers/accountProfile.ts @@ -0,0 +1,171 @@ +// Account profile: account type, plan, and onboarding state. +// +// MOCK — everything here lives in localStorage, keyed by user id. Nothing is +// enforced: no feature is gated by plan or type, no quota is counted, no queue +// priority is applied. This is the shape the signup flow collects so the UI can +// be built and reacted to before any of it is made real. +// +// Deliberately NOT here: the display name and account deletion. Both have real +// server-side homes (user_account.name, user_account.deletion_requested_at) and +// go through authContext's updateName / deleteAccount. Mirroring them here too +// would give each two sources of truth that drift the moment one is written +// without the other. +// +// This file is the single seam for what remains. When the backend grows +// `account_type` and `plan` columns, only load/persist below change — every +// caller reads through authContext, which reads through here. +// +// Follows the helpers/recentUploads.ts pattern (typed shape, try/catch around +// every storage access, pure functions so it can be unit-tested). + +export type AccountType = "patient" | "clinician" | "researcher" | "student"; +export type PlanId = "free" | "pro" | "team" | "enterprise"; + +export type AccountProfile = { + accountType: AccountType; + plan: PlanId; + /** ISO timestamp of terms acceptance; null until the signup flow's last step. */ + acceptedTermsAt: string | null; + /** ISO timestamp; null means signup was never finished (drives OAuth onboarding). */ + onboardingCompletedAt: string | null; +}; + +export const PROFILE_KEY_PREFIX = "accountProfile:"; + +const profileKey = (userId: string) => `${PROFILE_KEY_PREFIX}${userId}`; + +export const ACCOUNT_TYPES: { id: AccountType; label: string; blurb: string }[] = [ + { + id: "patient", + label: "Patient", + blurb: "Understand your own scan in plain language.", + }, + { + id: "clinician", + label: "Clinician", + blurb: "Read scans for patients, with the full measurement toolset.", + }, + { + id: "researcher", + label: "Researcher", + blurb: "Run cohorts and export structured results in bulk.", + }, + { + id: "student", + label: "Student or trainee", + blurb: "Learn anatomy against the public PanTS dataset.", + }, +]; + +// No prices yet — deliberately. These describe who each plan is for, which is +// the part worth settling before anyone picks a number. +export const PLANS: { id: PlanId; label: string; tagline: string; points: string[] }[] = [ + { + id: "free", + label: "Free", + tagline: "For a single scan, or to try things out.", + points: [ + "A few scans per month", + "Full viewer and 3D reconstruction", + "Standard queue", + "Results kept short-term", + ], + }, + { + id: "pro", + label: "Pro", + tagline: "For one clinician or researcher working seriously.", + points: [ + "Room for regular use", + "Priority in the inference queue", + "Several scans processing at once", + "Specialized models", + "Long-term result retention", + ], + }, + { + id: "team", + label: "Team", + tagline: "For a practice or research group working together.", + points: [ + "Everything in Pro, per seat", + "Shared case library and annotations", + "Pooled quota across the team", + "Roles and an audit log", + ], + }, + { + id: "enterprise", + label: "Enterprise", + tagline: "For a hospital or institution.", + points: [ + "Single sign-on", + "Dedicated or on-premise compute", + "Custom retention, DPA and BAA", + "API access and support commitments", + ], + }, +]; + +export const DEFAULT_PROFILE: AccountProfile = { + accountType: "patient", + plan: "free", + acceptedTermsAt: null, + onboardingCompletedAt: null, +}; + +export const loadProfile = (userId: string): AccountProfile | null => { + try { + const raw = localStorage.getItem(profileKey(userId)); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return null; + // Merge over defaults so a profile written by an older build (missing a + // field added later) still loads instead of throwing. + return { ...DEFAULT_PROFILE, ...parsed }; + } catch { + return null; + } +}; + +export const persistProfile = (userId: string, profile: AccountProfile) => { + try { + localStorage.setItem(profileKey(userId), JSON.stringify(profile)); + } catch (e) { + console.warn("persistProfile failed", e); + } +}; + +export const updateProfile = ( + userId: string, + patch: Partial +): AccountProfile => { + const next = { ...(loadProfile(userId) ?? DEFAULT_PROFILE), ...patch }; + persistProfile(userId, next); + return next; +}; + +export const clearProfile = (userId: string) => { + try { + localStorage.removeItem(profileKey(userId)); + } catch (e) { + console.warn("clearProfile failed", e); + } +}; + +/** + * Whether to send this user through the account-type/plan/terms steps. + * + * MOCK LIMITATION: "needs onboarding" really means "no profile in *this + * browser's* localStorage", so an OAuth user returning on a new device is asked + * again. The real fix is a user_account.onboarding_completed_at column returned + * by /api/auth/me — this function is the one place that changes when it lands. + */ +export const needsOnboarding = (userId: string): boolean => + loadProfile(userId)?.onboardingCompletedAt == null; + +export const planLabel = (id: PlanId): string => + PLANS.find((p) => p.id === id)?.label ?? id; + +export const accountTypeLabel = (id: AccountType): string => + ACCOUNT_TYPES.find((t) => t.id === id)?.label ?? id; diff --git a/PanTS-Demo/src/routes/AccountPage.css b/PanTS-Demo/src/routes/AccountPage.css index 3824696..338b3c8 100644 --- a/PanTS-Demo/src/routes/AccountPage.css +++ b/PanTS-Demo/src/routes/AccountPage.css @@ -154,6 +154,52 @@ transform: translateX(20px); } +/* Rows that stack their choices underneath (account type). */ +.account-row--stack { + flex-direction: column; + align-items: stretch; + gap: 14px; +} + +/* Selectable plan / account-type cards */ +.account-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + padding-bottom: 18px; +} +.account-option { + display: flex; + flex-direction: column; + gap: 4px; + text-align: left; + padding: 13px 14px; + border-radius: 11px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: #ffffff; + font-family: 'Space Grotesk', sans-serif; + cursor: pointer; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} +.account-option:hover { + border-color: rgba(0, 0, 0, 0.3); +} +.account-option--on { + border-color: #002d72; + background: rgba(0, 45, 114, 0.04); + box-shadow: 0 0 0 1px #002d72 inset; +} +.account-option-label { + font-size: 14px; + font-weight: 600; + color: #111111; +} +.account-option-blurb { + font-size: 12px; + line-height: 1.45; + color: #6a6a6a; +} + /* Text input (name edit, type-to-confirm) */ .account-input { flex: 1; diff --git a/PanTS-Demo/src/routes/AccountPage.tsx b/PanTS-Demo/src/routes/AccountPage.tsx index a14ecf3..4e748b2 100644 --- a/PanTS-Demo/src/routes/AccountPage.tsx +++ b/PanTS-Demo/src/routes/AccountPage.tsx @@ -2,6 +2,12 @@ import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import Header from "../components/Header"; import { useAuth } from "../contexts/authContext"; +import { + ACCOUNT_TYPES, + PLANS, + accountTypeLabel, + planLabel, +} from "../helpers/accountProfile"; import "./AccountPage.css"; // Account settings: profile (with an editable display name), the email @@ -14,10 +20,12 @@ const AccountPage: React.FC = () => { const navigate = useNavigate(); const { user, isAuthenticated, loading, signOut, updatePreferences, - updateName, exportData, deleteScanHistory, deleteAccount, promptAuth, + updateName, exportData, deleteScanHistory, deleteAccount, updateAccountProfile, + promptAuth, } = useAuth(); const [editingName, setEditingName] = useState(false); + const [changingPlan, setChangingPlan] = useState(false); const [nameDraft, setNameDraft] = useState(""); // Which destructive action is awaiting type-to-confirm (null = none). const [confirming, setConfirming] = useState<"history" | "account" | null>(null); @@ -32,7 +40,7 @@ const AccountPage: React.FC = () => { useEffect(() => { if (!loading && !isAuthenticated) { navigate("/", { replace: true }); - promptAuth("signin"); + promptAuth(); } }, [loading, isAuthenticated, navigate, promptAuth]); @@ -47,6 +55,8 @@ const AccountPage: React.FC = () => { if (!user) return null; + const { profile } = user; + // One wrapper so every action reports failure the same way instead of // silently doing nothing. const run = async (fn: () => Promise) => { @@ -90,8 +100,8 @@ const AccountPage: React.FC = () => { ); return; } - // authContext parks the "still restorable" message on the sign-in - // popup, which is where deleting drops you and where you'd act on it. + // The danger-zone copy above states the grace period before you confirm, + // so nothing needs to follow you to the signed-out page. await deleteAccount(); navigate("/", { replace: true }); }); @@ -173,6 +183,79 @@ const AccountPage: React.FC = () => { + {/* Plan — mock: selecting one gates nothing and charges nothing. */} +
+
Plan
+
+
+
+
{planLabel(profile.plan)}
+
+ {PLANS.find((p) => p.id === profile.plan)?.tagline} Nothing is charged — + pricing isn't set, and every feature is open while BodyMaps is in + development. +
+
+ +
+ + {changingPlan && ( +
+ {PLANS.map((p) => ( + + ))} +
+ )} +
+
+ + {/* Account type — self-reported; changes what's surfaced, not access. */} +
+
Account type
+
+
+
+
{accountTypeLabel(profile.accountType)}
+
+ Tailors what you see — which tools are surfaced and how results are + worded. Self-reported, and it doesn't change what you can access. +
+
+
+ {ACCOUNT_TYPES.map((t) => ( + + ))} +
+
+
+
+ {/* Notifications */}
Notifications
diff --git a/PanTS-Demo/src/routes/LegalPage.css b/PanTS-Demo/src/routes/LegalPage.css new file mode 100644 index 0000000..73d5f5f --- /dev/null +++ b/PanTS-Demo/src/routes/LegalPage.css @@ -0,0 +1,91 @@ +/* Terms / Privacy placeholder pages. Matches the sign-up page's vocabulary. */ + +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap'); + +.legal-wrapper { + min-height: 100vh; + background: #fafafa; + font-family: 'Space Grotesk', sans-serif; + color: #111111; +} + +.legal-header { + padding: 20px 28px; +} + +.legal-brand { + display: inline-flex; + align-items: center; + gap: 10px; + text-decoration: none; + color: #111111; + font-weight: 600; + font-size: 16px; +} +.legal-logo { + width: 30px; + height: 30px; + border-radius: 8px; +} + +.legal-main { + max-width: 680px; + margin: 0 auto; + padding: 16px 24px 80px; +} + +.legal-title { + font-size: 30px; + font-weight: 700; + margin: 0 0 20px; +} + +.legal-banner { + border: 1px solid rgba(217, 119, 6, 0.35); + background: rgba(217, 119, 6, 0.07); + border-radius: 12px; + padding: 16px 18px; + font-size: 13.5px; + line-height: 1.6; + color: #4a4a4a; + margin-bottom: 32px; +} +.legal-banner strong { + color: #9a5b04; +} + +.legal-section { + margin-bottom: 26px; +} + +.legal-heading { + font-size: 16px; + font-weight: 600; + margin: 0 0 7px; +} + +.legal-body { + font-size: 14px; + line-height: 1.65; + color: #6a6a6a; + margin: 0; +} + +.legal-footer { + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid rgba(0, 0, 0, 0.08); + font-size: 13.5px; + color: #8f8f8f; +} +.legal-footer a, +.legal-body a { + color: #002d72; + font-weight: 600; +} + +@media (max-width: 560px) { + .legal-title { + font-size: 25px; + } +} diff --git a/PanTS-Demo/src/routes/LegalPage.tsx b/PanTS-Demo/src/routes/LegalPage.tsx new file mode 100644 index 0000000..85d217e --- /dev/null +++ b/PanTS-Demo/src/routes/LegalPage.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import "./LegalPage.css"; + +// Terms of Service and Privacy Policy. +// +// PLACEHOLDER — this is not legal copy and must be replaced before launch. +// BodyMaps accepts uploaded medical imaging, which means the real versions have +// to be written or reviewed by a lawyer: PHI handling, HIPAA/BAA obligations +// when a clinician uploads a patient's scan, research-use limitations, and +// data-retention commitments are all jurisdiction-specific and none of them can +// be guessed at. The headings below are the sections that will need real text. + +type Section = { heading: string; body: string }; + +const TERMS: Section[] = [ + { + heading: "Research use only", + body: "BodyMaps is research software. Segmentations, measurements, and reports it produces are not a diagnosis and are not a substitute for the judgment of a qualified clinician. It has not been cleared or approved by any medical device regulator.", + }, + { + heading: "Accounts", + body: "What you are responsible for as an account holder, what we may do with an account that is misused, and how an account can be closed by either side.", + }, + { + heading: "What you upload", + body: "Who may upload imaging, what rights and permissions you are confirming you have when you upload it, and what we are permitted to do with it in order to run the service.", + }, + { + heading: "Plans and payment", + body: "How plans, quotas, and any future billing work. Nothing is charged today and no pricing has been set.", + }, + { + heading: "Acceptable use", + body: "What the service may not be used for, including any use as the basis of a clinical decision.", + }, + { + heading: "Availability and liability", + body: "What we do and do not commit to regarding uptime, data durability, and the limits of our liability.", + }, +]; + +const PRIVACY: Section[] = [ + { + heading: "What we collect", + body: "Account details, uploaded imaging and the results derived from it, and the technical logs needed to operate and debug the service.", + }, + { + heading: "Protected health information", + body: "How imaging that may constitute PHI is handled, who can access it, and the circumstances under which a Business Associate Agreement is required. This section in particular needs legal review.", + }, + { + heading: "How we use it", + body: "Running inference you request, operating and improving the service, and whether data is ever used for model training — and how you would opt out if so.", + }, + { + heading: "Retention and deletion", + body: "How long scans and results are kept, what deleting your history removes, and what happens to your data when you delete your account.", + }, + { + heading: "Sharing", + body: "Third parties involved in operating the service, and the commitment that data is not sold.", + }, + { + heading: "Your rights", + body: "How to export your data, correct it, or have it deleted, and how to contact us to do so.", + }, +]; + +const LegalPage: React.FC<{ kind: "terms" | "privacy" }> = ({ kind }) => { + const isTerms = kind === "terms"; + const sections = isTerms ? TERMS : PRIVACY; + + return ( +
+
+ + + BodyMaps + +
+ +
+

{isTerms ? "Terms of Service" : "Privacy Policy"}

+ +
+ Draft placeholder — not yet in force. This page outlines the + sections the final {isTerms ? "terms" : "policy"} will cover. It has not been + written or reviewed by a lawyer and creates no agreement between you and + BodyMaps. Because the service handles medical imaging, the real text needs + proper legal review before launch. +
+ + {sections.map((s) => ( +
+

{s.heading}

+

{s.body}

+
+ ))} + +

+ Questions in the meantime? Get in touch through the{" "} + team page. +

+
+
+ ); +}; + +export default LegalPage; diff --git a/PanTS-Demo/src/routes/SignupPage.css b/PanTS-Demo/src/routes/SignupPage.css new file mode 100644 index 0000000..96fa36d --- /dev/null +++ b/PanTS-Demo/src/routes/SignupPage.css @@ -0,0 +1,401 @@ +/* Fullscreen sign-up. Same light/monochrome vocabulary as the auth popup + (components/AuthModal.css): Space Grotesk, JHU blue, soft radii. */ + +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap'); + +.signup-wrapper { + min-height: 100vh; + display: flex; + flex-direction: column; + background: #fafafa; + font-family: 'Space Grotesk', sans-serif; + color: #111111; +} + +.signup-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 20px 28px; + flex-wrap: wrap; +} + +.signup-brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + color: #111111; + font-weight: 600; + font-size: 16px; +} +.signup-logo { + width: 30px; + height: 30px; + border-radius: 8px; +} + +.signup-header-alt { + font-size: 13px; + color: #6a6a6a; +} +.signup-link { + background: none; + border: none; + padding: 0; + font: inherit; + font-weight: 600; + color: #002d72; + cursor: pointer; + text-decoration: underline; +} + +.signup-main { + flex: 1; + display: flex; + justify-content: center; + padding: 16px 20px 64px; +} + +.signup-panel { + width: 100%; + max-width: 560px; + background: #ffffff; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 18px; + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.06); + padding: 32px 32px 28px; +} + +/* Progress dots */ +.signup-steps { + display: flex; + align-items: center; + gap: 8px; + list-style: none; + margin: 0 0 22px; + padding: 0; +} +.signup-step { + flex: 1; +} +.signup-step-dot { + display: flex; + align-items: center; + justify-content: center; + height: 26px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.05); + color: #8f8f8f; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 500; + transition: background 0.2s, color 0.2s; +} +.signup-step--current .signup-step-dot { + background: #002d72; + color: #ffffff; +} +.signup-step--done .signup-step-dot { + background: rgba(0, 45, 114, 0.14); + color: #002d72; +} + +.signup-title { + font-size: 24px; + font-weight: 700; + margin: 0 0 8px; +} + +.signup-sub { + font-size: 14px; + line-height: 1.55; + color: #6a6a6a; + margin: 0 0 22px; +} + +.signup-note { + font-size: 12.5px; + line-height: 1.5; + color: #8f8f8f; + margin: 16px 0 0; +} + +/* Providers + divider */ +.signup-providers { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 20px; +} +.signup-provider { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 12px 16px; + border-radius: 10px; + background: #ffffff; + border: 1px solid rgba(0, 0, 0, 0.14); + color: #111111; + font-family: inherit; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.signup-provider:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.03); + border-color: rgba(0, 0, 0, 0.28); +} +.signup-provider:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.signup-divider { + display: flex; + align-items: center; + margin: 18px 0; + color: #b0b0b0; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; +} +.signup-divider::before, +.signup-divider::after { + content: ""; + flex: 1; + height: 1px; + background: rgba(0, 0, 0, 0.08); +} +.signup-divider span { + padding: 0 12px; +} + +/* Form */ +.signup-form { + display: flex; + flex-direction: column; + gap: 14px; +} +.signup-field { + display: flex; + flex-direction: column; + gap: 6px; +} +.signup-label { + font-size: 12px; + font-weight: 600; + color: #6a6a6a; +} +.signup-input { + padding: 11px 13px; + border-radius: 10px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: #fafafa; + font-family: inherit; + font-size: 14px; + color: #111111; + transition: border-color 0.15s, background 0.15s; +} +.signup-input:focus { + outline: none; + border-color: #002d72; + background: #ffffff; +} +.signup-error { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: #ef4444; +} + +/* Buttons */ +.signup-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + margin-top: 24px; +} +.signup-primary { + padding: 12px 22px; + border-radius: 10px; + background: #002d72; + color: #ffffff; + border: 1px solid #002d72; + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.signup-primary:hover:not(:disabled) { + background: #00399a; +} +.signup-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.signup-back { + padding: 12px 16px; + border-radius: 10px; + background: none; + border: 1px solid rgba(0, 0, 0, 0.12); + font-family: inherit; + font-size: 14px; + color: #6a6a6a; + cursor: pointer; + margin-right: auto; +} +.signup-back:hover { + color: #111111; + border-color: rgba(0, 0, 0, 0.28); +} + +/* Account-type cards */ +.signup-choices { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.signup-choice { + display: flex; + flex-direction: column; + gap: 5px; + text-align: left; + padding: 16px; + border-radius: 12px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: #ffffff; + font-family: inherit; + cursor: pointer; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} +.signup-choice:hover { + border-color: rgba(0, 0, 0, 0.3); +} +.signup-choice--on { + border-color: #002d72; + background: rgba(0, 45, 114, 0.04); + box-shadow: 0 0 0 1px #002d72 inset; +} +.signup-choice-label { + font-size: 14.5px; + font-weight: 600; + color: #111111; +} +.signup-choice-blurb { + font-size: 12.5px; + line-height: 1.45; + color: #6a6a6a; +} + +/* Plan cards */ +.signup-plans { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.signup-plan { + display: flex; + flex-direction: column; + gap: 6px; + text-align: left; + padding: 16px; + border-radius: 12px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: #ffffff; + font-family: inherit; + cursor: pointer; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} +.signup-plan:hover { + border-color: rgba(0, 0, 0, 0.3); +} +.signup-plan--on { + border-color: #002d72; + background: rgba(0, 45, 114, 0.04); + box-shadow: 0 0 0 1px #002d72 inset; +} +.signup-plan-name { + font-size: 15px; + font-weight: 700; + color: #111111; +} +.signup-plan-tagline { + font-size: 12.5px; + line-height: 1.45; + color: #6a6a6a; +} +.signup-plan-points { + list-style: none; + margin: 6px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 5px; +} +.signup-plan-points li { + display: flex; + align-items: flex-start; + gap: 6px; + font-size: 12.5px; + line-height: 1.4; + color: #4a4a4a; +} +.signup-plan-points svg { + flex-shrink: 0; + margin-top: 2px; + color: #002d72; +} + +/* Terms */ +.signup-agree { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 16px; + border-radius: 12px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: #fafafa; + font-size: 13.5px; + line-height: 1.5; + color: #4a4a4a; + cursor: pointer; +} +.signup-agree input { + margin-top: 2px; + width: 16px; + height: 16px; + accent-color: #002d72; + flex-shrink: 0; + cursor: pointer; +} +.signup-agree a { + color: #002d72; + font-weight: 600; +} + +@media (max-width: 560px) { + .signup-panel { + padding: 24px 20px 22px; + border-radius: 14px; + } + .signup-title { + font-size: 21px; + } + .signup-choices, + .signup-plans { + grid-template-columns: minmax(0, 1fr); + } + .signup-actions { + flex-wrap: wrap; + } + .signup-primary, + .signup-back { + flex: 1; + text-align: center; + } +} diff --git a/PanTS-Demo/src/routes/SignupPage.tsx b/PanTS-Demo/src/routes/SignupPage.tsx new file mode 100644 index 0000000..8285f2b --- /dev/null +++ b/PanTS-Demo/src/routes/SignupPage.tsx @@ -0,0 +1,331 @@ +import { IconBrandGithub, IconBrandGoogle, IconCheck } from "@tabler/icons-react"; +import React, { useEffect, useState } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { useAuth } from "../contexts/authContext"; +import { + ACCOUNT_TYPES, + PLANS, + type AccountType, + type PlanId, +} from "../helpers/accountProfile"; +import "./SignupPage.css"; + +// Fullscreen sign-up, modelled on how ChatGPT/Claude onboard: create the +// account, then a few questions, rather than one wall of fields. Sign-IN stays +// the popup (components/AuthModal) — it's a one-field return path and doesn't +// deserve a page. +// +// The account-type and plan answers are a client-side mock today +// (helpers/accountProfile.ts): nothing is gated, nothing is charged, no prices +// are shown. They exist so the flow is real enough to react to. +// +// ?step=type is the OAuth entry point — the provider callback lands back in the +// app, and App/authContext sends first-time users straight here with the +// account already created, so step 1 is skipped. + +type Step = "account" | "type" | "plan" | "terms"; +const STEPS: Step[] = ["account", "type", "plan", "terms"]; +const STEP_TITLES: Record = { + account: "Create your account", + type: "How will you use BodyMaps?", + plan: "Choose a plan", + terms: "Terms and privacy", +}; + +const SignupPage: React.FC = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { + user, isAuthenticated, loading, signUp, signInWithProvider, + oauthProviders, updateAccountProfile, promptAuth, + } = useAuth(); + + // Steps after the first need an account to attach answers to, so an + // unauthenticated visitor always starts at step 1 regardless of ?step. + const requestedStep = searchParams.get("step") as Step | null; + const [step, setStep] = useState( + requestedStep && STEPS.includes(requestedStep) ? requestedStep : "account" + ); + + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + const [accountType, setAccountType] = useState("patient"); + const [plan, setPlan] = useState("free"); + const [agreed, setAgreed] = useState(false); + + // Someone who already finished signup has no business here. + useEffect(() => { + if (!loading && isAuthenticated && user?.profile.onboardingCompletedAt) { + navigate("/upload", { replace: true }); + } + }, [loading, isAuthenticated, user, navigate]); + + // Arriving mid-flow (OAuth callback, or a refresh) without an account sends + // you back to step 1. + useEffect(() => { + if (!loading && !isAuthenticated && step !== "account") setStep("account"); + }, [loading, isAuthenticated, step]); + + // Prefill from whatever the account already knows (OAuth arrivals especially). + // The name comes off the user, not the profile mock — it has a real column. + useEffect(() => { + if (user) { + setName((n) => n || (user.hasCustomName ? user.name : "")); + setAccountType(user.profile.accountType); + setPlan(user.profile.plan); + } + }, [user]); + + const stepIndex = STEPS.indexOf(step); + + const submitAccount = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + if (!name.trim()) { setError("Enter your name."); return; } + if (!email.trim() || !password) { setError("Enter an email and password."); return; } + if (password !== confirm) { setError("Passwords don't match."); return; } + setBusy(true); + try { + await signUp(email, password, name); + setStep("type"); + } catch (err) { + setError(err instanceof Error && err.message ? err.message : "Something went wrong. Try again."); + } finally { + setBusy(false); + } + }; + + // The name was already sent to the server by signUp(); only the mock fields + // are written here. + const finish = () => { + const now = new Date().toISOString(); + updateAccountProfile({ + accountType, + plan, + acceptedTermsAt: now, + onboardingCompletedAt: now, + }); + navigate("/upload", { replace: true }); + }; + + return ( +
+
+ + + BodyMaps + + {step === "account" && ( +
+ Already have an account?{" "} + +
+ )} +
+ +
+
+ {/* Progress */} +
    + {STEPS.map((s, i) => ( +
  1. + + {i < stepIndex ? : i + 1} + +
  2. + ))} +
+ +

{STEP_TITLES[step]}

+ + {/* ── Step 1: account ── */} + {step === "account" && ( + <> +
+ + +
+ +
or
+ +
+ + + + + {error &&
{error}
} + +
+ + )} + + {/* ── Step 2: account type ── */} + {step === "type" && ( + <> +

+ This tailors what you see — the tools, the wording, and how results are + presented. You can change it any time in account settings. +

+
+ {ACCOUNT_TYPES.map((t) => ( + + ))} +
+
+ +
+ + )} + + {/* ── Step 3: plan ── */} + {step === "plan" && ( + <> +

+ Pricing isn't set yet and nothing is charged — everything is available + while BodyMaps is in development. Pick what fits how you'd use it. +

+
+ {PLANS.map((p) => ( + + ))} +
+

+ Researcher or student? There's a free academic access lane — ask us once + you're in. +

+
+ + +
+ + )} + + {/* ── Step 4: terms ── */} + {step === "terms" && ( + <> +

+ BodyMaps is research software. It does not provide a diagnosis, and its + output is not a substitute for a qualified clinician's judgment. +

+ +
+ + +
+ + )} +
+
+
+ ); +}; + +export default SignupPage; diff --git a/PanTS-Demo/src/routes/UploadPage.tsx b/PanTS-Demo/src/routes/UploadPage.tsx index 83ee9e5..b36aeb4 100644 --- a/PanTS-Demo/src/routes/UploadPage.tsx +++ b/PanTS-Demo/src/routes/UploadPage.tsx @@ -110,11 +110,13 @@ type SelectedItem = const UploadPage: React.FC = () => { const navigate = useNavigate(); // Running inference requires an account, so any upload action while signed - // out opens the sign-up popup instead of proceeding. + // out opens the sign-in popup instead of proceeding. Sign-in, not sign-up: + // most people hitting this already have an account, and the popup offers a + // "Sign up" link out to /signup for the ones who don't. const { isAuthenticated, promptAuth } = useAuth(); const ensureAccount = (): boolean => { if (isAuthenticated) return true; - promptAuth("signup"); + promptAuth(); return false; }; const fileInputRef = useRef(null); @@ -235,7 +237,7 @@ const UploadPage: React.FC = () => { e.preventDefault(); setIsDragOver(false); // Inlined (not via ensureAccount) so the memoized closure sees fresh auth. - if (!isAuthenticated) { promptAuth("signup"); return; } + if (!isAuthenticated) { promptAuth(); return; } if (!e.dataTransfer.files) return; const filteredFiles = Array.from(e.dataTransfer.files).filter((file) => allowedExtensions.some((ext) => file.name.toLowerCase().endsWith(ext)), @@ -1587,7 +1589,7 @@ const UploadPage: React.FC = () => { {!isAuthenticated && (
- {" "} to run inference on the server and get notified when it's done. diff --git a/PanTS-Demo/src/test/accounts.test.tsx b/PanTS-Demo/src/test/accounts.test.tsx new file mode 100644 index 0000000..abfb407 --- /dev/null +++ b/PanTS-Demo/src/test/accounts.test.tsx @@ -0,0 +1,134 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactElement } from "react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AuthProvider } from "../contexts/authContext"; +import { loadProfile } from "../helpers/accountProfile"; +import SignupPage from "../routes/SignupPage"; + +// The signup flow end to end at the component level: all four steps, and where +// each answer lands. Account type and plan are a localStorage mock, so asserting +// on storage is the real check; the name is NOT — it goes to the server with the +// registration, so that one is asserted on the request. +// +// The account page has its own suite (accountPage.test.tsx), which exercises the +// real endpoints rather than the mock. + +const USER = { id: "u1", email: "test@example.com" }; + +// /auth/me answers with a user only after `signedIn` flips, so the same stub +// serves both the signed-out signup case and the signed-in account case. +let signedIn = false; +// What the register endpoint actually received, so the name can be asserted on +// the request rather than on local storage. +let registeredWith: Record | null = null; + +const jsonResponse = (body: unknown) => ({ + ok: true, + status: 200, + json: async () => body, + text: async () => "", + headers: { get: () => "application/json" }, +}); + +beforeEach(() => { + localStorage.clear(); + signedIn = false; + registeredWith = null; + global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const u = String(url); + if (u.includes("/api/auth/me")) return jsonResponse({ user: signedIn ? USER : null }); + if (u.includes("/api/auth/register") || u.includes("/api/auth/login")) { + if (u.includes("/register") && init?.body) { + registeredWith = JSON.parse(String(init.body)); + } + signedIn = true; + return jsonResponse({ user: USER }); + } + if (u.includes("/api/auth/oauth/providers")) return jsonResponse({ google: true, github: true }); + return jsonResponse({ items: [], total: 0, ids: [] }); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const renderAt = (ui: ReactElement, path = "/") => + render( + + + + + Upload page
} /> + Landing} /> + + + + ); + +describe("SignupPage", () => { + it("walks all four steps and stores the answers against the user", async () => { + const user = userEvent.setup(); + renderAt(, "/signup"); + + // Step 1 — credentials. + expect(await screen.findByText("Create your account")).toBeInTheDocument(); + await user.type(screen.getByLabelText(/^Name$/i), "Ada Lovelace"); + await user.type(screen.getByLabelText(/^Email$/i), "test@example.com"); + await user.type(screen.getByLabelText(/^Password$/i), "hunter2hunter2"); + await user.type(screen.getByLabelText(/Confirm password/i), "hunter2hunter2"); + await user.click(screen.getByRole("button", { name: "Continue" })); + + // Step 2 — account type. + expect(await screen.findByText("How will you use BodyMaps?")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Clinician" })); + await user.click(screen.getByRole("button", { name: "Continue" })); + + // Step 3 — plan. No prices anywhere. + expect(await screen.findByText("Choose a plan")).toBeInTheDocument(); + expect(document.body.textContent).not.toMatch(/\$\d/); + await user.click(screen.getByRole("button", { name: "Pro" })); + await user.click(screen.getByRole("button", { name: "Continue" })); + + // Step 4 — terms. Finish stays disabled until the box is ticked. + expect(await screen.findByText("Terms and privacy")).toBeInTheDocument(); + const finish = screen.getByRole("button", { name: "Finish" }); + expect(finish).toBeDisabled(); + await user.click(screen.getByRole("checkbox")); + expect(finish).toBeEnabled(); + await user.click(finish); + + // Account type and plan are the mock; the name is not — it was sent to + // /auth/register and lives in user_account.name. + await waitFor(() => { + const profile = loadProfile(USER.id); + expect(profile).toMatchObject({ accountType: "clinician", plan: "pro" }); + expect(profile?.acceptedTermsAt).toBeTruthy(); + expect(profile?.onboardingCompletedAt).toBeTruthy(); + }); + expect(registeredWith).toMatchObject({ email: "test@example.com", name: "Ada Lovelace" }); + expect(loadProfile(USER.id)).not.toHaveProperty("name"); + }); + + it("refuses to advance when the passwords don't match", async () => { + const user = userEvent.setup(); + renderAt(, "/signup"); + + await user.type(await screen.findByLabelText(/^Name$/i), "Ada"); + await user.type(screen.getByLabelText(/^Email$/i), "test@example.com"); + await user.type(screen.getByLabelText(/^Password$/i), "hunter2hunter2"); + await user.type(screen.getByLabelText(/Confirm password/i), "something-else"); + await user.click(screen.getByRole("button", { name: "Continue" })); + + expect(await screen.findByText("Passwords don't match.")).toBeInTheDocument(); + expect(screen.getByText("Create your account")).toBeInTheDocument(); + }); + + it("sends someone with no account back to step 1 even when asked for a later step", async () => { + // ?step=type is the OAuth entry point; without a session it must not stick. + renderAt(, "/signup?step=type"); + expect(await screen.findByText("Create your account")).toBeInTheDocument(); + }); +}); diff --git a/PanTS-Demo/src/test/routes.smoke.test.tsx b/PanTS-Demo/src/test/routes.smoke.test.tsx index 087dd55..419767b 100644 --- a/PanTS-Demo/src/test/routes.smoke.test.tsx +++ b/PanTS-Demo/src/test/routes.smoke.test.tsx @@ -59,19 +59,38 @@ describe("route smoke tests", () => { it("AuthModal opens as a popup with provider options when prompted", async () => { const Trigger = () => { const { promptAuth } = useAuth(); - useEffect(() => promptAuth("signin"), [promptAuth]); + useEffect(() => promptAuth(), [promptAuth]); return null; }; - render( - + // AuthModal links out to /signup, so it needs a router in the tree. + renderRoute( + <> - , + , ); expect(await screen.findByText("Sign in with Google")).toBeInTheDocument(); expect(screen.getByText("Sign in with GitHub")).toBeInTheDocument(); }); + it("AuthModal offers sign up as a link to the /signup page, not a popup mode", async () => { + const Trigger = () => { + const { promptAuth } = useAuth(); + useEffect(() => promptAuth(), [promptAuth]); + return null; + }; + renderRoute( + <> + + + , + ); + expect(await screen.findByRole("link", { name: "Sign up" })).toHaveAttribute( + "href", + "/signup", + ); + }); + it("AccountPage renders the email-notification setting when signed in", async () => { // The session lives in an httponly cookie, so "signed in" means /auth/me // answers with a user - not a localStorage key. From c34d437ceac02847970e3ffe67f32feaefed5ff9 Mon Sep 17 00:00:00 2001 From: Yusufa09 Date: Fri, 7 Aug 2026 15:49:36 -0400 Subject: [PATCH 2/4] Sign in without choosing plan, better settings page, mimic claude UI --- PanTS-Demo/src/App.tsx | 53 +- .../src/components/AIAssistant/AISidebar.tsx | 29 +- .../src/components/Header/Header.module.css | 42 +- PanTS-Demo/src/components/Header/index.tsx | 26 +- PanTS-Demo/src/components/UpgradeDialog.css | 135 +++++ PanTS-Demo/src/components/UpgradeDialog.tsx | 150 +++++ PanTS-Demo/src/contexts/authContext.tsx | 68 ++- PanTS-Demo/src/helpers/accountProfile.test.ts | 132 +++-- PanTS-Demo/src/helpers/accountProfile.ts | 274 ++++++--- PanTS-Demo/src/helpers/recentUploads.test.ts | 48 ++ PanTS-Demo/src/helpers/recentUploads.ts | 25 +- PanTS-Demo/src/routes/AccountPage.css | 319 ----------- PanTS-Demo/src/routes/AccountPage.tsx | 407 -------------- .../src/routes/Settings/HistorySettings.tsx | 84 +++ .../routes/Settings/NotificationSettings.tsx | 31 + .../src/routes/Settings/PlanSettings.tsx | 168 ++++++ .../src/routes/Settings/PrivacySettings.tsx | 124 ++++ .../src/routes/Settings/ProfileSettings.tsx | 126 +++++ PanTS-Demo/src/routes/Settings/Settings.css | 530 ++++++++++++++++++ PanTS-Demo/src/routes/Settings/context.ts | 23 + PanTS-Demo/src/routes/Settings/index.tsx | 107 ++++ PanTS-Demo/src/routes/SignupPage.css | 247 ++------ PanTS-Demo/src/routes/SignupPage.tsx | 358 +++--------- PanTS-Demo/src/routes/UploadPage.css | 40 ++ PanTS-Demo/src/routes/UploadPage.tsx | 101 +++- PanTS-Demo/src/test/accountPage.test.tsx | 235 ++++++-- PanTS-Demo/src/test/accounts.test.tsx | 142 +++-- PanTS-Demo/src/test/planGating.test.tsx | 283 ++++++++++ PanTS-Demo/src/test/routes.smoke.test.tsx | 17 +- PanTS-Demo/src/test/uploadScheduling.test.tsx | 4 +- flask-server/.gitignore | 3 +- flask-server/api/api_blueprint.py | 63 +++ flask-server/api/auth_blueprint.py | 29 +- .../b4d21f907ac3_plan_and_usage_events.py | 66 +++ flask-server/models/engine.py | 1 + flask-server/models/usage_event.py | 50 ++ flask-server/models/user.py | 5 + flask-server/services/plan_store.py | 294 ++++++++++ .../tests/functional/test_auth_endpoints.py | 37 ++ flask-server/tests/unit/test_plan_store.py | 143 +++++ 40 files changed, 3427 insertions(+), 1592 deletions(-) create mode 100644 PanTS-Demo/src/components/UpgradeDialog.css create mode 100644 PanTS-Demo/src/components/UpgradeDialog.tsx delete mode 100644 PanTS-Demo/src/routes/AccountPage.css delete mode 100644 PanTS-Demo/src/routes/AccountPage.tsx create mode 100644 PanTS-Demo/src/routes/Settings/HistorySettings.tsx create mode 100644 PanTS-Demo/src/routes/Settings/NotificationSettings.tsx create mode 100644 PanTS-Demo/src/routes/Settings/PlanSettings.tsx create mode 100644 PanTS-Demo/src/routes/Settings/PrivacySettings.tsx create mode 100644 PanTS-Demo/src/routes/Settings/ProfileSettings.tsx create mode 100644 PanTS-Demo/src/routes/Settings/Settings.css create mode 100644 PanTS-Demo/src/routes/Settings/context.ts create mode 100644 PanTS-Demo/src/routes/Settings/index.tsx create mode 100644 PanTS-Demo/src/test/planGating.test.tsx create mode 100644 flask-server/migrations/versions/b4d21f907ac3_plan_and_usage_events.py create mode 100644 flask-server/models/usage_event.py create mode 100644 flask-server/services/plan_store.py create mode 100644 flask-server/tests/unit/test_plan_store.py diff --git a/PanTS-Demo/src/App.tsx b/PanTS-Demo/src/App.tsx index 8af358e..99a61e2 100644 --- a/PanTS-Demo/src/App.tsx +++ b/PanTS-Demo/src/App.tsx @@ -1,10 +1,9 @@ -import { lazy, Suspense, useEffect } from "react"; -import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router"; +import { lazy, Suspense } from "react"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router"; import "./App.css"; import AuthModal from "./components/AuthModal"; import { AnnotationProvider } from "./contexts/annotationContexts"; -import { AuthProvider, useAuth } from "./contexts/authContext"; -import { needsOnboarding } from "./helpers/accountProfile"; +import { AuthProvider } from "./contexts/authContext"; import { FileProvider } from "./contexts/fileContexts"; import LandingPage from "./routes/LandingPage"; import ComparePage from "./routes/ComparePage"; @@ -18,7 +17,12 @@ import ScrollToTopButton from "./components/ScrollToTopButton"; const VisualizationPage = lazy(() => import("./routes/VisualizationPage")); const CompareViewerPage = lazy(() => import("./routes/CompareViewerPage")); const UploadPage = lazy(() => import("./routes/UploadPage")); -const AccountPage = lazy(() => import("./routes/AccountPage")); +const SettingsPage = lazy(() => import("./routes/Settings")); +const ProfileSettings = lazy(() => import("./routes/Settings/ProfileSettings")); +const PlanSettings = lazy(() => import("./routes/Settings/PlanSettings")); +const HistorySettings = lazy(() => import("./routes/Settings/HistorySettings")); +const NotificationSettings = lazy(() => import("./routes/Settings/NotificationSettings")); +const PrivacySettings = lazy(() => import("./routes/Settings/PrivacySettings")); const SignupPage = lazy(() => import("./routes/SignupPage")); const LegalPage = lazy(() => import("./routes/LegalPage")); const RotatingHeartLoader = lazy(() => import("./components/Loading")); @@ -52,34 +56,6 @@ function RouteFallback() { ); } -// Routes that must stay reachable while onboarding is incomplete — otherwise the -// gate below would bounce the signup flow off its own page. -const ONBOARDING_EXEMPT = ["/signup", "/terms", "/privacy"]; - -// Sends a signed-in user who never finished signup into the account-type / plan / -// terms steps. This is what catches OAuth first-timers: the provider callback -// lands them back in the app with an account but no answers. -// -// MOCK LIMITATION: "never finished" is read from this browser's localStorage -// (helpers/accountProfile.needsOnboarding), so the same OAuth user on a new -// device is asked again. A user_account.onboarding_completed_at column returned -// by /api/auth/me is the real fix. -function OnboardingGate() { - const { user, isAuthenticated, loading } = useAuth(); - const location = useLocation(); - const navigate = useNavigate(); - - useEffect(() => { - if (loading || !isAuthenticated || !user) return; - if (ONBOARDING_EXEMPT.some((p) => location.pathname.startsWith(p))) return; - if (needsOnboarding(user.id)) { - navigate("/signup?step=type", { replace: true }); - } - }, [loading, isAuthenticated, user, location.pathname, navigate]); - - return null; -} - function App() { return ( @@ -88,7 +64,6 @@ function App() {
- }> } /> @@ -115,7 +90,15 @@ function App() { {/* Sign in is a popup; sign up is a page. Old /login links land on home. */} } /> } /> - } /> + {/* Settings is a shell with a left nav; each section is its + own URL so a link can point straight at one. */} + }> + } /> + } /> + } /> + } /> + } /> + } /> } /> ({})); + throw new PlanLimitError(limit.message || "You've reached today's message limit."); + } if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`); const reader = response.body.getReader(); @@ -720,9 +733,13 @@ export default function AISidebar({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), + credentials: "include", signal, }); const data = await response.json(); + if (response.status === 402) { + throw new PlanLimitError(data.message || "You've reached today's message limit."); + } if (!response.ok) throw new Error(data.reply || `HTTP ${response.status}`); const returnedActions: AIAction[] = Array.isArray(data.actions) ? data.actions : []; if (returnedActions.length) void applyReturnedActions(returnedActions); @@ -818,12 +835,22 @@ export default function AISidebar({ } catch (streamError) { if (isAbort(streamError)) { // User pressed Stop — keep whatever was streamed, no error. + } else if (streamError instanceof PlanLimitError) { + // A spent allowance is an answer, not a transport failure: retrying + // on the non-streaming endpoint would just be refused again. + updateMessage(assistantId, (m) => ({ + ...m, content: streamError.message, status: undefined, + })); } else { console.warn("[BodyMaps AI stream] falling back:", streamError); try { await sendNonStreaming(assistantId, payload, controller.signal); } catch (error) { - if (!isAbort(error)) { + if (error instanceof PlanLimitError) { + updateMessage(assistantId, (m) => ({ + ...m, content: error.message, status: undefined, + })); + } else if (!isAbort(error)) { console.error("[BodyMaps AI send error]", error); updateMessage(assistantId, (m) => ({ ...m, diff --git a/PanTS-Demo/src/components/Header/Header.module.css b/PanTS-Demo/src/components/Header/Header.module.css index 586dfdd..ee5327f 100644 --- a/PanTS-Demo/src/components/Header/Header.module.css +++ b/PanTS-Demo/src/components/Header/Header.module.css @@ -77,8 +77,8 @@ color: #ffffff; } -/* ── GitHub link container (right side) ── */ -.githubContainer { +/* ── Right-side actions (auth button + hamburger) ── */ +.navActions { display: flex; justify-content: flex-end; align-items: center; @@ -86,24 +86,6 @@ flex-shrink: 0; } -/* ── GitHub link (desktop) ── */ -.githubLink { - display: flex; - align-items: center; - gap: 6px; - border-radius: 6px; - padding: 8px 12px; - font-size: 12px; - color: #6a6a6a; - text-decoration: none; - transition: color 0.2s, background 0.2s; - min-height: 44px; -} -.githubLink:hover { - color: #111111; - background: rgba(0, 0, 0, 0.05); -} - /* ── Hamburger button (hidden on desktop) ── */ .hamburger { display: none; @@ -147,8 +129,7 @@ .nav { padding: 14px 20px; } .tabBar { display: none; } .hamburger { display: flex; } - .githubLink { display: none; } - .githubContainer { width: auto; } + .navActions { width: auto; } /* Backdrop */ .backdrop { @@ -257,20 +238,3 @@ border-top: 1px solid rgba(0, 0, 0, 0.07); flex-shrink: 0; } -.mobileGithubLink { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - border-radius: 10px; - font-size: 14px; - font-weight: 600; - color: #6a6a6a; - text-decoration: none; - min-height: 44px; - transition: color 0.15s, background 0.15s; -} -.mobileGithubLink:hover { - color: #111111; - background: rgba(0, 0, 0, 0.04); -} diff --git a/PanTS-Demo/src/components/Header/index.tsx b/PanTS-Demo/src/components/Header/index.tsx index 3dce466..0b27c97 100644 --- a/PanTS-Demo/src/components/Header/index.tsx +++ b/PanTS-Demo/src/components/Header/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useRef, useState } from "react"; -import { IconBrandGithub, IconX } from "@tabler/icons-react"; +import { IconX } from "@tabler/icons-react"; import { Link, NavLink } from "react-router-dom"; import AuthButton from "../AuthButton"; import styles from "./Header.module.css"; @@ -79,18 +79,7 @@ export default function Header() { ))}
-
- - - +
+ +

{headline(block)}

+

{detail(block)}

+ + {targetPlan && ( +
+
{targetPlan.label}
+
    + {targetPlan.points.slice(0, 4).map((p) => ( +
  • {p}
  • + ))} +
+
+ )} + +
+ {targetPlan && ( + + )} + +
+
+
+ ); +}; + +export default UpgradeDialog; diff --git a/PanTS-Demo/src/contexts/authContext.tsx b/PanTS-Demo/src/contexts/authContext.tsx index a945ce8..3ebe137 100644 --- a/PanTS-Demo/src/contexts/authContext.tsx +++ b/PanTS-Demo/src/contexts/authContext.tsx @@ -16,9 +16,14 @@ // reversible for a grace period: the server keeps the row and signing back in // cancels it. // +// The plan is real too: user_account.plan, changed through /me/plan, and its +// limits are enforced server-side (see flask-server/services/plan_store.py). +// There is no payment step — pricing hasn't been set — but the limits bite. +// // Not wired yet: emailNotifications -> B3, still a client-only localStorage pref. -// Also client-only: account type and plan (helpers/accountProfile.ts). The name -// is NOT part of that mock — it has a real column and goes through updateName. +// Also client-only: account type (helpers/accountProfile.ts), which is +// self-reported and gates nothing. The name is NOT part of that — it has a real +// column and goes through updateName. import { createContext, useCallback, @@ -33,6 +38,7 @@ import { loadProfile, updateProfile as persistProfilePatch, type AccountProfile, + type PlanId, } from "../helpers/accountProfile"; import { API_BASE } from "../helpers/constants"; @@ -44,10 +50,19 @@ export type AuthUser = { /** True when `name` is the user's own rather than derived from the email. */ hasCustomName: boolean; emailNotifications: boolean; // client-only preference until B3 - /** Account type + plan. Client-only mock until the backend grows the columns. */ + /** Billing plan, from user_account.plan. Its limits are enforced server-side. */ + plan: PlanId; + /** Self-reported account type. Client-only, and gates nothing. */ profile: AccountProfile; }; +/** What GET /me/usage returns: the plan's limits and what's been used of them. */ +export type PlanUsage = { + plan: PlanId; + scans: { used: number; limit: number | null; in_flight: number; resets_at: string | null }; + ai_messages: { used: number; limit: number | null; resets_at: string | null }; +}; + export type AuthProvider2 = "google" | "github"; type AuthContextValue = { @@ -75,8 +90,13 @@ type AuthContextValue = { * in — resolves with the deadline and the grace period, so the UI can say so. */ deleteAccount: () => Promise<{ restoreBy: string; graceDays: number }>; - /** Patch the account-type / plan / onboarding mock. */ + /** Patch the self-reported account type. */ updateAccountProfile: (patch: Partial) => void; + /** Move to another plan. No payment step — pricing isn't set. */ + setPlan: (plan: PlanId) => Promise; + /** Current plan usage, or null until loaded. Refreshed by refreshUsage(). */ + usage: PlanUsage | null; + refreshUsage: () => Promise; // Global sign-in popup, opened from the header or any gated action. Signing // up is a page (/signup), so this has no signup mode. authPrompt: { open: boolean }; @@ -120,7 +140,7 @@ const savePref = (id: string, on: boolean) => { } }; -type ApiUser = { id: string; email: string; name?: string | null }; +type ApiUser = { id: string; email: string; name?: string | null; plan?: string | null }; const mapApiUser = (u: ApiUser): AuthUser => { const custom = (u.name || "").trim(); return { @@ -130,6 +150,7 @@ const mapApiUser = (u: ApiUser): AuthUser => { name: custom || nameFromEmail(u.email), hasCustomName: custom.length > 0, emailNotifications: loadPref(u.id), + plan: (u.plan as PlanId) || "free", profile: loadProfile(u.id) ?? DEFAULT_PROFILE, }; }; @@ -148,6 +169,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true); const [authPrompt, setAuthPrompt] = useState<{ open: boolean }>({ open: false }); const [oauthProviders, setOauthProviders] = useState | null>(null); + const [usage, setUsage] = useState(null); // The OAuth callback redirects back with ?auth_error=... on failure (e.g. an // unverified provider email colliding with an existing account). Read it once // on mount, then strip it from the URL so a refresh doesn't resurface it. @@ -339,6 +361,35 @@ export function AuthProvider({ children }: { children: ReactNode }) { [user] ); + const refreshUsage = useCallback(async () => { + if (!user) { + setUsage(null); + return; + } + try { + const res = await authFetch("/api/me/usage"); + setUsage(res.ok ? await res.json() : null); + } catch { + setUsage(null); // a usage read failing is not worth surfacing + } + }, [user]); + + const setPlan = useCallback(async (plan: PlanId) => { + const res = await authFetch("/api/me/plan", { + method: "POST", + body: JSON.stringify({ plan }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "Couldn't change your plan. Try again."); + setUser(mapApiUser(data.user)); + }, []); + + // Keep usage in step with whoever is signed in — including after a plan + // change, since the limits it reports come from the plan. + useEffect(() => { + refreshUsage(); + }, [refreshUsage]); + const clearOauthError = useCallback(() => setOauthError(null), []); const value = useMemo( @@ -357,6 +408,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { deleteScanHistory, deleteAccount, updateAccountProfile, + setPlan, + usage, + refreshUsage, authPrompt, promptAuth, closeAuthPrompt, @@ -365,8 +419,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { }), [user, loading, signIn, signUp, signInWithProvider, oauthProviders, signOut, updatePreferences, updateName, exportData, deleteScanHistory, deleteAccount, - updateAccountProfile, authPrompt, promptAuth, closeAuthPrompt, oauthError, - clearOauthError] + updateAccountProfile, setPlan, usage, refreshUsage, authPrompt, promptAuth, + closeAuthPrompt, oauthError, clearOauthError] ); return {children}; diff --git a/PanTS-Demo/src/helpers/accountProfile.test.ts b/PanTS-Demo/src/helpers/accountProfile.test.ts index 1242ffa..5dfbd39 100644 --- a/PanTS-Demo/src/helpers/accountProfile.test.ts +++ b/PanTS-Demo/src/helpers/accountProfile.test.ts @@ -1,10 +1,16 @@ import { beforeEach, describe, expect, it } from "vitest"; import { + canPostprocess, clearProfile, DEFAULT_PROFILE, + isModelLocked, + limitsFor, loadProfile, - needsOnboarding, + maxConcurrentScans, + nextPlanUp, persistProfile, + PLAN_LIMITS, + PLANS, PROFILE_KEY_PREFIX, updateProfile, } from "./accountProfile"; @@ -15,6 +21,80 @@ beforeEach(() => { localStorage.clear(); }); +describe("plan limits", () => { + it("gives Free only the free model", () => { + expect(PLAN_LIMITS.free.models).toEqual(["LesionSegmenter"]); + expect(isModelLocked("free", "LesionSegmenter")).toBe(false); + expect(isModelLocked("free", "ePAI")).toBe(true); + expect(isModelLocked("free", "R-Super")).toBe(true); + }); + + it("never locks the view-only option — it doesn't reach the server", () => { + expect(isModelLocked("free", "None")).toBe(false); + }); + + it("unlocks every model above Free", () => { + for (const plan of ["pro", "team", "enterprise"] as const) { + expect(isModelLocked(plan, "ePAI")).toBe(false); + expect(canPostprocess(plan)).toBe(true); + } + }); + + it("holds postprocessing back from Free", () => { + expect(canPostprocess("free")).toBe(false); + }); + + it("reports concurrency as a number, unlimited included", () => { + expect(maxConcurrentScans("free")).toBe(1); + expect(maxConcurrentScans("pro")).toBe(5); + expect(maxConcurrentScans("enterprise")).toBe(Infinity); + }); + + it("falls back to Free for an unknown plan rather than throwing", () => { + // @ts-expect-error deliberately outside PlanId — a stale value from the API + expect(limitsFor("platinum")).toEqual(PLAN_LIMITS.free); + expect(limitsFor(undefined)).toEqual(PLAN_LIMITS.free); + }); + + it("points each plan at the next one up, and Enterprise at nothing", () => { + expect(nextPlanUp("free")).toBe("pro"); + expect(nextPlanUp("pro")).toBe("team"); + expect(nextPlanUp("team")).toBe("enterprise"); + expect(nextPlanUp("enterprise")).toBeNull(); + }); +}); + +describe("plan cards", () => { + it("splits into two individual and two team plans", () => { + expect(PLANS.filter((p) => p.group === "individual").map((p) => p.id)) + .toEqual(["free", "pro"]); + expect(PLANS.filter((p) => p.group === "team").map((p) => p.id)) + .toEqual(["team", "enterprise"]); + }); + + it("keeps every card short enough to scan", () => { + for (const plan of PLANS) { + expect(plan.points.length).toBeLessThanOrEqual(6); + // Bullets are noun phrases, not sentences. + for (const point of plan.points) { + expect(point.split(" ").length).toBeLessThanOrEqual(6); + expect(point).not.toMatch(/\.$/); + } + expect(plan.blurb.split(" ").length).toBeLessThanOrEqual(7); + } + }); + + it("quotes no prices — none have been set", () => { + const text = JSON.stringify(PLANS); + expect(text).not.toMatch(/[$£€]\s?\d/); + expect(text).not.toMatch(/per month|\/mo\b/i); + }); + + it("has a limits entry for every card and vice versa", () => { + expect(PLANS.map((p) => p.id).sort()).toEqual(Object.keys(PLAN_LIMITS).sort()); + }); +}); + describe("loadProfile", () => { it("returns null when the user has no stored profile", () => { expect(loadProfile(USER)).toBeNull(); @@ -25,19 +105,20 @@ describe("loadProfile", () => { expect(loadProfile(USER)).toBeNull(); }); - it("fills in fields missing from a profile written by an older build", () => { + it("ignores plan and onboarding keys left by an older build", () => { + // Both moved server-side; a stale local copy must not resurface. localStorage.setItem( `${PROFILE_KEY_PREFIX}${USER}`, - JSON.stringify({ plan: "pro" }) + JSON.stringify({ plan: "enterprise", onboardingCompletedAt: "2026-01-01", accountType: "clinician" }) ); - expect(loadProfile(USER)).toEqual({ ...DEFAULT_PROFILE, plan: "pro" }); + expect(loadProfile(USER)).toEqual({ accountType: "clinician" }); }); it("keeps profiles for different users separate", () => { - persistProfile(USER, { ...DEFAULT_PROFILE, plan: "pro" }); - persistProfile("user-2", { ...DEFAULT_PROFILE, plan: "team" }); - expect(loadProfile(USER)?.plan).toBe("pro"); - expect(loadProfile("user-2")?.plan).toBe("team"); + persistProfile(USER, { accountType: "clinician" }); + persistProfile("user-2", { accountType: "researcher" }); + expect(loadProfile(USER)?.accountType).toBe("clinician"); + expect(loadProfile("user-2")?.accountType).toBe("researcher"); }); }); @@ -49,40 +130,23 @@ describe("updateProfile", () => { }); }); - it("merges into the existing profile rather than replacing it", () => { - updateProfile(USER, { plan: "pro" }); - const next = updateProfile(USER, { accountType: "researcher" }); - expect(next).toMatchObject({ plan: "pro", accountType: "researcher" }); + it("persists across a reload", () => { + updateProfile(USER, { accountType: "researcher" }); + expect(loadProfile(USER)?.accountType).toBe("researcher"); }); - it("persists across a reload", () => { - updateProfile(USER, { plan: "enterprise" }); - expect(loadProfile(USER)?.plan).toBe("enterprise"); + it("can clear the account type back to unset", () => { + updateProfile(USER, { accountType: "student" }); + expect(updateProfile(USER, { accountType: null }).accountType).toBeNull(); }); }); describe("clearProfile", () => { it("removes only the target user's profile", () => { - persistProfile(USER, { ...DEFAULT_PROFILE, plan: "pro" }); - persistProfile("user-2", { ...DEFAULT_PROFILE, plan: "team" }); + persistProfile(USER, { accountType: "clinician" }); + persistProfile("user-2", { accountType: "researcher" }); clearProfile(USER); expect(loadProfile(USER)).toBeNull(); - expect(loadProfile("user-2")?.plan).toBe("team"); - }); -}); - -describe("needsOnboarding", () => { - it("is true for a user with no profile at all", () => { - expect(needsOnboarding(USER)).toBe(true); - }); - - it("stays true while onboarding is only partly done", () => { - updateProfile(USER, { accountType: "clinician", plan: "pro" }); - expect(needsOnboarding(USER)).toBe(true); - }); - - it("is false once onboarding is marked complete", () => { - updateProfile(USER, { onboardingCompletedAt: new Date().toISOString() }); - expect(needsOnboarding(USER)).toBe(false); + expect(loadProfile("user-2")?.accountType).toBe("researcher"); }); }); diff --git a/PanTS-Demo/src/helpers/accountProfile.ts b/PanTS-Demo/src/helpers/accountProfile.ts index d403300..2cf5bb5 100644 --- a/PanTS-Demo/src/helpers/accountProfile.ts +++ b/PanTS-Demo/src/helpers/accountProfile.ts @@ -1,128 +1,233 @@ -// Account profile: account type, plan, and onboarding state. +// Plans, what each one allows, and the one remaining client-only profile field. // -// MOCK — everything here lives in localStorage, keyed by user id. Nothing is -// enforced: no feature is gated by plan or type, no quota is counted, no queue -// priority is applied. This is the shape the signup flow collects so the UI can -// be built and reacted to before any of it is made real. +// PLAN_LIMITS mirrors flask-server/services/plan_store.py. The server is what +// actually decides — it returns 402 with a reason, and that reason is what the +// upgrade dialog reports. This copy exists so the UI can grey a locked control +// out up front instead of letting you click into a rejection. A drift between +// the two is a cosmetic bug here and a real one there; change them together. // -// Deliberately NOT here: the display name and account deletion. Both have real -// server-side homes (user_account.name, user_account.deletion_requested_at) and -// go through authContext's updateName / deleteAccount. Mirroring them here too -// would give each two sources of truth that drift the moment one is written -// without the other. -// -// This file is the single seam for what remains. When the backend grows -// `account_type` and `plan` columns, only load/persist below change — every -// caller reads through authContext, which reads through here. -// -// Follows the helpers/recentUploads.ts pattern (typed shape, try/catch around -// every storage access, pure functions so it can be unit-tested). +// The plan itself is NOT stored here. It lives on user_account.plan and arrives +// through authContext. What's left in localStorage is `accountType`, which is +// self-reported, optional, and gates nothing. export type AccountType = "patient" | "clinician" | "researcher" | "student"; export type PlanId = "free" | "pro" | "team" | "enterprise"; +/** Which tab of the plan picker a plan appears under. */ +export type PlanGroup = "individual" | "team"; -export type AccountProfile = { - accountType: AccountType; - plan: PlanId; - /** ISO timestamp of terms acceptance; null until the signup flow's last step. */ - acceptedTermsAt: string | null; - /** ISO timestamp; null means signup was never finished (drives OAuth onboarding). */ - onboardingCompletedAt: string | null; +/** null means unlimited, matching the server's representation. */ +export type PlanLimits = { + dailyScans: number | null; + concurrentScans: number | null; + /** Model ids the plan may run; null means every model. */ + models: string[] | null; + postprocessing: boolean; + dailyAiMessages: number | null; + createReports: boolean; + retentionDays: number | null; + priorityQueue: boolean; }; -export const PROFILE_KEY_PREFIX = "accountProfile:"; - -const profileKey = (userId: string) => `${PROFILE_KEY_PREFIX}${userId}`; - -export const ACCOUNT_TYPES: { id: AccountType; label: string; blurb: string }[] = [ - { - id: "patient", - label: "Patient", - blurb: "Understand your own scan in plain language.", +export const PLAN_LIMITS: Record = { + free: { + dailyScans: 3, + concurrentScans: 1, + models: ["LesionSegmenter"], + postprocessing: false, + dailyAiMessages: 10, + createReports: false, + retentionDays: 7, + priorityQueue: false, }, - { - id: "clinician", - label: "Clinician", - blurb: "Read scans for patients, with the full measurement toolset.", + pro: { + dailyScans: 50, + concurrentScans: 5, + models: null, + postprocessing: true, + dailyAiMessages: 200, + createReports: true, + retentionDays: 365, + priorityQueue: true, }, - { - id: "researcher", - label: "Researcher", - blurb: "Run cohorts and export structured results in bulk.", + team: { + dailyScans: 50, + concurrentScans: 5, + models: null, + postprocessing: true, + dailyAiMessages: 200, + createReports: true, + retentionDays: 365, + priorityQueue: true, }, - { - id: "student", - label: "Student or trainee", - blurb: "Learn anatomy against the public PanTS dataset.", + enterprise: { + dailyScans: null, + concurrentScans: null, + models: null, + postprocessing: true, + dailyAiMessages: null, + createReports: true, + retentionDays: null, + priorityQueue: true, }, -]; +}; + +export const limitsFor = (plan: PlanId | undefined): PlanLimits => + PLAN_LIMITS[plan ?? "free"] ?? PLAN_LIMITS.free; + +// Plan cards. Kept to a name, one line, a price, and a handful of short bullets +// — the shape Claude and ChatGPT both use. Longer copy was the old version's +// problem: four plans each explaining themselves in paragraphs is a wall, and +// nobody reads a wall to pick a tier. +export type Plan = { + id: PlanId; + label: string; + group: PlanGroup; + /** One line under the name. Six words is the budget. */ + blurb: string; + /** Shown where a price goes. No numbers: pricing hasn't been set. */ + price: string; + /** Small pill in the card's top corner (member counts), if any. */ + badge?: string; + /** Renders "Everything in , plus:" above the bullets. */ + inherits?: PlanId; + /** Short noun phrases, not sentences. */ + points: string[]; + cta: string; +}; -// No prices yet — deliberately. These describe who each plan is for, which is -// the part worth settling before anyone picks a number. -export const PLANS: { id: PlanId; label: string; tagline: string; points: string[] }[] = [ +export const PLANS: Plan[] = [ { id: "free", label: "Free", - tagline: "For a single scan, or to try things out.", + group: "individual", + blurb: "Try BodyMaps", + // Not "Free" — that's the plan's name, and repeating it in the price slot + // reads as a rendering bug. + price: "No cost", points: [ - "A few scans per month", + "3 scans a day", + "LesionSegmenter model", "Full viewer and 3D reconstruction", - "Standard queue", - "Results kept short-term", + "10 assistant messages a day", + "Results kept 7 days", ], + cta: "Start free", }, { id: "pro", label: "Pro", - tagline: "For one clinician or researcher working seriously.", + group: "individual", + blurb: "For everyday clinical and research work", + price: "Not priced yet", + inherits: "free", points: [ - "Room for regular use", - "Priority in the inference queue", - "Several scans processing at once", - "Specialized models", - "Long-term result retention", + "50 scans a day", + "Every model", + "5 scans at once", + "Priority in the queue", + "Create reports and annotations", + "Results kept a year", ], + cta: "Upgrade to Pro", }, { id: "team", label: "Team", - tagline: "For a practice or research group working together.", + group: "team", + blurb: "For a practice or lab", + price: "Not priced yet", + badge: "2–15 members", + inherits: "pro", + // No "everything in Pro per member" bullet: the inherits line above the + // list already says it. points: [ - "Everything in Pro, per seat", - "Shared case library and annotations", - "Pooled quota across the team", - "Roles and an audit log", + "Shared case library", + "Shared annotations and reports", + "Usage pooled across the team", + "Central billing", ], + cta: "Choose Team", }, { id: "enterprise", label: "Enterprise", - tagline: "For a hospital or institution.", + group: "team", + blurb: "For a hospital or institution", + // "Talk to us" is the button; the price slot needs to say something else. + price: "Custom", + badge: "15+ members", + inherits: "team", points: [ "Single sign-on", - "Dedicated or on-premise compute", - "Custom retention, DPA and BAA", - "API access and support commitments", + "Signed BAA and DPA", + "On-premise processing", + "Access audit log", + "Retention set to your policy", + "PACS integration", ], + cta: "Talk to us", }, ]; -export const DEFAULT_PROFILE: AccountProfile = { - accountType: "patient", - plan: "free", - acceptedTermsAt: null, - onboardingCompletedAt: null, +export const planLabel = (id: PlanId): string => + PLANS.find((p) => p.id === id)?.label ?? id; + +/** The plan an upgrade prompt should point at. Enterprise has nowhere to go. */ +export const nextPlanUp = (plan: PlanId): PlanId | null => + plan === "free" ? "pro" : plan === "pro" ? "team" : plan === "team" ? "enterprise" : null; + +// ---- feature checks -------------------------------------------------------- +// Cosmetic only — each has a server-side counterpart that does the real work. + +/** Whether a model id is outside the plan's allowance. "None" is view-only and + * never reaches the server, so it's never locked. */ +export const isModelLocked = (plan: PlanId, modelId: string): boolean => { + if (modelId === "None") return false; + const allowed = limitsFor(plan).models; + return allowed !== null && !allowed.includes(modelId); }; +export const canPostprocess = (plan: PlanId): boolean => limitsFor(plan).postprocessing; + +export const canCreateReports = (plan: PlanId): boolean => limitsFor(plan).createReports; + +/** How many scans this plan may have running at once (Infinity if unlimited). */ +export const maxConcurrentScans = (plan: PlanId): number => + limitsFor(plan).concurrentScans ?? Infinity; + +// ---- account type (self-reported, optional, gates nothing) ----------------- + +export const ACCOUNT_TYPES: { id: AccountType; label: string }[] = [ + { id: "patient", label: "Patient" }, + { id: "clinician", label: "Clinician" }, + { id: "researcher", label: "Researcher" }, + { id: "student", label: "Student" }, +]; + +export const accountTypeLabel = (id: AccountType | null): string => + ACCOUNT_TYPES.find((t) => t.id === id)?.label ?? "Not set"; + +export type AccountProfile = { + /** null until the user picks one in settings. Nothing depends on it. */ + accountType: AccountType | null; +}; + +export const DEFAULT_PROFILE: AccountProfile = { accountType: null }; + +export const PROFILE_KEY_PREFIX = "accountProfile:"; + +const profileKey = (userId: string) => `${PROFILE_KEY_PREFIX}${userId}`; + export const loadProfile = (userId: string): AccountProfile | null => { try { const raw = localStorage.getItem(profileKey(userId)); if (!raw) return null; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return null; - // Merge over defaults so a profile written by an older build (missing a - // field added later) still loads instead of throwing. - return { ...DEFAULT_PROFILE, ...parsed }; + // Merge over defaults so a profile written by an older build still loads. + // Older builds also wrote plan/onboarding keys here; they're ignored now + // that the plan is a server column, and dropped on the next write. + return { accountType: parsed.accountType ?? null }; } catch { return null; } @@ -152,20 +257,3 @@ export const clearProfile = (userId: string) => { console.warn("clearProfile failed", e); } }; - -/** - * Whether to send this user through the account-type/plan/terms steps. - * - * MOCK LIMITATION: "needs onboarding" really means "no profile in *this - * browser's* localStorage", so an OAuth user returning on a new device is asked - * again. The real fix is a user_account.onboarding_completed_at column returned - * by /api/auth/me — this function is the one place that changes when it lands. - */ -export const needsOnboarding = (userId: string): boolean => - loadProfile(userId)?.onboardingCompletedAt == null; - -export const planLabel = (id: PlanId): string => - PLANS.find((p) => p.id === id)?.label ?? id; - -export const accountTypeLabel = (id: AccountType): string => - ACCOUNT_TYPES.find((t) => t.id === id)?.label ?? id; diff --git a/PanTS-Demo/src/helpers/recentUploads.test.ts b/PanTS-Demo/src/helpers/recentUploads.test.ts index 01a039a..52b3132 100644 --- a/PanTS-Demo/src/helpers/recentUploads.test.ts +++ b/PanTS-Demo/src/helpers/recentUploads.test.ts @@ -4,7 +4,10 @@ import { formatRelativeTime, loadRecentUploads, MAX_RECENT_UPLOADS, + RECENT_WINDOW_MS, recentStatusColor, + splitByAge, + groupUploads, updateRecentUploadStatus, type RecentUpload, } from "./recentUploads"; @@ -86,3 +89,48 @@ describe("recentStatusColor", () => { expect(recentStatusColor("Completed")).toBe("#8f8f8f"); }); }); + +describe("splitByAge", () => { + const now = Date.UTC(2026, 7, 7, 12, 0, 0); + const agoMs = (ms: number) => now - ms; + + it("keeps the last day on the upload page and sends the rest to history", () => { + const groups = groupUploads([ + makeEntry({ sessionId: "today", timestamp: agoMs(2 * 60 * 60 * 1000) }), + makeEntry({ sessionId: "old", timestamp: agoMs(3 * RECENT_WINDOW_MS) }), + ]); + + const { recent, older } = splitByAge(groups, now); + expect(recent.map((g) => g.timestamp)).toEqual([agoMs(2 * 60 * 60 * 1000)]); + expect(older.map((g) => g.timestamp)).toEqual([agoMs(3 * RECENT_WINDOW_MS)]); + }); + + it("treats an entry exactly on the boundary as recent", () => { + const groups = groupUploads([ + makeEntry({ sessionId: "edge", timestamp: agoMs(RECENT_WINDOW_MS) }), + ]); + expect(splitByAge(groups, now).recent).toHaveLength(1); + }); + + it("judges a batch by its newest scan so it is never torn in half", () => { + const groups = groupUploads([ + makeEntry({ + sessionId: "a", batchId: "b1", batchLabel: "2 scans", + timestamp: agoMs(3 * RECENT_WINDOW_MS), + }), + makeEntry({ + sessionId: "b", batchId: "b1", batchLabel: "2 scans", + timestamp: agoMs(60 * 1000), + }), + ]); + + const { recent, older } = splitByAge(groups, now); + expect(older).toHaveLength(0); + expect(recent).toHaveLength(1); + expect(recent[0].kind === "batch" && recent[0].uploads).toHaveLength(2); + }); + + it("returns both sides empty for an empty list", () => { + expect(splitByAge([], now)).toEqual({ recent: [], older: [] }); + }); +}); diff --git a/PanTS-Demo/src/helpers/recentUploads.ts b/PanTS-Demo/src/helpers/recentUploads.ts index f52c160..d30b99c 100644 --- a/PanTS-Demo/src/helpers/recentUploads.ts +++ b/PanTS-Demo/src/helpers/recentUploads.ts @@ -17,8 +17,10 @@ export type RecentUpload = { }; export const RECENT_UPLOADS_KEY = "recentUploads"; -// Raised from 8 so a multi-scan batch isn't half-evicted from the list. -export const MAX_RECENT_UPLOADS = 60; +// Raised from 8 so a multi-scan batch isn't half-evicted from the list, and +// again from 60 once anything older than a day became the History page's +// content — a history that forgets your 61st scan isn't much of one. +export const MAX_RECENT_UPLOADS = 200; const TERMINAL: RecentUploadStatus[] = ["Completed", "Failed", "Cancelled"]; export const isTerminalStatus = (s: RecentUploadStatus): boolean => TERMINAL.includes(s); @@ -68,6 +70,25 @@ export const isGroupInFlight = (g: UploadGroup): boolean => ? g.upload.status === "Processing" : g.uploads.some((u) => u.status === "Processing"); +// How long a finished scan stays on the Upload page before it belongs to +// history. A day is the window in which you're still working with a result; +// past that the Upload page is showing you a filing cabinet. +export const RECENT_WINDOW_MS = 24 * 60 * 60 * 1000; + +/** Split finished groups into what the Upload page shows and what History gets. + * A batch is judged by its most recent scan, so a batch straddling the + * boundary stays whole rather than being torn in half. */ +export const splitByAge = ( + groups: UploadGroup[], + now: number = Date.now() +): { recent: UploadGroup[]; older: UploadGroup[] } => { + const cutoff = now - RECENT_WINDOW_MS; + return { + recent: groups.filter((g) => g.timestamp >= cutoff), + older: groups.filter((g) => g.timestamp < cutoff), + }; +}; + export const loadRecentUploads = (): RecentUpload[] => { try { const arr = JSON.parse(localStorage.getItem(RECENT_UPLOADS_KEY) || "[]"); diff --git a/PanTS-Demo/src/routes/AccountPage.css b/PanTS-Demo/src/routes/AccountPage.css deleted file mode 100644 index 338b3c8..0000000 --- a/PanTS-Demo/src/routes/AccountPage.css +++ /dev/null @@ -1,319 +0,0 @@ -/* Account settings. Monochrome + JHU Heritage Blue accents, matching the app. */ - -@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap'); - -.account-wrapper { - min-height: 100vh; - background: #ffffff; - color: #111111; - font-family: 'Space Grotesk', sans-serif; -} - -.account-main { - max-width: 640px; - margin: 0 auto; - padding: 48px 24px 96px; -} - -.account-title { - font-size: 28px; - font-weight: 700; - letter-spacing: -0.02em; - margin: 0 0 8px; -} - -.account-section { - margin-top: 28px; -} - -.account-section-label { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; - color: #8f8f8f; - margin-bottom: 12px; - padding-left: 2px; -} - -.account-panel { - background: #f5f5f5; - border: 1px solid rgba(0, 0, 0, 0.06); - border-radius: 14px; - padding: 4px 20px; -} - -/* Feedback after an action (name saved, export downloaded, delete failed). */ -.account-notice { - margin-top: 16px; - padding: 11px 14px; - border-radius: 10px; - background: rgba(0, 45, 114, 0.06); - border: 1px solid rgba(0, 45, 114, 0.18); - font-size: 13px; - color: #002d72; -} -.account-notice--error { - background: rgba(239, 68, 68, 0.06); - border-color: rgba(239, 68, 68, 0.28); - color: #b91c1c; -} - -/* Profile */ -.account-profile { - display: flex; - align-items: center; - gap: 16px; - padding: 18px 0; -} -.account-profile-body { - flex: 1; - min-width: 0; -} -.account-name-edit { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} -.account-avatar { - width: 44px; - height: 44px; - flex-shrink: 0; - border-radius: 50%; - background: #002d72; - color: #ffffff; - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - font-weight: 700; -} -.account-name { - font-size: 15px; - font-weight: 600; - color: #111111; -} -.account-email { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - color: #6a6a6a; - margin-top: 2px; -} - -/* Rows */ -.account-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - padding: 18px 0; -} -.account-row-title { - font-size: 14px; - font-weight: 600; - color: #111111; -} -.account-row-desc { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - line-height: 1.6; - color: #8f8f8f; - margin-top: 4px; - max-width: 42ch; -} - -/* Toggle switch */ -.account-switch { - position: relative; - flex-shrink: 0; - width: 46px; - height: 26px; - border-radius: 13px; - border: none; - background: rgba(0, 0, 0, 0.18); - cursor: pointer; - transition: background 0.2s ease; - padding: 0; -} -.account-switch--on { - background: #002d72; -} -.account-switch-knob { - position: absolute; - top: 3px; - left: 3px; - width: 20px; - height: 20px; - border-radius: 50%; - background: #ffffff; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); - transition: transform 0.2s ease; -} -.account-switch--on .account-switch-knob { - transform: translateX(20px); -} - -/* Rows that stack their choices underneath (account type). */ -.account-row--stack { - flex-direction: column; - align-items: stretch; - gap: 14px; -} - -/* Selectable plan / account-type cards */ -.account-options { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 10px; - padding-bottom: 18px; -} -.account-option { - display: flex; - flex-direction: column; - gap: 4px; - text-align: left; - padding: 13px 14px; - border-radius: 11px; - border: 1px solid rgba(0, 0, 0, 0.12); - background: #ffffff; - font-family: 'Space Grotesk', sans-serif; - cursor: pointer; - transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; -} -.account-option:hover { - border-color: rgba(0, 0, 0, 0.3); -} -.account-option--on { - border-color: #002d72; - background: rgba(0, 45, 114, 0.04); - box-shadow: 0 0 0 1px #002d72 inset; -} -.account-option-label { - font-size: 14px; - font-weight: 600; - color: #111111; -} -.account-option-blurb { - font-size: 12px; - line-height: 1.45; - color: #6a6a6a; -} - -/* Text input (name edit, type-to-confirm) */ -.account-input { - flex: 1; - min-width: 0; - padding: 8px 11px; - border-radius: 9px; - border: 1px solid rgba(0, 0, 0, 0.14); - background: #ffffff; - font-family: 'Space Grotesk', sans-serif; - font-size: 14px; - color: #111111; -} -.account-input:focus { - outline: none; - border-color: #002d72; -} - -/* Neutral button used across the page */ -.account-btn { - flex-shrink: 0; - padding: 9px 16px; - border-radius: 10px; - background: #ffffff; - border: 1px solid rgba(0, 0, 0, 0.14); - color: #111111; - font-family: 'Space Grotesk', sans-serif; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: background 0.15s, border-color 0.15s; -} -.account-btn:hover:not(:disabled) { - background: rgba(0, 0, 0, 0.03); - border-color: rgba(0, 0, 0, 0.3); -} -.account-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} -.account-btn--primary { - background: #002d72; - border-color: #002d72; - color: #ffffff; -} -.account-btn--primary:hover:not(:disabled) { - background: #00399a; - border-color: #00399a; -} - -/* Danger zone */ -.account-panel--danger { - background: rgba(239, 68, 68, 0.03); - border-color: rgba(239, 68, 68, 0.22); -} -.account-danger-btn { - flex-shrink: 0; - padding: 9px 16px; - border-radius: 10px; - background: #ffffff; - border: 1px solid rgba(239, 68, 68, 0.4); - color: #ef4444; - font-family: 'Space Grotesk', sans-serif; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: background 0.15s, border-color 0.15s; -} -.account-danger-btn:hover:not(:disabled) { - background: rgba(239, 68, 68, 0.06); - border-color: rgba(239, 68, 68, 0.6); -} -.account-danger-btn:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.account-confirm { - margin: 0 0 18px; - padding: 14px; - border-radius: 11px; - background: #ffffff; - border: 1px solid rgba(239, 68, 68, 0.3); -} -.account-confirm-text { - font-size: 13px; - color: #4a4a4a; - margin-bottom: 10px; -} -.account-confirm-text strong { - font-family: 'JetBrains Mono', monospace; - color: #ef4444; -} -.account-confirm-actions { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -/* Sign out */ -.account-signout { - flex-shrink: 0; - padding: 9px 18px; - border-radius: 10px; - background: #ffffff; - border: 1px solid rgba(239, 68, 68, 0.4); - color: #ef4444; - font-family: 'Space Grotesk', sans-serif; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: background 0.15s, border-color 0.15s; -} -.account-signout:hover { - background: rgba(239, 68, 68, 0.06); - border-color: rgba(239, 68, 68, 0.6); -} diff --git a/PanTS-Demo/src/routes/AccountPage.tsx b/PanTS-Demo/src/routes/AccountPage.tsx deleted file mode 100644 index 4e748b2..0000000 --- a/PanTS-Demo/src/routes/AccountPage.tsx +++ /dev/null @@ -1,407 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import Header from "../components/Header"; -import { useAuth } from "../contexts/authContext"; -import { - ACCOUNT_TYPES, - PLANS, - accountTypeLabel, - planLabel, -} from "../helpers/accountProfile"; -import "./AccountPage.css"; - -// Account settings: profile (with an editable display name), the email -// notification preference, data export, and the destructive controls. Sends you -// home + opens the sign-in popup if reached signed out. -// -// Every action here hits a real endpoint. Deleting the account is reversible for -// a grace period the server reports back, and the confirmation says so. -const AccountPage: React.FC = () => { - const navigate = useNavigate(); - const { - user, isAuthenticated, loading, signOut, updatePreferences, - updateName, exportData, deleteScanHistory, deleteAccount, updateAccountProfile, - promptAuth, - } = useAuth(); - - const [editingName, setEditingName] = useState(false); - const [changingPlan, setChangingPlan] = useState(false); - const [nameDraft, setNameDraft] = useState(""); - // Which destructive action is awaiting type-to-confirm (null = none). - const [confirming, setConfirming] = useState<"history" | "account" | null>(null); - const [confirmText, setConfirmText] = useState(""); - const [busy, setBusy] = useState(false); - const [notice, setNotice] = useState(""); - const [error, setError] = useState(""); - - // Wait for the initial /me check before deciding. Without the `loading` guard - // a hard refresh on /account bounces you to the landing page, because the - // session cookie hasn't been exchanged for a user yet on first render. - useEffect(() => { - if (!loading && !isAuthenticated) { - navigate("/", { replace: true }); - promptAuth(); - } - }, [loading, isAuthenticated, navigate, promptAuth]); - - // Success messages clear themselves after a few seconds — they confirm - // something that already happened, so leaving one pinned there indefinitely - // makes the page look stuck. Errors are left up: those need acting on. - useEffect(() => { - if (!notice) return; - const t = setTimeout(() => setNotice(""), 6000); - return () => clearTimeout(t); - }, [notice]); - - if (!user) return null; - - const { profile } = user; - - // One wrapper so every action reports failure the same way instead of - // silently doing nothing. - const run = async (fn: () => Promise) => { - setBusy(true); - setError(""); - setNotice(""); - try { - await fn(); - } catch (e) { - setError(e instanceof Error ? e.message : "Something went wrong. Try again."); - } finally { - setBusy(false); - } - }; - - const saveName = () => - run(async () => { - await updateName(nameDraft.trim()); - setEditingName(false); - setNotice("Your name has been updated."); - }); - - const doExport = () => - run(async () => { - await exportData(); - setNotice("Your data has been downloaded."); - }); - - const confirmWord = confirming === "account" ? "DELETE" : "CLEAR"; - - const runDestructive = () => - run(async () => { - if (confirming === "history") { - const count = await deleteScanHistory(); - setConfirming(null); - setConfirmText(""); - setNotice( - count === 0 - ? "You had no scans to delete." - : `Deleted ${count} scan${count === 1 ? "" : "s"} and their results.` - ); - return; - } - // The danger-zone copy above states the grace period before you confirm, - // so nothing needs to follow you to the signed-out page. - await deleteAccount(); - navigate("/", { replace: true }); - }); - - return ( -
-
-
-

Account

- - {notice &&
{notice}
} - {error &&
{error}
} - - {/* Profile */} -
-
Profile
-
-
- - {(user.name || user.email).charAt(0).toUpperCase()} - -
- {editingName ? ( -
- setNameDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") saveName(); - if (e.key === "Escape") setEditingName(false); - }} - /> - - -
- ) : ( - <> -
{user.name}
-
{user.email}
- {!user.hasCustomName && ( -
- We guessed this from your email address. -
- )} - - )} -
- {!editingName && ( - - )} -
-
-
- - {/* Plan — mock: selecting one gates nothing and charges nothing. */} -
-
Plan
-
-
-
-
{planLabel(profile.plan)}
-
- {PLANS.find((p) => p.id === profile.plan)?.tagline} Nothing is charged — - pricing isn't set, and every feature is open while BodyMaps is in - development. -
-
- -
- - {changingPlan && ( -
- {PLANS.map((p) => ( - - ))} -
- )} -
-
- - {/* Account type — self-reported; changes what's surfaced, not access. */} -
-
Account type
-
-
-
-
{accountTypeLabel(profile.accountType)}
-
- Tailors what you see — which tools are surfaced and how results are - worded. Self-reported, and it doesn't change what you can access. -
-
-
- {ACCOUNT_TYPES.map((t) => ( - - ))} -
-
-
-
- - {/* Notifications */} -
-
Notifications
-
- -
-
- - {/* Data */} -
-
Your data
-
-
-
-
Export my data
-
- Download your account details and the full record of every scan - you've run, as a JSON file. Available any time — you don't have to - be leaving to take a copy. -
-
- -
-
-
- - {/* Danger zone */} -
-
Danger zone
-
-
-
-
Delete scan history
-
- Permanently deletes every scan you've uploaded and every - segmentation produced from them. Your account stays. This can't be - undone. -
-
- -
- -
-
-
Delete account
-
- Signs you out everywhere and schedules your account and all your - scans for deletion. You have 30 days to change your mind — just - sign back in and everything is restored. -
-
- -
- - {confirming && ( -
-
- Type {confirmWord} to confirm. -
-
- setConfirmText(e.target.value)} - /> - - -
-
- )} -
-
- - {/* Session */} -
-
Session
-
-
-
-
Sign out
-
End this session on this browser.
-
- -
-
-
-
-
- ); -}; - -export default AccountPage; diff --git a/PanTS-Demo/src/routes/Settings/HistorySettings.tsx b/PanTS-Demo/src/routes/Settings/HistorySettings.tsx new file mode 100644 index 0000000..07d0ba0 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/HistorySettings.tsx @@ -0,0 +1,84 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + formatRelativeTime, + groupUploads, + isGroupInFlight, + loadRecentUploads, + removeRecentUpload, + splitByAge, + type RecentUpload, + type UploadGroup, +} from "../../helpers/recentUploads"; + +// History: every scan older than a day. The Upload page keeps the last 24 hours +// so it stays a workbench rather than a filing cabinet; everything before that +// lives here. +// +// Reads the same localStorage list the Upload page does, so labels and batch +// grouping carry over. That means history is per-browser — the server's own +// record (GET /api/me/jobs) is only written by the queue path, not the one the +// Upload page actually uses, so it would show an empty list here. +const HistorySettings: React.FC = () => { + const navigate = useNavigate(); + const [uploads, setUploads] = useState(() => loadRecentUploads()); + + const finished = groupUploads(uploads).filter((g) => !isGroupInFlight(g)); + const { older } = splitByAge(finished); + + const remove = (group: UploadGroup) => { + const ids = + group.kind === "single" ? [group.upload.sessionId] : group.uploads.map((u) => u.sessionId); + let next = uploads; + ids.forEach((id) => { next = removeRecentUpload(id); }); + setUploads(next); + }; + + const open = (u: RecentUpload) => + navigate(`/${u.isReconstruction ? "reconstruction" : "session"}/${u.sessionId}`); + + return ( +
+

History

+

+ Scans older than a day. Newer ones stay on the Upload page. +

+ + {older.length === 0 ? ( +
Nothing here yet.
+ ) : ( +
+ {older.map((g) => { + const first = g.kind === "single" ? g.upload : g.uploads[0]; + const label = g.kind === "single" ? g.upload.label : g.label; + const meta = + g.kind === "single" + ? `${g.upload.model ? `${g.upload.model} · ` : ""}${g.upload.status} · ${formatRelativeTime(g.timestamp)}` + : `${g.uploads.filter((u) => u.status === "Completed").length} of ${g.uploads.length} completed · ${formatRelativeTime(g.timestamp)}`; + const viewable = first.status !== "Failed" && first.status !== "Cancelled"; + return ( +
+
+
{label}
+
{meta}
+
+
+ {viewable && ( + + )} + +
+
+ ); + })} +
+ )} +
+ ); +}; + +export default HistorySettings; diff --git a/PanTS-Demo/src/routes/Settings/NotificationSettings.tsx b/PanTS-Demo/src/routes/Settings/NotificationSettings.tsx new file mode 100644 index 0000000..e73e5d9 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/NotificationSettings.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { useAuth } from "../../contexts/authContext"; + +// Notifications. One switch today; its own section so adding the next one +// doesn't mean re-cutting the navigation. +const NotificationSettings: React.FC = () => { + const { user, updatePreferences } = useAuth(); + if (!user) return null; + + return ( +
+

Notifications

+ +
+ Email me when a scan finishes + +
+
+ ); +}; + +export default NotificationSettings; diff --git a/PanTS-Demo/src/routes/Settings/PlanSettings.tsx b/PanTS-Demo/src/routes/Settings/PlanSettings.tsx new file mode 100644 index 0000000..274dd7f --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/PlanSettings.tsx @@ -0,0 +1,168 @@ +import { IconCheck } from "@tabler/icons-react"; +import React, { useState } from "react"; +import { useAuth } from "../../contexts/authContext"; +import { PLANS, planLabel, type PlanGroup, type PlanId } from "../../helpers/accountProfile"; +import { useSettings } from "./context"; + +/** "in 6 hrs" / "in 24 min" from an ISO timestamp, or null once it's passed. */ +const untilLabel = (iso: string | null): string | null => { + if (!iso) return null; + const mins = Math.round((new Date(iso).getTime() - Date.now()) / 60000); + if (mins <= 0) return null; + if (mins < 60) return `Resets in ${mins} min`; + const hours = Math.round(mins / 60); + return `Resets in ${hours} hr${hours === 1 ? "" : "s"}`; +}; + +const UsageBar: React.FC<{ + label: string; + used: number; + limit: number | null; + resetsAt: string | null; +}> = ({ label, used, limit, resetsAt }) => { + const pct = limit === null ? 0 : Math.min(100, Math.round((used / limit) * 100)); + const spent = limit !== null && used >= limit; + const reset = untilLabel(resetsAt); + return ( +
+ + {label} + {reset && {reset}} + +
+
+
+ + {limit === null ? "Unlimited" : `${used} of ${limit}`} + +
+ ); +}; + +// Plan: what you're on, what you've used of it, and the picker. +// +// The picker is Claude's upgrade page — an Individual / Team and Enterprise +// segmented toggle over two cards each. Four plans side by side was the old +// version's mistake: nobody compares four columns of paragraphs. +// +// No payment: /me/plan is a column write. The limits are real either way. +const PlanSettings: React.FC = () => { + const { user, usage, setPlan, refreshUsage } = useAuth(); + const { busy, run, notify } = useSettings(); + const [group, setGroup] = useState("individual"); + + if (!user) return null; + + const current = user.plan; + const currentPlan = PLANS.find((p) => p.id === current); + + const choose = (id: PlanId) => + run(async () => { + await setPlan(id); + await refreshUsage(); + notify(`You're on ${planLabel(id)}.`); + }); + + return ( + <> +
+
+
+

{planLabel(current)} plan

+

{currentPlan?.blurb}

+
+
+
+ +
+

Usage

+

Rolling 24 hours.

+ {usage ? ( + <> + + + + ) : ( +

Loading…

+ )} +
+ +
+

Change plan

+

+ Nothing is charged — pricing hasn't been set. Limits apply as listed. +

+ +
+ {([ + ["individual", "Individual"], + ["team", "Team and Enterprise"], + ] as const).map(([id, label]) => ( + + ))} +
+ +
+ {PLANS.filter((p) => p.group === group).map((p) => { + const isCurrent = p.id === current; + return ( +
+ {p.badge && {p.badge}} +

{p.label}

+

{p.blurb}

+
{p.price}
+ +
    + {p.inherits && ( +
  • + Everything in {planLabel(p.inherits)}, plus: +
  • + )} + {p.points.map((pt) => ( +
  • + {pt} +
  • + ))} +
+
+ ); + })} +
+
+ + ); +}; + +export default PlanSettings; diff --git a/PanTS-Demo/src/routes/Settings/PrivacySettings.tsx b/PanTS-Demo/src/routes/Settings/PrivacySettings.tsx new file mode 100644 index 0000000..6422291 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/PrivacySettings.tsx @@ -0,0 +1,124 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../../contexts/authContext"; +import { useSettings } from "./context"; + +// Privacy: export, and the two destructive actions. +// +// There is no red "danger zone" panel any more. Claude puts "Delete account" +// in a plain row like any other and saves the weight for the confirmation — +// which is the right split: a warning you scroll past every visit stops +// registering, and the moment that actually matters is the click. +const PrivacySettings: React.FC = () => { + const navigate = useNavigate(); + const { exportData, deleteScanHistory, deleteAccount } = useAuth(); + const { busy, run, notify } = useSettings(); + + const [confirming, setConfirming] = useState<"history" | "account" | null>(null); + const [typed, setTyped] = useState(""); + + const word = confirming === "account" ? "DELETE" : "CLEAR"; + + const start = (which: "history" | "account") => { + setConfirming(which); + setTyped(""); + }; + + const confirm = () => + run(async () => { + if (confirming === "history") { + const count = await deleteScanHistory(); + setConfirming(null); + setTyped(""); + notify( + count === 0 + ? "You had no scans to delete." + : `Deleted ${count} scan${count === 1 ? "" : "s"} and their results.` + ); + return; + } + await deleteAccount(); + navigate("/", { replace: true }); + }); + + return ( + <> +
+

Your data

+ +
+ Export data + +
+ +
+ Delete scan history + +
+ +
+ Delete account + +
+ + {confirming && ( +
+
+ {confirming === "history" ? ( + <> + This permanently deletes every scan you've uploaded and every + segmentation produced from them. Your account stays. It can't be undone. + + ) : ( + <> + This signs you out everywhere and schedules your account and all your + scans for deletion. You have 30 days to change your mind — sign back in + and everything is restored. + + )} +
+ Type {word} to confirm. +
+
+ setTyped(e.target.value)} + /> + + +
+
+ )} +
+ + ); +}; + +export default PrivacySettings; diff --git a/PanTS-Demo/src/routes/Settings/ProfileSettings.tsx b/PanTS-Demo/src/routes/Settings/ProfileSettings.tsx new file mode 100644 index 0000000..66f8f1c --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/ProfileSettings.tsx @@ -0,0 +1,126 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../../contexts/authContext"; +import { ACCOUNT_TYPES, type AccountType } from "../../helpers/accountProfile"; +import { useSettings } from "./context"; + +// Profile: avatar, name, email, and the self-reported account type. +// +// The account type used to be a required signup step with four descriptive +// cards. Nothing reads it, so it's a plain optional select here — asking a +// question that changes nothing is worse than not asking it. +const ProfileSettings: React.FC = () => { + const navigate = useNavigate(); + const { user, updateName, updateAccountProfile, signOut } = useAuth(); + const { busy, run, notify } = useSettings(); + + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(""); + + if (!user) return null; + + const save = () => + run(async () => { + await updateName(draft.trim()); + setEditing(false); + notify("Your name has been updated."); + }); + + return ( +
+

Profile

+ +
+ Avatar + + {(user.name || user.email).charAt(0).toUpperCase()} + +
+ +
+ Name + {editing ? ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") save(); + if (e.key === "Escape") setEditing(false); + }} + /> + + +
+ ) : ( +
+ {user.name} + +
+ )} +
+ +
+ Email + {user.email} +
+ +
+ + Role + Optional. Doesn't affect what you can access. + + +
+ +
+ Sign out + +
+
+ ); +}; + +export default ProfileSettings; diff --git a/PanTS-Demo/src/routes/Settings/Settings.css b/PanTS-Demo/src/routes/Settings/Settings.css new file mode 100644 index 0000000..b926d66 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/Settings.css @@ -0,0 +1,530 @@ +/* Settings: left rail + panel of rows. + + The vocabulary is the site's (Space Grotesk, JHU blue #002d72, soft radii); + the structure is Claude's settings — a row is a label and its control with a + hairline between, and prose is the exception rather than the default. */ + +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap'); + +.set-wrapper { + min-height: 100vh; + background: #fafafa; + font-family: 'Space Grotesk', sans-serif; + color: #111111; +} + +.set-main { + max-width: 1000px; + margin: 0 auto; + padding: 28px 24px 80px; +} + +.set-title { + font-size: 26px; + font-weight: 700; + margin: 0 0 24px; +} + +.set-layout { + display: grid; + grid-template-columns: 176px minmax(0, 1fr); + gap: 28px; + align-items: start; +} + +/* ── Left rail ── */ +.set-nav { + display: flex; + flex-direction: column; + gap: 2px; + position: sticky; + top: 24px; +} +.set-nav-item { + padding: 9px 12px; + border-radius: 8px; + font-size: 14px; + color: #6a6a6a; + text-decoration: none; + transition: background 0.15s, color 0.15s; +} +.set-nav-item:hover { + background: rgba(0, 0, 0, 0.04); + color: #111111; +} +.set-nav-item--on { + background: rgba(0, 45, 114, 0.07); + color: #002d72; + font-weight: 600; +} + +/* ── Panel ── */ +.set-panel { + background: #ffffff; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 16px; + padding: 26px 28px; + min-height: 340px; +} + +.set-banner { + margin-bottom: 20px; + padding: 10px 14px; + border-radius: 10px; + background: rgba(0, 45, 114, 0.06); + border: 1px solid rgba(0, 45, 114, 0.14); + font-size: 13px; + color: #002d72; +} +.set-banner--error { + background: rgba(239, 68, 68, 0.06); + border-color: rgba(239, 68, 68, 0.22); + color: #b91c1c; +} + +.set-heading { + font-size: 17px; + font-weight: 700; + margin: 0 0 4px; +} +/* Section headings after the first need air above them. */ +.set-group + .set-group { + margin-top: 38px; +} +.set-sub { + font-size: 13px; + line-height: 1.55; + color: #8f8f8f; + margin: 0 0 12px; +} + +/* ── The row: label left, control right, hairline between ── */ +.set-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 16px 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.07); +} +.set-row:last-child { + border-bottom: none; +} +.set-row-label { + font-size: 14px; + color: #111111; +} +/* Used sparingly — only where the row would genuinely be ambiguous alone. */ +.set-row-note { + display: block; + margin-top: 3px; + font-size: 12.5px; + line-height: 1.45; + color: #8f8f8f; +} +.set-row-value { + font-size: 14px; + color: #6a6a6a; + font-family: 'JetBrains Mono', monospace; +} + +/* ── Controls ── */ +.set-btn { + padding: 8px 14px; + border-radius: 8px; + background: #ffffff; + border: 1px solid rgba(0, 0, 0, 0.14); + font-family: inherit; + font-size: 13px; + color: #111111; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, border-color 0.15s; +} +.set-btn:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.04); + border-color: rgba(0, 0, 0, 0.28); +} +.set-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.set-btn--primary { + background: #002d72; + border-color: #002d72; + color: #ffffff; + font-weight: 600; +} +.set-btn--primary:hover:not(:disabled) { + background: #00399a; + border-color: #00399a; +} +.set-btn--danger { + color: #b91c1c; + border-color: rgba(239, 68, 68, 0.3); +} +.set-btn--danger:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.06); + border-color: rgba(239, 68, 68, 0.5); +} + +.set-input { + padding: 8px 11px; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.14); + background: #fafafa; + font-family: inherit; + font-size: 14px; + color: #111111; + min-width: 200px; +} +.set-input:focus { + outline: none; + border-color: #002d72; + background: #ffffff; +} + +.set-select { + padding: 8px 11px; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.14); + background: #ffffff; + font-family: inherit; + font-size: 14px; + color: #111111; + cursor: pointer; +} + +.set-switch { + width: 40px; + height: 23px; + flex-shrink: 0; + border-radius: 999px; + border: none; + background: rgba(0, 0, 0, 0.16); + cursor: pointer; + padding: 3px; + display: flex; + transition: background 0.18s; +} +.set-switch--on { + background: #002d72; + justify-content: flex-end; +} +.set-switch-knob { + width: 17px; + height: 17px; + border-radius: 50%; + background: #ffffff; +} + +.set-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: #002d72; + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 600; + flex-shrink: 0; +} + +.set-row-edit { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +/* ── Confirmation (the only place destructive framing appears) ── */ +.set-confirm { + margin-top: 14px; + padding: 14px 16px; + border-radius: 10px; + border: 1px solid rgba(239, 68, 68, 0.25); + background: rgba(239, 68, 68, 0.04); +} +.set-confirm-text { + font-size: 13px; + line-height: 1.55; + color: #4a4a4a; + margin-bottom: 12px; +} +.set-confirm-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +/* ── Plan ── */ +.set-plan-hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding-bottom: 22px; + border-bottom: 1px solid rgba(0, 0, 0, 0.07); +} +.set-plan-name { + font-size: 20px; + font-weight: 700; + margin: 0; +} +.set-plan-blurb { + font-size: 13px; + color: #8f8f8f; + margin: 3px 0 0; +} + +/* Usage bar: label + reset time left, track, "n of m" right. */ +.set-usage { + display: grid; + grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr) auto; + align-items: center; + gap: 16px; + padding: 16px 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.07); +} +.set-usage:last-child { + border-bottom: none; +} +.set-usage-label { + font-size: 14px; +} +.set-usage-reset { + display: block; + margin-top: 2px; + font-size: 12px; + color: #8f8f8f; +} +.set-usage-track { + height: 6px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.07); + overflow: hidden; +} +.set-usage-fill { + height: 100%; + border-radius: 999px; + background: #002d72; + transition: width 0.3s; +} +.set-usage-fill--full { + background: #ef4444; +} +.set-usage-count { + font-family: 'JetBrains Mono', monospace; + font-size: 12.5px; + color: #6a6a6a; + white-space: nowrap; +} + +/* Segmented Individual / Team toggle above the plan cards. */ +.set-segmented { + display: inline-flex; + padding: 3px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.05); + margin: 0 auto 20px; +} +.set-segmented-btn { + padding: 7px 18px; + border: none; + border-radius: 999px; + background: transparent; + font-family: inherit; + font-size: 13px; + font-weight: 500; + color: #6a6a6a; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.set-segmented-btn--on { + background: #ffffff; + color: #111111; + font-weight: 600; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.set-plan-picker { + display: flex; + flex-direction: column; + align-items: center; +} +.set-plan-cards { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.set-plan-card { + position: relative; + display: flex; + flex-direction: column; + padding: 20px; + border-radius: 14px; + border: 1px solid rgba(0, 0, 0, 0.1); + background: #ffffff; +} +.set-plan-card--current { + border-color: #002d72; + background: rgba(0, 45, 114, 0.025); +} +.set-plan-badge { + position: absolute; + top: 16px; + right: 16px; + font-family: 'JetBrains Mono', monospace; + font-size: 9.5px; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.05); + color: #6a6a6a; + white-space: nowrap; +} +.set-plan-card-name { + font-size: 19px; + font-weight: 700; + margin: 0; +} +.set-plan-card-blurb { + font-size: 13px; + color: #8f8f8f; + margin: 3px 0 16px; +} +.set-plan-price { + font-size: 15px; + font-weight: 600; + color: #111111; + margin-bottom: 16px; +} +.set-plan-cta { + width: 100%; + padding: 10px; + border-radius: 9px; + font-family: inherit; + font-size: 13.5px; + font-weight: 600; + cursor: pointer; + background: #002d72; + border: 1px solid #002d72; + color: #ffffff; + transition: background 0.15s; +} +.set-plan-cta:hover:not(:disabled) { + background: #00399a; +} +.set-plan-cta--current { + background: transparent; + border-color: rgba(0, 0, 0, 0.14); + color: #8f8f8f; + cursor: default; +} +.set-plan-cta:disabled { + opacity: 0.6; + cursor: not-allowed; +} +.set-plan-points { + list-style: none; + margin: 18px 0 0; + padding: 18px 0 0; + border-top: 1px solid rgba(0, 0, 0, 0.07); + display: flex; + flex-direction: column; + gap: 8px; +} +.set-plan-inherits { + font-size: 12.5px; + color: #6a6a6a; + margin-bottom: 2px; +} +.set-plan-points li { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 13px; + line-height: 1.4; + color: #4a4a4a; +} +.set-plan-points svg { + flex-shrink: 0; + margin-top: 2px; + color: #002d72; +} + +/* ── History ── */ +.set-history-list { + display: flex; + flex-direction: column; +} +.set-history-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.07); +} +.set-history-row:last-child { + border-bottom: none; +} +.set-history-name { + font-size: 14px; + font-weight: 500; + color: #111111; +} +.set-history-meta { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + color: #8f8f8f; + margin-top: 3px; +} +.set-history-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.set-empty { + padding: 40px 20px; + text-align: center; + font-size: 13.5px; + color: #8f8f8f; +} + +@media (max-width: 820px) { + .set-layout { + grid-template-columns: minmax(0, 1fr); + gap: 16px; + } + .set-nav { + position: static; + flex-direction: row; + overflow-x: auto; + gap: 4px; + padding-bottom: 4px; + } + .set-nav-item { + white-space: nowrap; + } + .set-panel { + padding: 20px 18px; + } + .set-plan-cards { + grid-template-columns: minmax(0, 1fr); + } + .set-usage { + grid-template-columns: minmax(0, 1fr) auto; + } + .set-usage-track { + grid-column: 1 / -1; + order: 3; + } +} diff --git a/PanTS-Demo/src/routes/Settings/context.ts b/PanTS-Demo/src/routes/Settings/context.ts new file mode 100644 index 0000000..56e63b3 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/context.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from "react"; + +// Shared busy/notice state for the settings sections, so every action reports +// success and failure through the one banner in the shell. +// +// Its own file rather than living in index.tsx: exporting a hook next to a +// component breaks fast refresh for the whole module. + +export type SettingsContextValue = { + busy: boolean; + /** Runs an action, reporting failure in the shared banner. */ + run: (fn: () => Promise) => Promise; + notify: (message: string) => void; + fail: (message: string) => void; +}; + +export const SettingsContext = createContext(null); + +export function useSettings(): SettingsContextValue { + const ctx = useContext(SettingsContext); + if (!ctx) throw new Error("useSettings must be used within the settings shell"); + return ctx; +} diff --git a/PanTS-Demo/src/routes/Settings/index.tsx b/PanTS-Demo/src/routes/Settings/index.tsx new file mode 100644 index 0000000..acae86d --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/index.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useState } from "react"; +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import Header from "../../components/Header"; +import { useAuth } from "../../contexts/authContext"; +import { SettingsContext } from "./context"; +import "./Settings.css"; + +// Settings shell: a left nav and a panel, one URL per section. +// +// Replaces the single scrolling page this used to be. The layout follows +// Claude's settings — a narrow rail of sections, and a panel of rows where each +// row is a label on the left and its control on the right, separated by a +// hairline. The old page explained every control in a paragraph underneath it; +// almost all of that prose is gone. "Export data" does not need three lines +// telling you what exporting data is. +// +// Sections own their own content (ProfileSettings, PlanSettings, ...); this +// file owns the chrome, the signed-out redirect, and the shared busy/notice +// state so every action reports success and failure the same way. + +const SECTIONS = [ + { to: "/account", label: "Profile", end: true }, + { to: "/account/plan", label: "Plan" }, + { to: "/account/history", label: "History" }, + { to: "/account/notifications", label: "Notifications" }, + { to: "/account/privacy", label: "Privacy" }, +]; + +const SettingsPage: React.FC = () => { + const navigate = useNavigate(); + const { isAuthenticated, loading, promptAuth } = useAuth(); + + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(""); + const [error, setError] = useState(""); + + // Wait for the initial /me check before deciding. Without the `loading` guard + // a hard refresh on /account bounces you to the landing page, because the + // session cookie hasn't been exchanged for a user yet on first render. + useEffect(() => { + if (!loading && !isAuthenticated) { + navigate("/", { replace: true }); + promptAuth(); + } + }, [loading, isAuthenticated, navigate, promptAuth]); + + // Success messages clear themselves — they confirm something that already + // happened, so leaving one pinned makes the page look stuck. Errors stay up. + useEffect(() => { + if (!notice) return; + const t = setTimeout(() => setNotice(""), 6000); + return () => clearTimeout(t); + }, [notice]); + + const run = async (fn: () => Promise) => { + setBusy(true); + setError(""); + setNotice(""); + try { + await fn(); + } catch (e) { + setError(e instanceof Error ? e.message : "Something went wrong. Try again."); + } finally { + setBusy(false); + } + }; + + if (!isAuthenticated) return null; + + return ( +
+
+
+

Settings

+ +
+ + +
+ {notice &&
{notice}
} + {error &&
{error}
} + + + +
+
+
+
+ ); +}; + +export default SettingsPage; diff --git a/PanTS-Demo/src/routes/SignupPage.css b/PanTS-Demo/src/routes/SignupPage.css index 96fa36d..dd29487 100644 --- a/PanTS-Demo/src/routes/SignupPage.css +++ b/PanTS-Demo/src/routes/SignupPage.css @@ -1,5 +1,8 @@ /* Fullscreen sign-up. Same light/monochrome vocabulary as the auth popup - (components/AuthModal.css): Space Grotesk, JHU blue, soft radii. */ + (components/AuthModal.css): Space Grotesk, JHU blue, soft radii. + + One column, one card, nothing above the fold but the thing you came to do — + the layout Claude and ChatGPT both land on. */ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap'); @@ -15,10 +18,7 @@ .signup-header { display: flex; align-items: center; - justify-content: space-between; - gap: 16px; padding: 20px 28px; - flex-wrap: wrap; } .signup-brand { @@ -36,10 +36,6 @@ border-radius: 8px; } -.signup-header-alt { - font-size: 13px; - color: #6a6a6a; -} .signup-link { background: none; border: none; @@ -55,79 +51,33 @@ flex: 1; display: flex; justify-content: center; - padding: 16px 20px 64px; + /* Sits a little above centre — a card pinned to the exact middle of a tall + viewport reads as floating. */ + align-items: flex-start; + padding: 4vh 20px 64px; } .signup-panel { width: 100%; - max-width: 560px; + max-width: 420px; background: #ffffff; border: 1px solid rgba(0, 0, 0, 0.08); border-radius: 18px; box-shadow: 0 18px 44px rgba(0, 0, 0, 0.06); - padding: 32px 32px 28px; -} - -/* Progress dots */ -.signup-steps { - display: flex; - align-items: center; - gap: 8px; - list-style: none; - margin: 0 0 22px; - padding: 0; -} -.signup-step { - flex: 1; -} -.signup-step-dot { - display: flex; - align-items: center; - justify-content: center; - height: 26px; - border-radius: 999px; - background: rgba(0, 0, 0, 0.05); - color: #8f8f8f; - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - font-weight: 500; - transition: background 0.2s, color 0.2s; -} -.signup-step--current .signup-step-dot { - background: #002d72; - color: #ffffff; -} -.signup-step--done .signup-step-dot { - background: rgba(0, 45, 114, 0.14); - color: #002d72; + padding: 32px 32px 26px; } .signup-title { font-size: 24px; font-weight: 700; - margin: 0 0 8px; -} - -.signup-sub { - font-size: 14px; - line-height: 1.55; - color: #6a6a6a; margin: 0 0 22px; + text-align: center; } -.signup-note { - font-size: 12.5px; - line-height: 1.5; - color: #8f8f8f; - margin: 16px 0 0; -} - -/* Providers + divider */ .signup-providers { display: flex; flex-direction: column; gap: 10px; - margin-top: 20px; } .signup-provider { width: 100%; @@ -174,7 +124,6 @@ padding: 0 12px; } -/* Form */ .signup-form { display: flex; flex-direction: column; @@ -211,15 +160,9 @@ color: #ef4444; } -/* Buttons */ -.signup-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 12px; - margin-top: 24px; -} .signup-primary { + width: 100%; + margin-top: 4px; padding: 12px 22px; border-radius: 10px; background: #002d72; @@ -238,164 +181,38 @@ opacity: 0.5; cursor: not-allowed; } -.signup-back { - padding: 12px 16px; - border-radius: 10px; - background: none; - border: 1px solid rgba(0, 0, 0, 0.12); - font-family: inherit; - font-size: 14px; - color: #6a6a6a; - cursor: pointer; - margin-right: auto; -} -.signup-back:hover { - color: #111111; - border-color: rgba(0, 0, 0, 0.28); -} - -/* Account-type cards */ -.signup-choices { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} -.signup-choice { - display: flex; - flex-direction: column; - gap: 5px; - text-align: left; - padding: 16px; - border-radius: 12px; - border: 1px solid rgba(0, 0, 0, 0.12); - background: #ffffff; - font-family: inherit; - cursor: pointer; - transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; -} -.signup-choice:hover { - border-color: rgba(0, 0, 0, 0.3); -} -.signup-choice--on { - border-color: #002d72; - background: rgba(0, 45, 114, 0.04); - box-shadow: 0 0 0 1px #002d72 inset; -} -.signup-choice-label { - font-size: 14.5px; - font-weight: 600; - color: #111111; -} -.signup-choice-blurb { - font-size: 12.5px; - line-height: 1.45; - color: #6a6a6a; -} -/* Plan cards */ -.signup-plans { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} -.signup-plan { - display: flex; - flex-direction: column; - gap: 6px; - text-align: left; - padding: 16px; - border-radius: 12px; - border: 1px solid rgba(0, 0, 0, 0.12); - background: #ffffff; - font-family: inherit; - cursor: pointer; - transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; -} -.signup-plan:hover { - border-color: rgba(0, 0, 0, 0.3); -} -.signup-plan--on { - border-color: #002d72; - background: rgba(0, 45, 114, 0.04); - box-shadow: 0 0 0 1px #002d72 inset; -} -.signup-plan-name { - font-size: 15px; - font-weight: 700; - color: #111111; +/* Consent by continuing, rather than a checkbox on a step of its own. */ +.signup-fineprint { + margin: 16px 0 0; + font-size: 11.5px; + line-height: 1.55; + color: #8f8f8f; + text-align: center; } -.signup-plan-tagline { - font-size: 12.5px; - line-height: 1.45; +.signup-fineprint a { color: #6a6a6a; + text-decoration: underline; } -.signup-plan-points { - list-style: none; - margin: 6px 0 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 5px; -} -.signup-plan-points li { - display: flex; - align-items: flex-start; - gap: 6px; - font-size: 12.5px; - line-height: 1.4; - color: #4a4a4a; -} -.signup-plan-points svg { - flex-shrink: 0; - margin-top: 2px; +.signup-fineprint a:hover { color: #002d72; } -/* Terms */ -.signup-agree { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 16px; - border-radius: 12px; - border: 1px solid rgba(0, 0, 0, 0.12); - background: #fafafa; - font-size: 13.5px; - line-height: 1.5; - color: #4a4a4a; - cursor: pointer; -} -.signup-agree input { - margin-top: 2px; - width: 16px; - height: 16px; - accent-color: #002d72; - flex-shrink: 0; - cursor: pointer; -} -.signup-agree a { - color: #002d72; - font-weight: 600; +.signup-alt { + margin-top: 22px; + padding-top: 18px; + border-top: 1px solid rgba(0, 0, 0, 0.07); + font-size: 13px; + color: #6a6a6a; + text-align: center; } -@media (max-width: 560px) { +@media (max-width: 480px) { .signup-panel { - padding: 24px 20px 22px; + padding: 24px 20px 20px; border-radius: 14px; } .signup-title { font-size: 21px; } - .signup-choices, - .signup-plans { - grid-template-columns: minmax(0, 1fr); - } - .signup-actions { - flex-wrap: wrap; - } - .signup-primary, - .signup-back { - flex: 1; - text-align: center; - } } diff --git a/PanTS-Demo/src/routes/SignupPage.tsx b/PanTS-Demo/src/routes/SignupPage.tsx index 8285f2b..16d5645 100644 --- a/PanTS-Demo/src/routes/SignupPage.tsx +++ b/PanTS-Demo/src/routes/SignupPage.tsx @@ -1,98 +1,48 @@ -import { IconBrandGithub, IconBrandGoogle, IconCheck } from "@tabler/icons-react"; +import { IconBrandGithub, IconBrandGoogle } from "@tabler/icons-react"; import React, { useEffect, useState } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/authContext"; -import { - ACCOUNT_TYPES, - PLANS, - type AccountType, - type PlanId, -} from "../helpers/accountProfile"; import "./SignupPage.css"; -// Fullscreen sign-up, modelled on how ChatGPT/Claude onboard: create the -// account, then a few questions, rather than one wall of fields. Sign-IN stays -// the popup (components/AuthModal) — it's a one-field return path and doesn't -// deserve a page. +// Sign-up: one screen, the way Claude and ChatGPT do it — providers, a divider, +// an email and a password, done. Both of those sites use the identical layout +// for signing in and signing up, changing only the title and the footer link. // -// The account-type and plan answers are a client-side mock today -// (helpers/accountProfile.ts): nothing is gated, nothing is charged, no prices -// are shown. They exist so the flow is real enough to react to. +// Everything that used to follow (account type, plan, a terms checkbox) is gone. +// A new account lands on Free and meets its limits when it reaches them, which +// is a better introduction than a tier chart shown before you've seen anything. // -// ?step=type is the OAuth entry point — the provider callback lands back in the -// app, and App/authContext sends first-time users straight here with the -// account already created, so step 1 is skipped. - -type Step = "account" | "type" | "plan" | "terms"; -const STEPS: Step[] = ["account", "type", "plan", "terms"]; -const STEP_TITLES: Record = { - account: "Create your account", - type: "How will you use BodyMaps?", - plan: "Choose a plan", - terms: "Terms and privacy", -}; - +// Terms are inline fine print under the button rather than a step of their own: +// consent by continuing, which is the pattern on both reference sites and on +// most of the web. The links still go to /terms and /privacy for anyone who +// wants to read them. +// +// Sign-IN stays the popup (components/AuthModal) — it's a short return path. const SignupPage: React.FC = () => { const navigate = useNavigate(); - const [searchParams] = useSearchParams(); const { - user, isAuthenticated, loading, signUp, signInWithProvider, - oauthProviders, updateAccountProfile, promptAuth, + isAuthenticated, loading, signUp, signInWithProvider, + oauthProviders, promptAuth, } = useAuth(); - // Steps after the first need an account to attach answers to, so an - // unauthenticated visitor always starts at step 1 regardless of ?step. - const requestedStep = searchParams.get("step") as Step | null; - const [step, setStep] = useState( - requestedStep && STEPS.includes(requestedStep) ? requestedStep : "account" - ); - - const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [confirm, setConfirm] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); - const [accountType, setAccountType] = useState("patient"); - const [plan, setPlan] = useState("free"); - const [agreed, setAgreed] = useState(false); - - // Someone who already finished signup has no business here. - useEffect(() => { - if (!loading && isAuthenticated && user?.profile.onboardingCompletedAt) { - navigate("/upload", { replace: true }); - } - }, [loading, isAuthenticated, user, navigate]); - - // Arriving mid-flow (OAuth callback, or a refresh) without an account sends - // you back to step 1. - useEffect(() => { - if (!loading && !isAuthenticated && step !== "account") setStep("account"); - }, [loading, isAuthenticated, step]); - - // Prefill from whatever the account already knows (OAuth arrivals especially). - // The name comes off the user, not the profile mock — it has a real column. + // Already signed in? Nothing to do here. useEffect(() => { - if (user) { - setName((n) => n || (user.hasCustomName ? user.name : "")); - setAccountType(user.profile.accountType); - setPlan(user.profile.plan); - } - }, [user]); - - const stepIndex = STEPS.indexOf(step); + if (!loading && isAuthenticated) navigate("/upload", { replace: true }); + }, [loading, isAuthenticated, navigate]); - const submitAccount = async (e: React.FormEvent) => { + const submit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); - if (!name.trim()) { setError("Enter your name."); return; } if (!email.trim() || !password) { setError("Enter an email and password."); return; } - if (password !== confirm) { setError("Passwords don't match."); return; } setBusy(true); try { - await signUp(email, password, name); - setStep("type"); + await signUp(email, password); + navigate("/upload", { replace: true }); } catch (err) { setError(err instanceof Error && err.message ? err.message : "Something went wrong. Try again."); } finally { @@ -100,19 +50,6 @@ const SignupPage: React.FC = () => { } }; - // The name was already sent to the server by signUp(); only the mock fields - // are written here. - const finish = () => { - const now = new Date().toISOString(); - updateAccountProfile({ - accountType, - plan, - acceptedTermsAt: now, - onboardingCompletedAt: now, - }); - navigate("/upload", { replace: true }); - }; - return (
@@ -120,8 +57,64 @@ const SignupPage: React.FC = () => { BodyMaps - {step === "account" && ( -
+
+ +
+
+

Create your account

+ +
+ + +
+ +
or
+ +
+ + + {error &&
{error}
} + +
+ +

+ By continuing you agree to our{" "} + Terms of Service and{" "} + Privacy Policy. BodyMaps is + research software and does not provide a diagnosis. +

+ +
Already have an account?{" "}
- )} - - -
-
- {/* Progress */} -
    - {STEPS.map((s, i) => ( -
  1. - - {i < stepIndex ? : i + 1} - -
  2. - ))} -
- -

{STEP_TITLES[step]}

- - {/* ── Step 1: account ── */} - {step === "account" && ( - <> -
- - -
- -
or
- -
- - - - - {error &&
{error}
} - -
- - )} - - {/* ── Step 2: account type ── */} - {step === "type" && ( - <> -

- This tailors what you see — the tools, the wording, and how results are - presented. You can change it any time in account settings. -

-
- {ACCOUNT_TYPES.map((t) => ( - - ))} -
-
- -
- - )} - - {/* ── Step 3: plan ── */} - {step === "plan" && ( - <> -

- Pricing isn't set yet and nothing is charged — everything is available - while BodyMaps is in development. Pick what fits how you'd use it. -

-
- {PLANS.map((p) => ( - - ))} -
-

- Researcher or student? There's a free academic access lane — ask us once - you're in. -

-
- - -
- - )} - - {/* ── Step 4: terms ── */} - {step === "terms" && ( - <> -

- BodyMaps is research software. It does not provide a diagnosis, and its - output is not a substitute for a qualified clinician's judgment. -

- -
- - -
- - )}
diff --git a/PanTS-Demo/src/routes/UploadPage.css b/PanTS-Demo/src/routes/UploadPage.css index dd8b375..a976325 100644 --- a/PanTS-Demo/src/routes/UploadPage.css +++ b/PanTS-Demo/src/routes/UploadPage.css @@ -487,6 +487,10 @@ top: calc(100% + 4px); left: 0; right: 0; + /* Wider than the trigger: the pipeline steps are narrow columns, and at that + width a model description wraps to five lines — worse still once an + "Upgrade" pill is sharing the row. */ + min-width: 300px; background: #ffffff; border: 1px solid rgba(0,0,0,.10); border-radius: 10px; @@ -512,6 +516,9 @@ display: flex; flex-direction: column; gap: 1px; + /* Take the slack, so the pill sits at the edge rather than squeezing the text. */ + flex: 1; + min-width: 0; } .model-dropdown-item-name { font-family: 'Space Grotesk', sans-serif; @@ -539,6 +546,24 @@ margin-left: 10px; } +/* A model the plan doesn't include. Dimmed but still readable and still + clickable — the click opens the upgrade dialog instead of selecting it. */ +.model-dropdown-item.locked .model-dropdown-item-name, +.model-dropdown-item.locked .model-dropdown-item-desc { + color: #b0b0b0; +} +.model-dropdown-lock { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 3px 7px; + border-radius: 999px; + background: rgba(0, 45, 114, 0.08); + color: #002d72; + white-space: nowrap; +} + /* ── Cancel button on Active cards ── */ .active-cancel-btn { background: transparent; @@ -1072,6 +1097,21 @@ text-decoration: underline; } +/* Way out to the History page for anything older than a day. */ +.upload-history-link { + margin-top: 12px; + background: none; + border: none; + padding: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + color: #6a6a6a; + cursor: pointer; +} +.upload-history-link:hover { + color: #002d72; +} + /* ── Processing summary bar (all in-flight scans in one row) ── */ .proc-bar { margin-top: 32px; diff --git a/PanTS-Demo/src/routes/UploadPage.tsx b/PanTS-Demo/src/routes/UploadPage.tsx index b36aeb4..29b6989 100644 --- a/PanTS-Demo/src/routes/UploadPage.tsx +++ b/PanTS-Demo/src/routes/UploadPage.tsx @@ -60,13 +60,21 @@ import { loadRecentUploads, recentStatusColor, removeRecentUpload, + splitByAge, updateRecentUploadStatus, type RecentUpload, } from "../helpers/recentUploads"; import Header from "../components/Header"; import ProcessingSummaryBar from "../components/ProcessingSummaryBar"; import BatchDetailsModal from "../components/BatchDetailsModal"; +import UpgradeDialog, { type UpgradeBlock } from "../components/UpgradeDialog"; import { useAuth } from "../contexts/authContext"; +import { + canPostprocess, + isModelLocked, + maxConcurrentScans, + type PlanId, +} from "../helpers/accountProfile"; import { looksLikeDicom, setLocalDicomFiles } from "../helpers/dicomLocal"; import { setLocalNiftiFile } from "../helpers/localNifti"; import { @@ -113,12 +121,19 @@ const UploadPage: React.FC = () => { // out opens the sign-in popup instead of proceeding. Sign-in, not sign-up: // most people hitting this already have an account, and the popup offers a // "Sign up" link out to /signup for the ones who don't. - const { isAuthenticated, promptAuth } = useAuth(); + const { isAuthenticated, promptAuth, user, refreshUsage } = useAuth(); const ensureAccount = (): boolean => { if (isAuthenticated) return true; promptAuth(); return false; }; + // Everything the plan won't allow routes through one dialog; this is what's + // currently being explained (null = nothing blocked). + const [upgradeBlock, setUpgradeBlock] = useState(null); + const plan = user?.plan ?? "free"; + // Cosmetic mirror of the server's rules — see helpers/accountProfile. The + // server still gets the final say via a 402, which lands in the same dialog. + const modelLocked = (id: string) => isAuthenticated && isModelLocked(plan, id); const fileInputRef = useRef(null); // Folder picker for a DICOM series (run inference, or view-only when model is "None"). const dicomUploadInputRef = useRef(null); @@ -580,11 +595,27 @@ const UploadPage: React.FC = () => { signal: controller.signal, }); const data = await parseApiResponse(res); + // 402 is the plan refusing, not a failure — the server's reason drives + // the upgrade dialog. The scan goes to Cancelled rather than Failed: + // nothing broke, it just never ran. + if (res.status === 402 && data?.code === "plan_limit") { + await deletePendingUpload(sid); + setPhase(sid); + setRecentUploads(updateRecentUploadStatus(sid, "Cancelled")); + setUpgradeBlock({ + reason: data.reason, message: data.message, feature: data.feature, + limit: data.limit, used: data.used, resetsAt: data.resets_at ?? null, + plan: (data.plan as PlanId) ?? "free", + }); + if (foreground) setMessage(""); + return; + } if (!res.ok) throw new Error(data.error || "Failed to start inference"); // Queued server-side now - nothing here is needed to finish the run, so // drop the resumable record. await deletePendingUpload(sid); + refreshUsage(); // a scan was just spent; keep the settings counter honest setSessionId(sid); setPhase(sid, "queued"); // server queues for the GPU; poll refines this if (foreground) setMessage(`${model} inference started. Session: ${sid}`); @@ -982,6 +1013,18 @@ const UploadPage: React.FC = () => { return; } + // Caught here rather than per-file, so a plan that runs one scan at a time + // says so before anything uploads instead of accepting the first and + // rejecting the rest one 402 at a time. + const slots = maxConcurrentScans(plan as PlanId); + const running = recentUploads.filter((u) => u.status === "Processing").length; + if (items.length + running > slots) { + setUpgradeBlock({ + reason: "concurrent_scans", limit: slots, used: running, plan: plan as PlanId, + }); + return; + } + const model = selectedModel; setInferenceCompleted(false); @@ -1374,11 +1417,23 @@ const UploadPage: React.FC = () => { {modelDropOpen && (
- {MODEL_OPTIONS.map((m) => ( + {MODEL_OPTIONS.map((m) => { + // Locked models stay visible with an "Upgrade" pill rather + // than being hidden — you can't want what you can't see, + // and ChatGPT's model picker works the same way. + const locked = modelLocked(m.id); + return (
{ + if (locked) { + setModelDropOpen(false); + setUpgradeBlock({ + reason: "model_locked", feature: m.label, plan: plan as PlanId, + }); + return; + } setSelectedModel(m.id as typeof selectedModel); setModelDropOpen(false); }} @@ -1392,7 +1447,8 @@ const UploadPage: React.FC = () => {
- {selectedModel === m.id && ( + {locked && Upgrade} + {!locked && selectedModel === m.id && ( { )}
- ))} + ); + })}
)}
@@ -1462,11 +1519,21 @@ const UploadPage: React.FC = () => { label: "ShapeKit", desc: "Clean up and smooth organ outlines", }, - ].map((opt) => ( + ].map((opt) => { + const locked = + opt.id !== "" && isAuthenticated && !canPostprocess(plan); + return (
{ + if (locked) { + setPostDropOpen(false); + setUpgradeBlock({ + reason: "postprocessing", feature: opt.label, plan: plan as PlanId, + }); + return; + } setPostValue(opt.id); setPostDropOpen(false); }} @@ -1480,7 +1547,8 @@ const UploadPage: React.FC = () => {
- {postValue === opt.id && ( + {locked && Upgrade} + {!locked && postValue === opt.id && ( { )}
- ))} + ); + })} )} @@ -1592,7 +1661,7 @@ const UploadPage: React.FC = () => { {" "} - to run inference on the server and get notified when it's done. + to run inference. )} @@ -1604,7 +1673,9 @@ const UploadPage: React.FC = () => { {(() => { const groups = groupUploads(recentUploads); const inFlight = groups.filter(isGroupInFlight); - const finished = groups.filter(g => !isGroupInFlight(g)); + // Only the last day stays here. Anything older is history, and lives + // in settings — this page is for the work in front of you. + const { recent: finished, older } = splitByAge(groups.filter(g => !isGroupInFlight(g))); // Only the upload needs this tab open; past that the job lives in the // server's queue. Rides along on each card's status line rather than as @@ -1819,11 +1890,19 @@ const UploadPage: React.FC = () => { : )} + {older.length > 0 && ( + + )} ); })()} + setUpgradeBlock(null)} /> + {/* Batch "View details" popup */} {(() => { if (!detailsBatchId) return null; diff --git a/PanTS-Demo/src/test/accountPage.test.tsx b/PanTS-Demo/src/test/accountPage.test.tsx index dc8c41e..8f4f112 100644 --- a/PanTS-Demo/src/test/accountPage.test.tsx +++ b/PanTS-Demo/src/test/accountPage.test.tsx @@ -1,16 +1,35 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { ReactElement } from "react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import AuthModal from "../components/AuthModal"; import { AuthProvider } from "../contexts/authContext"; -import AccountPage from "../routes/AccountPage"; - -// The account page's four controls against a stubbed API: renaming, export, -// deleting scan history, and deleting the account. Each asserts the request the -// server would actually receive, since that's the contract that matters. - -const USER = { id: "u1", email: "test.user@example.com", name: null as string | null }; +import SettingsPage from "../routes/Settings"; +import HistorySettings from "../routes/Settings/HistorySettings"; +import NotificationSettings from "../routes/Settings/NotificationSettings"; +import PlanSettings from "../routes/Settings/PlanSettings"; +import PrivacySettings from "../routes/Settings/PrivacySettings"; +import ProfileSettings from "../routes/Settings/ProfileSettings"; +import { RECENT_UPLOADS_KEY, type RecentUpload } from "../helpers/recentUploads"; + +// Settings against a stubbed API. Each section is its own URL now, so the tests +// navigate to one rather than scrolling one long page. Assertions are on the +// requests the server would actually receive — that's the contract that matters. + +const USER = { + id: "u1", + email: "test.user@example.com", + name: null as string | null, + plan: "free", +}; + +const USAGE = { + plan: "free", + limits: { daily_scans: 3, daily_ai_messages: 10 }, + scans: { used: 2, limit: 3, in_flight: 0, resets_at: null }, + ai_messages: { used: 0, limit: 10, resets_at: null }, +}; let calls: { method: string; url: string; body?: unknown }[] = []; @@ -25,7 +44,9 @@ const json = (body: unknown, ok = true, status = 200) => ({ beforeEach(() => { calls = []; + localStorage.clear(); USER.name = null; + USER.plan = "free"; URL.createObjectURL = vi.fn(() => "blob:stub"); URL.revokeObjectURL = vi.fn(); @@ -39,6 +60,11 @@ beforeEach(() => { return json({ user: { ...USER } }); } if (u.includes("/api/auth/me")) return json({ user: { ...USER } }); + if (u.includes("/api/me/plan")) { + USER.plan = (JSON.parse(String(init?.body)) as { plan: string }).plan; + return json({ user: { ...USER } }); + } + if (u.includes("/api/me/usage")) return json({ ...USAGE, plan: USER.plan }); if (u.includes("/api/me/export")) return json({ account: USER, jobs: [] }); if (u.includes("/api/me/jobs") && method === "DELETE") { return json({ deleted: { jobs: 3, files: 5 } }); @@ -57,13 +83,20 @@ beforeEach(() => { afterEach(() => vi.restoreAllMocks()); -const renderPage = () => +/** Renders the settings shell at one of its sections. */ +const renderAt = (path = "/account", signedOutElement: ReactElement =
Landing
) => render( - + - } /> - Landing} /> + }> + } /> + } /> + } /> + } /> + } /> + + @@ -72,19 +105,41 @@ const renderPage = () => const lastCall = (method: string, fragment: string) => [...calls].reverse().find((c) => c.method === method && c.url.includes(fragment)); +describe("navigation", () => { + it("offers a section per URL", async () => { + const user = userEvent.setup(); + renderAt(); + await screen.findByRole("heading", { name: "Profile" }); + + for (const [link, heading] of [ + ["Plan", "Usage"], + ["History", "History"], + ["Notifications", "Notifications"], + ["Privacy", "Your data"], + ] as const) { + await user.click(screen.getByRole("link", { name: link })); + expect(await screen.findByRole("heading", { name: heading })).toBeInTheDocument(); + } + }); + + it("opens straight at a deep-linked section", async () => { + renderAt("/account/privacy"); + expect(await screen.findByRole("heading", { name: "Your data" })).toBeInTheDocument(); + }); +}); + describe("display name", () => { - it("falls back to the email and says so when no name is set", async () => { - renderPage(); + it("falls back to the email when no name is set", async () => { + renderAt(); expect(await screen.findByText("Test User")).toBeInTheDocument(); - expect(screen.getByText(/guessed this from your email/i)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Add name" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument(); }); - it("saves a new name and stops calling it a guess", async () => { + it("saves a new name", async () => { const user = userEvent.setup(); - renderPage(); + renderAt(); - await user.click(await screen.findByRole("button", { name: "Add name" })); + await user.click(await screen.findByRole("button", { name: "Add" })); await user.type(screen.getByLabelText(/Display name/i), "Ada Lovelace"); await user.click(screen.getByRole("button", { name: "Save" })); @@ -92,7 +147,6 @@ describe("display name", () => { expect(lastCall("PATCH", "/api/auth/me")?.body).toEqual({ name: "Ada Lovelace" }) ); expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); - expect(screen.queryByText(/guessed this from your email/i)).not.toBeInTheDocument(); }); it("surfaces a save failure instead of silently doing nothing", async () => { @@ -106,8 +160,8 @@ describe("display name", () => { return json({}); }) as unknown as typeof fetch; - renderPage(); - await user.click(await screen.findByRole("button", { name: "Add name" })); + renderAt(); + await user.click(await screen.findByRole("button", { name: "Add" })); await user.type(screen.getByLabelText(/Display name/i), "x"); await user.click(screen.getByRole("button", { name: "Save" })); @@ -115,10 +169,83 @@ describe("display name", () => { }); }); +describe("plan", () => { + it("shows the current plan and what's been used of it", async () => { + renderAt("/account/plan"); + expect(await screen.findByRole("heading", { name: "Free plan" })).toBeInTheDocument(); + expect(await screen.findByText("2 of 3")).toBeInTheDocument(); + expect(screen.getByText("0 of 10")).toBeInTheDocument(); + }); + + it("splits the plans into Individual and Team like the reference sites", async () => { + const user = userEvent.setup(); + renderAt("/account/plan"); + await screen.findByRole("heading", { name: "Change plan" }); + + expect(screen.getByRole("heading", { name: "Free" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Pro" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Team" })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: "Team and Enterprise" })); + expect(await screen.findByRole("heading", { name: "Team" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Enterprise" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Pro" })).not.toBeInTheDocument(); + }); + + it("marks the plan you're on and won't let you re-pick it", async () => { + renderAt("/account/plan"); + await screen.findByRole("heading", { name: "Change plan" }); + expect(screen.getByRole("button", { name: "Current plan" })).toBeDisabled(); + }); + + it("upgrades through the server, not local state", async () => { + const user = userEvent.setup(); + renderAt("/account/plan"); + + await user.click(await screen.findByRole("button", { name: "Upgrade to Pro" })); + + await waitFor(() => expect(lastCall("POST", "/api/me/plan")?.body).toEqual({ plan: "pro" })); + expect(await screen.findByText("You're on Pro.")).toBeInTheDocument(); + }); + + it("quotes no prices", async () => { + renderAt("/account/plan"); + await screen.findByRole("heading", { name: "Change plan" }); + expect(document.body.textContent).not.toMatch(/[$£€]\s?\d/); + }); +}); + +describe("history", () => { + const day = 24 * 60 * 60 * 1000; + const entry = (over: Partial): RecentUpload => ({ + sessionId: "s", label: "ct.nii.gz", model: "LesionSegmenter", + status: "Completed", timestamp: Date.now(), ...over, + }); + + it("lists only scans older than a day", async () => { + localStorage.setItem(RECENT_UPLOADS_KEY, JSON.stringify([ + entry({ sessionId: "new", label: "today.nii.gz", timestamp: Date.now() - 60_000 }), + entry({ sessionId: "old", label: "lastweek.nii.gz", timestamp: Date.now() - 7 * day }), + ])); + + renderAt("/account/history"); + expect(await screen.findByText("lastweek.nii.gz")).toBeInTheDocument(); + expect(screen.queryByText("today.nii.gz")).not.toBeInTheDocument(); + }); + + it("says so when there's nothing old enough yet", async () => { + localStorage.setItem(RECENT_UPLOADS_KEY, JSON.stringify([ + entry({ sessionId: "new", timestamp: Date.now() - 60_000 }), + ])); + renderAt("/account/history"); + expect(await screen.findByText("Nothing here yet.")).toBeInTheDocument(); + }); +}); + describe("export", () => { it("downloads from the server rather than rebuilding from local state", async () => { const user = userEvent.setup(); - renderPage(); + renderAt("/account/privacy"); await user.click(await screen.findByRole("button", { name: "Export" })); @@ -126,21 +253,16 @@ describe("export", () => { expect(URL.createObjectURL).toHaveBeenCalled(); expect(await screen.findByText(/Your data has been downloaded/i)).toBeInTheDocument(); }); - - it("sits outside the danger zone so taking a copy isn't tied to leaving", async () => { - renderPage(); - const exportBtn = await screen.findByRole("button", { name: "Export" }); - const danger = screen.getByRole("button", { name: "Delete account" }).closest(".account-panel"); - expect(danger?.contains(exportBtn)).toBe(false); - }); }); describe("delete scan history", () => { it("needs CLEAR typed, then reports how many scans went", async () => { const user = userEvent.setup(); - renderPage(); + renderAt("/account/privacy"); - await user.click(await screen.findByRole("button", { name: "Delete history" })); + await user.click( + (await screen.findAllByRole("button", { name: "Delete" }))[0] + ); const confirm = screen.getByRole("button", { name: "Confirm" }); expect(confirm).toBeDisabled(); expect(lastCall("DELETE", "/api/me/jobs")).toBeUndefined(); @@ -154,33 +276,38 @@ describe("delete scan history", () => { it("keeps you signed in", async () => { const user = userEvent.setup(); - renderPage(); + renderAt("/account/privacy"); - await user.click(await screen.findByRole("button", { name: "Delete history" })); + await user.click((await screen.findAllByRole("button", { name: "Delete" }))[0]); await user.type(screen.getByLabelText(/Type CLEAR to confirm/i), "CLEAR"); await user.click(screen.getByRole("button", { name: "Confirm" })); await waitFor(() => expect(lastCall("DELETE", "/api/me/jobs")).toBeTruthy()); - expect(screen.getByText("Account")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument(); }); }); describe("delete account", () => { - it("needs DELETE, not CLEAR", async () => { + // The warning lives in the confirmation rather than on the page, so it has to + // appear on the way through — not before, and not never. + it("explains itself only once you start, and needs DELETE not CLEAR", async () => { const user = userEvent.setup(); - renderPage(); + renderAt("/account/privacy"); + await screen.findByRole("heading", { name: "Your data" }); + expect(screen.queryByText(/30 days to change your mind/i)).not.toBeInTheDocument(); - await user.click(await screen.findByRole("button", { name: "Delete account" })); - await user.type(screen.getByLabelText(/Type DELETE to confirm/i), "CLEAR"); + await user.click(screen.getAllByRole("button", { name: "Delete" })[1]); + expect(await screen.findByText(/30 days to change your mind/i)).toBeInTheDocument(); + await user.type(screen.getByLabelText(/Type DELETE to confirm/i), "CLEAR"); expect(screen.getByRole("button", { name: "Confirm" })).toBeDisabled(); }); - it("calls the endpoint and leaves the account page once confirmed", async () => { + it("calls the endpoint and leaves settings once confirmed", async () => { const user = userEvent.setup(); - renderPage(); + renderAt("/account/privacy"); - await user.click(await screen.findByRole("button", { name: "Delete account" })); + await user.click((await screen.findAllByRole("button", { name: "Delete" }))[1]); await user.type(screen.getByLabelText(/Type DELETE to confirm/i), "DELETE"); await user.click(screen.getByRole("button", { name: "Confirm" })); @@ -188,27 +315,11 @@ describe("delete account", () => { expect(await screen.findByText("Landing")).toBeInTheDocument(); }); - it("tells you the deletion is reversible before you confirm", async () => { - renderPage(); - expect( - await screen.findByText(/30 days to change your mind/i) - ).toBeInTheDocument(); - }); - it("does not carry a deletion message onto the sign-in popup", async () => { const user = userEvent.setup(); - render( - - - - } /> - } /> - - - - ); + renderAt("/account/privacy", ); - await user.click(await screen.findByRole("button", { name: "Delete account" })); + await user.click((await screen.findAllByRole("button", { name: "Delete" }))[1]); await user.type(screen.getByLabelText(/Type DELETE to confirm/i), "DELETE"); await user.click(screen.getByRole("button", { name: "Confirm" })); @@ -222,7 +333,7 @@ describe("success notices", () => { it("clear themselves instead of staying pinned to the page", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - renderPage(); + renderAt("/account/privacy"); await user.click(await screen.findByRole("button", { name: "Export" })); expect(await screen.findByText(/Your data has been downloaded/i)).toBeInTheDocument(); @@ -237,14 +348,14 @@ describe("success notices", () => { it("leaves errors up, since those still need acting on", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + global.fetch = vi.fn(async (url: RequestInfo | URL) => { const u = String(url); if (u.includes("/api/me/export")) return json({ error: "Storage is offline" }, false, 503); if (u.includes("/api/auth/me")) return json({ user: { ...USER } }); return json({}); }) as unknown as typeof fetch; - renderPage(); + renderAt("/account/privacy"); await user.click(await screen.findByRole("button", { name: "Export" })); expect(await screen.findByText(/Couldn't prepare your data/i)).toBeInTheDocument(); diff --git a/PanTS-Demo/src/test/accounts.test.tsx b/PanTS-Demo/src/test/accounts.test.tsx index abfb407..781a007 100644 --- a/PanTS-Demo/src/test/accounts.test.tsx +++ b/PanTS-Demo/src/test/accounts.test.tsx @@ -4,24 +4,18 @@ import type { ReactElement } from "react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AuthProvider } from "../contexts/authContext"; -import { loadProfile } from "../helpers/accountProfile"; import SignupPage from "../routes/SignupPage"; -// The signup flow end to end at the component level: all four steps, and where -// each answer lands. Account type and plan are a localStorage mock, so asserting -// on storage is the real check; the name is NOT — it goes to the server with the -// registration, so that one is asserted on the request. +// Signing up, at the component level. One screen now: providers, email, +// password, done — no account type, no plan picker, no terms checkbox. A new +// account lands on Free and meets its limits when it reaches them. // -// The account page has its own suite (accountPage.test.tsx), which exercises the -// real endpoints rather than the mock. +// Settings has its own suite (accountPage.test.tsx). -const USER = { id: "u1", email: "test@example.com" }; +const USER = { id: "u1", email: "test@example.com", plan: "free" }; -// /auth/me answers with a user only after `signedIn` flips, so the same stub -// serves both the signed-out signup case and the signed-in account case. let signedIn = false; -// What the register endpoint actually received, so the name can be asserted on -// the request rather than on local storage. +// What the register endpoint received, so the request can be asserted on. let registeredWith: Record | null = null; const jsonResponse = (body: unknown) => ({ @@ -69,66 +63,102 @@ const renderAt = (ui: ReactElement, path = "/") => ); describe("SignupPage", () => { - it("walks all four steps and stores the answers against the user", async () => { + it("creates the account from one screen and goes straight to the app", async () => { const user = userEvent.setup(); renderAt(, "/signup"); - // Step 1 — credentials. expect(await screen.findByText("Create your account")).toBeInTheDocument(); - await user.type(screen.getByLabelText(/^Name$/i), "Ada Lovelace"); await user.type(screen.getByLabelText(/^Email$/i), "test@example.com"); await user.type(screen.getByLabelText(/^Password$/i), "hunter2hunter2"); - await user.type(screen.getByLabelText(/Confirm password/i), "hunter2hunter2"); - await user.click(screen.getByRole("button", { name: "Continue" })); - - // Step 2 — account type. - expect(await screen.findByText("How will you use BodyMaps?")).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Clinician" })); - await user.click(screen.getByRole("button", { name: "Continue" })); - - // Step 3 — plan. No prices anywhere. - expect(await screen.findByText("Choose a plan")).toBeInTheDocument(); - expect(document.body.textContent).not.toMatch(/\$\d/); - await user.click(screen.getByRole("button", { name: "Pro" })); - await user.click(screen.getByRole("button", { name: "Continue" })); - - // Step 4 — terms. Finish stays disabled until the box is ticked. - expect(await screen.findByText("Terms and privacy")).toBeInTheDocument(); - const finish = screen.getByRole("button", { name: "Finish" }); - expect(finish).toBeDisabled(); - await user.click(screen.getByRole("checkbox")); - expect(finish).toBeEnabled(); - await user.click(finish); - - // Account type and plan are the mock; the name is not — it was sent to - // /auth/register and lives in user_account.name. - await waitFor(() => { - const profile = loadProfile(USER.id); - expect(profile).toMatchObject({ accountType: "clinician", plan: "pro" }); - expect(profile?.acceptedTermsAt).toBeTruthy(); - expect(profile?.onboardingCompletedAt).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Create account" })); + + expect(await screen.findByText("Upload page")).toBeInTheDocument(); + expect(registeredWith).toMatchObject({ + email: "test@example.com", password: "hunter2hunter2", }); - expect(registeredWith).toMatchObject({ email: "test@example.com", name: "Ada Lovelace" }); - expect(loadProfile(USER.id)).not.toHaveProperty("name"); }); - it("refuses to advance when the passwords don't match", async () => { + it("asks nothing beyond an email and a password", async () => { + renderAt(, "/signup"); + await screen.findByText("Create your account"); + + // The steps that used to follow are gone, not merely reordered. + expect(screen.queryByLabelText(/^Name$/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/Confirm password/i)).not.toBeInTheDocument(); + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + expect(document.body.textContent).not.toMatch(/How will you use|Choose a plan/i); + for (const plan of ["Free", "Pro", "Team", "Enterprise"]) { + expect(screen.queryByRole("button", { name: plan })).not.toBeInTheDocument(); + } + }); + + it("accepts the terms inline rather than as a step", async () => { + renderAt(, "/signup"); + await screen.findByText("Create your account"); + + expect(screen.getByText(/By continuing you agree to our/i)).toBeInTheDocument(); + // The links are still there for anyone who wants to read them. + expect(screen.getByRole("link", { name: "Terms of Service" })).toHaveAttribute("href", "/terms"); + expect(screen.getByRole("link", { name: "Privacy Policy" })).toHaveAttribute("href", "/privacy"); + }); + + it("offers both providers", async () => { + renderAt(, "/signup"); + await screen.findByText("Create your account"); + + expect(screen.getByRole("button", { name: /Continue with Google/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Continue with GitHub/i })).toBeInTheDocument(); + }); + + it("disables a provider the server has no credentials for", async () => { + global.fetch = vi.fn(async (url: RequestInfo | URL) => { + const u = String(url); + if (u.includes("/api/auth/me")) return jsonResponse({ user: null }); + if (u.includes("/api/auth/oauth/providers")) return jsonResponse({ google: true, github: false }); + return jsonResponse({}); + }) as unknown as typeof fetch; + + renderAt(, "/signup"); + await screen.findByText("Create your account"); + + await waitFor(() => + expect(screen.getByRole("button", { name: /Continue with GitHub/i })).toBeDisabled() + ); + expect(screen.getByRole("button", { name: /Continue with Google/i })).toBeEnabled(); + }); + + it("reports a rejected registration without leaving the page", async () => { + global.fetch = vi.fn(async (url: RequestInfo | URL) => { + const u = String(url); + if (u.includes("/api/auth/me")) return jsonResponse({ user: null }); + if (u.includes("/api/auth/oauth/providers")) return jsonResponse({ google: true, github: false }); + if (u.includes("/api/auth/register")) { + return { + ok: false, status: 409, + json: async () => ({ error: "An account with that email already exists" }), + text: async () => "", + headers: { get: () => "application/json" }, + }; + } + return jsonResponse({}); + }) as unknown as typeof fetch; + const user = userEvent.setup(); renderAt(, "/signup"); - await user.type(await screen.findByLabelText(/^Name$/i), "Ada"); - await user.type(screen.getByLabelText(/^Email$/i), "test@example.com"); + await user.type(await screen.findByLabelText(/^Email$/i), "taken@example.com"); await user.type(screen.getByLabelText(/^Password$/i), "hunter2hunter2"); - await user.type(screen.getByLabelText(/Confirm password/i), "something-else"); - await user.click(screen.getByRole("button", { name: "Continue" })); + await user.click(screen.getByRole("button", { name: "Create account" })); - expect(await screen.findByText("Passwords don't match.")).toBeInTheDocument(); + expect( + await screen.findByText("An account with that email already exists") + ).toBeInTheDocument(); expect(screen.getByText("Create your account")).toBeInTheDocument(); }); - it("sends someone with no account back to step 1 even when asked for a later step", async () => { - // ?step=type is the OAuth entry point; without a session it must not stick. - renderAt(, "/signup?step=type"); - expect(await screen.findByText("Create your account")).toBeInTheDocument(); + it("sends someone who is already signed in to the app", async () => { + signedIn = true; + renderAt(, "/signup"); + await waitFor(() => expect(screen.getByText("Upload page")).toBeInTheDocument()); }); }); diff --git a/PanTS-Demo/src/test/planGating.test.tsx b/PanTS-Demo/src/test/planGating.test.tsx new file mode 100644 index 0000000..11fc980 --- /dev/null +++ b/PanTS-Demo/src/test/planGating.test.tsx @@ -0,0 +1,283 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AuthProvider } from "../contexts/authContext"; +import UploadPage from "../routes/UploadPage"; +import { RECENT_UPLOADS_KEY, type RecentUpload } from "../helpers/recentUploads"; + +// What a Free account runs into on the Upload page, and what it's told. +// +// The server is the enforcer (flask-server/services/plan_store.py) — these +// cover the client half: locked controls that explain themselves before you +// commit, and the 402 path for the limits only the server can know about. + +const CHUNK_SIZE = 512 * 1024; +const USER = { + id: "u1", email: "test.user@example.com", name: null, plan: "free", +}; + +const makeFile = (name: string) => + new File([new Uint8Array(CHUNK_SIZE)], name, { type: "application/gzip" }); + +const json = (body: unknown, ok = true, status = 200) => ({ + ok, status, + json: async () => body, + text: async () => "", + headers: { get: () => "application/json" }, +}); + +/** Set by a test that wants /run-inference to refuse. */ +let inferenceRefusal: Record | null = null; + +beforeEach(() => { + inferenceRefusal = null; + localStorage.clear(); + global.fetch = vi.fn(async (url: RequestInfo | URL) => { + const u = String(url); + if (u.includes("/api/auth/me")) return json({ user: USER }); + if (u.includes("/api/auth/oauth/providers")) return json({ google: true }); + if (u.includes("/api/me/usage")) { + return json({ + plan: "free", + limits: { daily_scans: 3 }, + scans: { used: 3, limit: 3, in_flight: 0, resets_at: null }, + ai_messages: { used: 0, limit: 10, resets_at: null }, + }); + } + if (u.includes("/api/upload-inference-chunk")) return json({ ok: true }); + if (u.includes("/api/finalize-upload")) return json({ uploaded_filename: "ct.nii.gz" }); + if (u.includes("/api/run-epai-inference")) { + if (inferenceRefusal) return json(inferenceRefusal, false, 402); + return json({ message: "Segmentation started" }); + } + if (u.includes("/api/inference-status/")) return json({ status: "queued" }); + return json({ items: [], total: 0, ids: [] }); + }) as unknown as typeof fetch; +}); + +afterEach(() => vi.restoreAllMocks()); + +const renderUpload = () => + render( + + + + } /> + Plan settings} /> + + + + ); + +/** Waits out the /auth/me probe so the page knows who is signed in. */ +const settled = () => + waitFor(() => + expect(screen.queryByText(/to run inference/)).not.toBeInTheDocument() + ); + +const openModelMenu = async (user: ReturnType) => { + await user.click(screen.getByRole("button", { name: /None \(view scan\)/ })); +}; + +describe("model access", () => { + it("keeps locked models visible with an upgrade marker rather than hiding them", async () => { + const user = userEvent.setup(); + renderUpload(); + await settled(); + await openModelMenu(user); + + // The free model is offered; the rest are shown but marked. + expect(screen.getByText("LesionSegmenter")).toBeInTheDocument(); + expect(screen.getByText("ePAI")).toBeInTheDocument(); + expect(screen.getAllByText("Upgrade").length).toBeGreaterThan(0); + }); + + it("explains the lock instead of selecting the model", async () => { + const user = userEvent.setup(); + renderUpload(); + await settled(); + await openModelMenu(user); + await user.click(screen.getByText("ePAI")); + + expect(await screen.findByText("ePAI needs Pro")).toBeInTheDocument(); + // The picker did not change to the model that was refused. + expect(screen.getByRole("button", { name: /None \(view scan\)/ })).toBeInTheDocument(); + }); + + it("lets the free model through untouched", async () => { + const user = userEvent.setup(); + renderUpload(); + await settled(); + await openModelMenu(user); + await user.click(screen.getByText("LesionSegmenter")); + + expect(screen.queryByText(/needs Pro/)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /LesionSegmenter/ })).toBeInTheDocument(); + }); + + it("sends you to the plan page from the dialog", async () => { + const user = userEvent.setup(); + renderUpload(); + await settled(); + await openModelMenu(user); + await user.click(screen.getByText("ePAI")); + await user.click(await screen.findByRole("button", { name: "See plans" })); + + expect(await screen.findByText("Plan settings")).toBeInTheDocument(); + }); + + it("closes without going anywhere on Not now", async () => { + const user = userEvent.setup(); + renderUpload(); + await settled(); + await openModelMenu(user); + await user.click(screen.getByText("ePAI")); + await user.click(await screen.findByRole("button", { name: "Not now" })); + + await waitFor(() => + expect(screen.queryByText("ePAI needs Pro")).not.toBeInTheDocument() + ); + expect(screen.queryByText("Plan settings")).not.toBeInTheDocument(); + }); +}); + +describe("postprocessing", () => { + it("is locked on Free", async () => { + const user = userEvent.setup(); + renderUpload(); + await settled(); + + // Preprocessing (step 1) and postprocessing (step 3) share the placeholder; + // the second is the one ShapeKit lives under. + const skipButtons = screen.getAllByRole("button", { name: /None \(skip\)/ }); + await user.click(skipButtons[skipButtons.length - 1]); + await user.click(screen.getByText("ShapeKit")); + + expect(await screen.findByText("ShapeKit needs Pro")).toBeInTheDocument(); + }); +}); + +describe("running several scans at once", () => { + it("says so before uploading anything, rather than one refusal per file", async () => { + const user = userEvent.setup(); + const { container } = renderUpload(); + await settled(); + + const input = container.querySelector('input[accept=".nii,.gz"]')!; + await user.upload(input, [makeFile("a.nii.gz"), makeFile("b.nii.gz")]); + + await openModelMenu(user); + await user.click(screen.getByText("LesionSegmenter")); + await user.click(screen.getByRole("button", { name: "Run" })); + + expect(await screen.findByText("One scan at a time on Free")).toBeInTheDocument(); + // Nothing was sent. + const calls = (global.fetch as unknown as ReturnType).mock.calls; + expect(calls.some((c) => String(c[0]).includes("upload-inference-chunk"))).toBe(false); + }); + + it("counts a scan already in flight against the limit", async () => { + const running: RecentUpload = { + sessionId: "s-running", label: "earlier.nii.gz", model: "LesionSegmenter", + status: "Processing", timestamp: Date.now(), + }; + localStorage.setItem(RECENT_UPLOADS_KEY, JSON.stringify([running])); + + const user = userEvent.setup(); + const { container } = renderUpload(); + await settled(); + + const input = container.querySelector('input[accept=".nii,.gz"]')!; + await user.upload(input, [makeFile("a.nii.gz")]); + await openModelMenu(user); + await user.click(screen.getByText("LesionSegmenter")); + await user.click(screen.getByRole("button", { name: "Run" })); + + expect(await screen.findByText("One scan at a time on Free")).toBeInTheDocument(); + }); +}); + +describe("the server's own refusal", () => { + it("turns a 402 into the same dialog, with the server's numbers", async () => { + inferenceRefusal = { + error: "You've used your 3 scans for today", + code: "plan_limit", + reason: "daily_scans", + plan: "free", + limit: 3, + used: 3, + resets_at: new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(), + }; + + const user = userEvent.setup(); + const { container } = renderUpload(); + await settled(); + + const input = container.querySelector('input[accept=".nii,.gz"]')!; + await user.upload(input, [makeFile("a.nii.gz")]); + await openModelMenu(user); + await user.click(screen.getByText("LesionSegmenter")); + await user.click(screen.getByRole("button", { name: "Run" })); + + expect( + await screen.findByText("You've used your scans for today", {}, { timeout: 5000 }) + ).toBeInTheDocument(); + // The reset time comes from the server, not a guess. + expect(screen.getByText(/next one is available in about 6 hours/i)).toBeInTheDocument(); + }); + + it("marks the refused scan cancelled, not failed — nothing broke", async () => { + inferenceRefusal = { + error: "You've used your 3 scans for today", + code: "plan_limit", reason: "daily_scans", plan: "free", limit: 3, used: 3, + }; + + const user = userEvent.setup(); + const { container } = renderUpload(); + await settled(); + + const input = container.querySelector('input[accept=".nii,.gz"]')!; + await user.upload(input, [makeFile("a.nii.gz")]); + await openModelMenu(user); + await user.click(screen.getByText("LesionSegmenter")); + await user.click(screen.getByRole("button", { name: "Run" })); + + await screen.findByText("You've used your scans for today", {}, { timeout: 5000 }); + await waitFor(() => { + const stored = JSON.parse(localStorage.getItem(RECENT_UPLOADS_KEY) || "[]"); + expect(stored[0]?.status).toBe("Cancelled"); + }); + }); +}); + +describe("signed out", () => { + it("asks for a sign-in in as few words as possible", async () => { + global.fetch = vi.fn(async (url: RequestInfo | URL) => { + const u = String(url); + if (u.includes("/api/auth/me")) return json({ user: null }); + if (u.includes("/api/auth/oauth/providers")) return json({ google: true }); + return json({ items: [], total: 0, ids: [] }); + }) as unknown as typeof fetch; + + renderUpload(); + expect(await screen.findByText(/to run inference\./)).toBeInTheDocument(); + expect(document.body.textContent).not.toMatch(/get notified when it's done/i); + }); + + it("locks nothing before it knows who you are", async () => { + global.fetch = vi.fn(async (url: RequestInfo | URL) => { + const u = String(url); + if (u.includes("/api/auth/me")) return json({ user: null }); + if (u.includes("/api/auth/oauth/providers")) return json({ google: true }); + return json({ items: [], total: 0, ids: [] }); + }) as unknown as typeof fetch; + + const user = userEvent.setup(); + renderUpload(); + await screen.findByText(/to run inference\./); + await openModelMenu(user); + + expect(screen.queryByText("Upgrade")).not.toBeInTheDocument(); + }); +}); diff --git a/PanTS-Demo/src/test/routes.smoke.test.tsx b/PanTS-Demo/src/test/routes.smoke.test.tsx index 419767b..d3689ea 100644 --- a/PanTS-Demo/src/test/routes.smoke.test.tsx +++ b/PanTS-Demo/src/test/routes.smoke.test.tsx @@ -1,10 +1,11 @@ import { render, screen } from "@testing-library/react"; import { useEffect, type ReactElement } from "react"; -import { MemoryRouter } from "react-router-dom"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import AuthModal from "../components/AuthModal"; import { AuthProvider, useAuth } from "../contexts/authContext"; -import AccountPage from "../routes/AccountPage"; +import SettingsPage from "../routes/Settings"; +import NotificationSettings from "../routes/Settings/NotificationSettings"; import LandingPage from "../routes/LandingPage"; import Homepage from "../routes/Homepage"; import UploadPage from "../routes/UploadPage"; @@ -91,7 +92,7 @@ describe("route smoke tests", () => { ); }); - it("AccountPage renders the email-notification setting when signed in", async () => { + it("Settings renders the email-notification setting when signed in", async () => { // The session lives in an httponly cookie, so "signed in" means /auth/me // answers with a user - not a localStorage key. global.fetch = vi.fn(async (url: RequestInfo | URL) => ({ @@ -104,9 +105,15 @@ describe("route smoke tests", () => { text: async () => "", headers: { get: () => "application/json" }, })) as unknown as typeof fetch; - renderRoute(); + renderRoute( + + }> + } /> + + , + ); expect( - await screen.findByText("Email me when inference finishes"), + await screen.findByText("Email me when a scan finishes"), ).toBeInTheDocument(); }); }); diff --git a/PanTS-Demo/src/test/uploadScheduling.test.tsx b/PanTS-Demo/src/test/uploadScheduling.test.tsx index 4347ab9..e113244 100644 --- a/PanTS-Demo/src/test/uploadScheduling.test.tsx +++ b/PanTS-Demo/src/test/uploadScheduling.test.tsx @@ -17,7 +17,7 @@ import UploadPage from "../routes/UploadPage"; // segmenting while scan 2 is still going up. const CHUNK_SIZE = 512 * 1024; -const USER = { id: "u1", email: "test.user@example.com", name: null }; +const USER = { id: "u1", email: "test.user@example.com", name: null, plan: "pro" }; // Comfortably more chunks than the uploader's in-flight concurrency (6), so a // concurrent schedule would visibly interleave the two files rather than @@ -91,7 +91,7 @@ const runTwoFiles = async () => { ); // Inference requires an account; wait for the session probe to land first. await waitFor(() => - expect(screen.queryByText(/to run inference on the server/)).not.toBeInTheDocument(), + expect(screen.queryByText(/to run inference/)).not.toBeInTheDocument(), ); const input = container.querySelector('input[accept=".nii,.gz"]')!; diff --git a/flask-server/.gitignore b/flask-server/.gitignore index 78e1fe7..38e7044 100644 --- a/flask-server/.gitignore +++ b/flask-server/.gitignore @@ -4,4 +4,5 @@ *.pyc .env /sessions -/test-sessions \ No newline at end of file +/test-sessions +/.localdata \ No newline at end of file diff --git a/flask-server/api/api_blueprint.py b/flask-server/api/api_blueprint.py index 0c13424..cb6844f 100644 --- a/flask-server/api/api_blueprint.py +++ b/flask-server/api/api_blueprint.py @@ -67,6 +67,8 @@ # directory before it touches os.path; secure_filename is the barrier at each # path-construction site. from .path_safety import is_safe_id as _is_safe_id +from .auth import current_user +from services import plan_store def _metadata_xlsx_path(): @@ -968,6 +970,14 @@ def _set_inference_job(session_id, **kwargs): inference_jobs[session_id] = current snapshot = dict(current) _persist_inference_job(session_id, snapshot) + # A run that has stopped no longer occupies one of the plan's concurrent + # slots. Best-effort: a bookkeeping failure must not break the status write + # that the polling frontend depends on. + if (snapshot.get("status") or "").lower() in ("completed", "failed", "cancelled"): + try: + plan_store.finish_inference(session_id) + except Exception as e: + print(f"[usage finish] {session_id}: {e}") def _get_inference_job(session_id): @@ -1012,6 +1022,19 @@ def _uploaded_file_candidate(session_id, uploaded_filename): def _start_auto_segmentation(session_id, model_name, ct_file=None, server_input_path=None): if not _is_safe_id(session_id): return jsonify({"error": "Invalid session ID"}), 400 + + # Plan enforcement sits here rather than on the routes because both + # /run-inference and /auto_segment funnel through this function — one gate, + # no way to reach the GPU around it. + user = current_user() + if user is None: + return jsonify({"error": "Sign in to run inference"}), 401 + blocked = plan_store.check_inference(user["id"], model_name) + if blocked is not None: + # 402 Payment Required: the request is well-formed and the user is + # authenticated — the plan is what's missing. + return jsonify({"error": blocked["message"], "code": "plan_limit", **blocked}), 402 + session_path = os.path.join(SESSIONS_DIR, session_id) os.makedirs(session_path, exist_ok=True) @@ -1035,6 +1058,10 @@ def _start_auto_segmentation(session_id, model_name, ct_file=None, server_input_ else: return jsonify({"error": "No CT file provided. Send MAIN_NIFTI or INPUT_SERVER_PATH."}), 400 + # Metered only once the run is definitely going ahead — every early return + # above is a request that never reached the queue and mustn't cost a scan. + plan_store.record_inference(user["id"], session_id, model_name) + # "queued": the worker thread starts immediately but real GPU work waits on # the segmentor's one-at-a-time lock; the on_start callback below flips the # job to "running" when it actually gets the GPU. @@ -3572,6 +3599,26 @@ def ai_models(): }), 200 +def _ai_quota_block(): + """402 body if the signed-in user is out of assistant messages, else None. + + Signed-out callers pass through unmetered — the assistant has always been + usable without an account on public dataset cases, and closing that is a + product decision rather than part of the plan work. Signed-in users are + metered against their plan's daily allowance. + """ + user = current_user() + if user is None: + return None + blocked = plan_store.check_ai_message(user["id"]) + if blocked is None: + return None + return jsonify({ + "reply": blocked["message"], "actions": [], "source": "plan_limit", + "code": "plan_limit", **blocked, + }), 402 + + @api_blueprint.route("/ai-command", methods=["POST"]) def ai_command(): try: @@ -3595,6 +3642,13 @@ def ai_command(): } ), 400 + blocked = _ai_quota_block() + if blocked is not None: + return blocked + user = current_user() + if user is not None: + plan_store.record_ai_message(user["id"]) + available_organs = body.get( "available_organs" ) or [] @@ -3922,6 +3976,15 @@ def ai_command_stream(): message = str(body.get("message") or "").strip() + # Checked before the stream opens, so the client gets a plain 402 it can act + # on rather than an error event buried in a 200 response body. + blocked = _ai_quota_block() + if blocked is not None: + return blocked + _quota_user = current_user() + if _quota_user is not None: + plan_store.record_ai_message(_quota_user["id"]) + available_organs = body.get("available_organs") or [] if not isinstance(available_organs, list): available_organs = [] diff --git a/flask-server/api/auth_blueprint.py b/flask-server/api/auth_blueprint.py index a04a6ee..ea56546 100644 --- a/flask-server/api/auth_blueprint.py +++ b/flask-server/api/auth_blueprint.py @@ -7,6 +7,8 @@ POST /auth/logout -> revokes session GET /auth/me -> current user (401 if none) PATCH /auth/me {name} -> update the display name + POST /me/plan {plan} -> change plan (no payment) + GET /me/usage -> plan limits + usage so far GET /me/jobs -> the current user's jobs GET /me/export -> everything we hold, as JSON DELETE /me/jobs -> delete scan history, keep account @@ -20,7 +22,7 @@ from api.auth import ( COOKIE_NAME, clear_session_cookie, current_user, require_auth, set_session_cookie, ) -from services import auth_store, job_store +from services import auth_store, job_store, plan_store auth_blueprint = Blueprint("auth", __name__) @@ -93,6 +95,31 @@ def update_me(): return jsonify({"user": user}), 200 +@auth_blueprint.route("/me/plan", methods=["POST"]) +@require_auth +def set_my_plan(): + """Move to another plan. + + No payment step: pricing hasn't been set, so this is a column write. The + limits attached to the plan are enforced for real from the next request on. + """ + plan = (_json().get("plan") or "").strip() + try: + user = plan_store.set_plan(current_user()["id"], plan) + except ValueError: + return jsonify({"error": "Unknown plan"}), 400 + if user is None: + return jsonify({"error": "Account not found"}), 404 + return jsonify({"user": user}), 200 + + +@auth_blueprint.route("/me/usage", methods=["GET"]) +@require_auth +def my_usage(): + """Plan, its limits, and what's been used of them in the current window.""" + return jsonify(plan_store.usage_summary(current_user()["id"])), 200 + + @auth_blueprint.route("/me/jobs", methods=["GET"]) @require_auth def my_jobs(): diff --git a/flask-server/migrations/versions/b4d21f907ac3_plan_and_usage_events.py b/flask-server/migrations/versions/b4d21f907ac3_plan_and_usage_events.py new file mode 100644 index 0000000..66fac7c --- /dev/null +++ b/flask-server/migrations/versions/b4d21f907ac3_plan_and_usage_events.py @@ -0,0 +1,66 @@ +"""account plan + usage events + +Two things, both additive: + +* ``user_account.plan`` — the billing plan id. NOT NULL with a server default of + 'free', so every existing row is backfilled by the default itself and no + separate UPDATE is needed. +* ``usage_event`` — one row per metered action (an inference run, an assistant + message). Backs the plan quotas in services/plan_store.py. Deliberately not a + count over ``job``: the frontend's /run-inference path never writes a job row. + +Safe against a live database (nothing is rewritten or dropped) and reversible. + +Revision ID: b4d21f907ac3 +Revises: 8a41c7f0d3b2 +Create Date: 2026-08-07 10:12:04.118273 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b4d21f907ac3' +down_revision: Union[str, None] = '8a41c7f0d3b2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('user_account', schema=None) as batch_op: + batch_op.add_column(sa.Column( + 'plan', sa.String(length=16), nullable=False, server_default='free' + )) + + op.create_table( + 'usage_event', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('kind', sa.String(length=24), nullable=False), + sa.Column('ref_id', sa.String(length=128), nullable=True), + sa.Column('model', sa.String(length=64), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('finished_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('usage_event', schema=None) as batch_op: + batch_op.create_index('ix_usage_event_user_id', ['user_id'], unique=False) + batch_op.create_index('ix_usage_event_ref_id', ['ref_id'], unique=False) + batch_op.create_index( + 'ix_usage_event_user_kind_created', ['user_id', 'kind', 'created_at'], unique=False + ) + + +def downgrade() -> None: + with op.batch_alter_table('usage_event', schema=None) as batch_op: + batch_op.drop_index('ix_usage_event_user_kind_created') + batch_op.drop_index('ix_usage_event_ref_id') + batch_op.drop_index('ix_usage_event_user_id') + op.drop_table('usage_event') + + with op.batch_alter_table('user_account', schema=None) as batch_op: + batch_op.drop_column('plan') diff --git a/flask-server/models/engine.py b/flask-server/models/engine.py index 321d405..4190a5a 100644 --- a/flask-server/models/engine.py +++ b/flask-server/models/engine.py @@ -37,6 +37,7 @@ def _set_sqlite_pragmas(dbapi_conn, _record): from models import user as _user # noqa: F401,E402 from models import auth_session as _auth_session # noqa: F401,E402 from models import oauth_identity as _oauth_identity # noqa: F401,E402 +from models import usage_event as _usage_event # noqa: F401,E402 _engine = None _SessionLocal = None diff --git a/flask-server/models/usage_event.py b/flask-server/models/usage_event.py new file mode 100644 index 0000000..a4a7d31 --- /dev/null +++ b/flask-server/models/usage_event.py @@ -0,0 +1,50 @@ +"""Per-account usage, for plan limit enforcement. + +Deliberately its own table rather than a count over ``job``: the frontend starts +runs through ``/run-inference``, which keeps state in an in-memory dict mirrored +to ``sessions//job.json`` and never writes a ``job`` row. Counting jobs would +therefore see zero for every run a real user makes. + +One row per metered action. ``finished_at`` is what makes concurrency +answerable — a null means the run is still in flight, so "how many scans does +this user have going right now" is a count rather than a guess. +""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, String +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import db +from models.job import utcnow + +# Wire values; plan_store switches on them. +KIND_INFERENCE = "inference" +KIND_AI_MESSAGE = "ai_message" + + +class UsageEvent(db.Model): + __tablename__ = "usage_event" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + # CASCADE, unlike job.user_id: usage is bookkeeping, not the user's data, so + # a purged account should take it with them rather than block the delete. + user_id: Mapped[str] = mapped_column( + String(36), ForeignKey("user_account.id", ondelete="CASCADE"), + nullable=False, index=True, + ) + kind: Mapped[str] = mapped_column(String(24), nullable=False) + # The inference session id, so the row can be closed out when the run ends. + # Null for kinds that finish the instant they start (ai_message). + ref_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + model: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + # Null means still running. Set when the job reaches a terminal state. + finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + __table_args__ = ( + Index("ix_usage_event_user_kind_created", "user_id", "kind", "created_at"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/flask-server/models/user.py b/flask-server/models/user.py index 2f5cf44..bf924bc 100644 --- a/flask-server/models/user.py +++ b/flask-server/models/user.py @@ -37,6 +37,10 @@ class User(db.Model): # Display name the user chose. Nullable because accounts created before this # column existed have none — the client falls back to the email's local part. name: Mapped[str | None] = mapped_column(String(120), nullable=True) + # Billing plan id (services.plan_store.PLAN_IDS). Everyone starts on "free"; + # nothing is charged, but the limits attached to it are enforced for real. + plan: Mapped[str] = mapped_column(String(16), nullable=False, default="free", + server_default="free") # Set when the user asks to delete their account. Non-null means the account # is scheduled for removal: it can't be used, but signing back in within the # grace window clears this and restores it. @@ -55,6 +59,7 @@ def to_public_dict(self) -> dict: "id": self.id, "email": self.email, "name": self.name, + "plan": self.plan or "free", "email_verified": self.email_verified_at is not None, "created_at": self.created_at.isoformat() if self.created_at else None, } diff --git a/flask-server/services/plan_store.py b/flask-server/services/plan_store.py new file mode 100644 index 0000000..e574126 --- /dev/null +++ b/flask-server/services/plan_store.py @@ -0,0 +1,294 @@ +"""Plan limits, and the checks that enforce them. + +The single seam for "is this account allowed to do this". Endpoints ask +``check_inference`` / ``check_ai_message`` before doing work and record usage +after; nothing else reads ``user_account.plan`` or the ``usage_event`` table. + +The limit table below is mirrored in the frontend +(PanTS-Demo/src/helpers/accountProfile.ts, PLAN_LIMITS) so the UI can grey a +locked control out instead of letting you click into a 402. The server copy is +the one that decides — the frontend copy only decides how things look. Keep them +in step; a drifted frontend is a cosmetic bug, a drifted backend is a real one. + +``None`` as a limit means unlimited. + +Nothing is charged. Upgrading is a plain column write (``set_plan``) with no +payment step, because pricing hasn't been set — but the limits on the plan you +are on are applied for real. +""" + +import uuid +from datetime import timedelta + +from sqlalchemy import select + +from models.engine import session_scope +from models.job import utcnow +from models.usage_event import KIND_AI_MESSAGE, KIND_INFERENCE, UsageEvent +from models.user import User + +# Quotas are a rolling window rather than a calendar day: "3 a day" that resets +# at a fixed hour hands out 6 runs to anyone who waits until 23:59. +WINDOW = timedelta(hours=24) + +DEFAULT_PLAN = "free" + +PLAN_LIMITS: dict[str, dict] = { + "free": { + "daily_scans": 3, + "concurrent_scans": 1, + # Model ids the plan may run. LesionSegmenter is the free model; "None" + # (view-only) never reaches the server, so it isn't listed. + "models": ["LesionSegmenter"], + "postprocessing": False, + "daily_ai_messages": 10, + "create_reports": False, + "retention_days": 7, + "priority_queue": False, + }, + "pro": { + "daily_scans": 50, + "concurrent_scans": 5, + "models": None, + "postprocessing": True, + "daily_ai_messages": 200, + "create_reports": True, + "retention_days": 365, + "priority_queue": True, + }, + # Team is Pro for each member; the pooling and shared-library half of the + # plan copy is not built, so per-member limits are what it grants today. + "team": { + "daily_scans": 50, + "concurrent_scans": 5, + "models": None, + "postprocessing": True, + "daily_ai_messages": 200, + "create_reports": True, + "retention_days": 365, + "priority_queue": True, + }, + "enterprise": { + "daily_scans": None, + "concurrent_scans": None, + "models": None, + "postprocessing": True, + "daily_ai_messages": None, + "create_reports": True, + "retention_days": None, + "priority_queue": True, + }, +} + +PLAN_IDS = tuple(PLAN_LIMITS) + +# Models that are a postprocessing step rather than a segmentation run. They are +# gated by the "postprocessing" flag and don't count against the scan quota. +POSTPROCESSING_MODELS = frozenset({"ShapeKit"}) + + +def limits_for(plan: str | None) -> dict: + """The limit dict for a plan id, falling back to free for anything unknown.""" + return PLAN_LIMITS.get(plan or DEFAULT_PLAN, PLAN_LIMITS[DEFAULT_PLAN]) + + +# ---- plan ------------------------------------------------------------------ + +def get_plan(user_id: str) -> str: + with session_scope() as s: + user = s.get(User, user_id) + return (user.plan if user else None) or DEFAULT_PLAN + + +def set_plan(user_id: str, plan: str) -> dict | None: + """Move an account to another plan. Returns the user's public dict, or None + for an unknown/system account. Raises ValueError on an unknown plan id.""" + if plan not in PLAN_LIMITS: + raise ValueError(f"Unknown plan {plan!r}") + with session_scope() as s: + user = s.get(User, user_id) + if user is None or user.is_system: + return None + user.plan = plan + s.flush() + return user.to_public_dict() + + +# ---- counting -------------------------------------------------------------- + +def _window_start(): + return utcnow() - WINDOW + + +def _count_in_window(s, user_id: str, kind: str) -> int: + return len(list(s.execute( + select(UsageEvent.id).where( + UsageEvent.user_id == user_id, + UsageEvent.kind == kind, + UsageEvent.created_at >= _window_start(), + ) + ).scalars())) + + +def _count_in_flight(s, user_id: str) -> int: + return len(list(s.execute( + select(UsageEvent.id).where( + UsageEvent.user_id == user_id, + UsageEvent.kind == KIND_INFERENCE, + UsageEvent.finished_at.is_(None), + ) + ).scalars())) + + +def _resets_at(s, user_id: str, kind: str) -> str | None: + """When the oldest event in the window ages out — i.e. when the next unit of + quota comes back. None if there's nothing in the window.""" + oldest = s.execute( + select(UsageEvent.created_at).where( + UsageEvent.user_id == user_id, + UsageEvent.kind == kind, + UsageEvent.created_at >= _window_start(), + ).order_by(UsageEvent.created_at.asc()).limit(1) + ).scalar_one_or_none() + return (oldest + WINDOW).isoformat() if oldest else None + + +# ---- checks ---------------------------------------------------------------- +# +# Each returns None when the action is allowed, or a dict describing the block. +# That dict is the 402 body: `reason` is what the UI switches on, the rest is +# what it needs to write a sentence without hardcoding the numbers. + +def check_inference(user_id: str, model: str) -> dict | None: + plan = get_plan(user_id) + limits = limits_for(plan) + postprocessing = model in POSTPROCESSING_MODELS + + if postprocessing: + if not limits["postprocessing"]: + return { + "reason": "postprocessing", "plan": plan, "feature": model, + "message": f"{model} postprocessing isn't included in the Free plan.", + } + return None + + allowed = limits["models"] + if allowed is not None and model not in allowed: + return { + "reason": "model_locked", "plan": plan, "feature": model, + "allowed_models": list(allowed), + "message": f"{model} isn't included in the Free plan.", + } + + with session_scope() as s: + max_concurrent = limits["concurrent_scans"] + if max_concurrent is not None: + in_flight = _count_in_flight(s, user_id) + if in_flight >= max_concurrent: + return { + "reason": "concurrent_scans", "plan": plan, + "limit": max_concurrent, "used": in_flight, + "message": ( + "The Free plan runs one scan at a time." + if max_concurrent == 1 + else f"Your plan runs {max_concurrent} scans at a time." + ), + } + + daily = limits["daily_scans"] + if daily is not None: + used = _count_in_window(s, user_id, KIND_INFERENCE) + if used >= daily: + return { + "reason": "daily_scans", "plan": plan, "limit": daily, "used": used, + "resets_at": _resets_at(s, user_id, KIND_INFERENCE), + "message": f"You've used your {daily} scans for today.", + } + return None + + +def check_ai_message(user_id: str) -> dict | None: + plan = get_plan(user_id) + daily = limits_for(plan)["daily_ai_messages"] + if daily is None: + return None + with session_scope() as s: + used = _count_in_window(s, user_id, KIND_AI_MESSAGE) + if used >= daily: + return { + "reason": "daily_ai_messages", "plan": plan, "limit": daily, "used": used, + "resets_at": _resets_at(s, user_id, KIND_AI_MESSAGE), + "message": f"You've used your {daily} assistant messages for today.", + } + return None + + +# ---- recording ------------------------------------------------------------- + +def record_inference(user_id: str, session_id: str, model: str) -> None: + """Open a usage row for a run that was just accepted. Re-running the same + session id (the frontend reuses it on retry) reopens the existing row rather + than charging twice.""" + if model in POSTPROCESSING_MODELS: + return # gated, but not metered — it's a step on an existing result + with session_scope() as s: + existing = s.execute( + select(UsageEvent).where( + UsageEvent.kind == KIND_INFERENCE, UsageEvent.ref_id == session_id + ) + ).scalar_one_or_none() + if existing is not None: + existing.finished_at = None + existing.model = model + return + s.add(UsageEvent( + id=str(uuid.uuid4()), user_id=user_id, kind=KIND_INFERENCE, + ref_id=session_id, model=model, + )) + + +def finish_inference(session_id: str) -> None: + """Close a run's usage row so it stops counting against concurrency. Called + when the job reaches a terminal state; a completed run still counts against + the daily quota (created_at is untouched).""" + with session_scope() as s: + event = s.execute( + select(UsageEvent).where( + UsageEvent.kind == KIND_INFERENCE, UsageEvent.ref_id == session_id + ) + ).scalar_one_or_none() + if event is not None and event.finished_at is None: + event.finished_at = utcnow() + + +def record_ai_message(user_id: str) -> None: + with session_scope() as s: + s.add(UsageEvent( + id=str(uuid.uuid4()), user_id=user_id, kind=KIND_AI_MESSAGE, + finished_at=utcnow(), + )) + + +# ---- reporting ------------------------------------------------------------- + +def usage_summary(user_id: str) -> dict: + """What the settings page shows: plan, its limits, and what's been used of + the metered ones in the current window.""" + plan = get_plan(user_id) + limits = limits_for(plan) + with session_scope() as s: + return { + "plan": plan, + "limits": limits, + "scans": { + "used": _count_in_window(s, user_id, KIND_INFERENCE), + "limit": limits["daily_scans"], + "in_flight": _count_in_flight(s, user_id), + "resets_at": _resets_at(s, user_id, KIND_INFERENCE), + }, + "ai_messages": { + "used": _count_in_window(s, user_id, KIND_AI_MESSAGE), + "limit": limits["daily_ai_messages"], + "resets_at": _resets_at(s, user_id, KIND_AI_MESSAGE), + }, + } diff --git a/flask-server/tests/functional/test_auth_endpoints.py b/flask-server/tests/functional/test_auth_endpoints.py index 269f06e..12f3836 100644 --- a/flask-server/tests/functional/test_auth_endpoints.py +++ b/flask-server/tests/functional/test_auth_endpoints.py @@ -19,9 +19,12 @@ def client(tmp_path, monkeypatch): importlib.reload(engine) import models.user # noqa: F401 import models.auth_session # noqa: F401 + import models.usage_event # noqa: F401 import services.auth_store as auth_store importlib.reload(auth_store) import services.job_store # noqa: F401 + import services.plan_store as plan_store + importlib.reload(plan_store) import api.auth as auth_mod importlib.reload(auth_mod) import api.auth_blueprint as bp_mod @@ -165,3 +168,37 @@ def test_signing_back_in_restores_a_deleted_account(client): back = client.post("/api/auth/login", json={"email": "z@a.com", "password": "password1"}) assert back.status_code == 200 assert client.get("/api/auth/me").status_code == 200 + + +def test_new_account_reports_the_free_plan(client): + r = client.post("/api/auth/register", json={"email": "p@q.com", "password": "password1"}) + assert r.get_json()["user"]["plan"] == "free" + + +def test_plan_and_usage_require_auth(client): + assert client.get("/api/me/usage").status_code == 401 + assert client.post("/api/me/plan", json={"plan": "pro"}).status_code == 401 + + +def test_changing_plan_changes_the_reported_limits(client): + client.post("/api/auth/register", json={"email": "p@q.com", "password": "password1"}) + + before = client.get("/api/me/usage").get_json() + assert before["plan"] == "free" + assert before["limits"]["daily_scans"] == 3 + assert before["limits"]["models"] == ["LesionSegmenter"] + + r = client.post("/api/me/plan", json={"plan": "pro"}) + assert r.status_code == 200 + assert r.get_json()["user"]["plan"] == "pro" + + after = client.get("/api/me/usage").get_json() + assert after["plan"] == "pro" + assert after["limits"]["models"] is None # every model + assert client.get("/api/auth/me").get_json()["user"]["plan"] == "pro" + + +def test_unknown_plan_is_rejected(client): + client.post("/api/auth/register", json={"email": "p@q.com", "password": "password1"}) + assert client.post("/api/me/plan", json={"plan": "platinum"}).status_code == 400 + assert client.post("/api/me/plan", json={}).status_code == 400 diff --git a/flask-server/tests/unit/test_plan_store.py b/flask-server/tests/unit/test_plan_store.py new file mode 100644 index 0000000..867d079 --- /dev/null +++ b/flask-server/tests/unit/test_plan_store.py @@ -0,0 +1,143 @@ +"""Unit tests for plan limits: which models a plan may run, the rolling daily +quota, the concurrency slot, and the assistant allowance. + +Each test gets its own temp-file database, following test_job_store's fixture. +""" + +import importlib + +import pytest + + +@pytest.fixture() +def plans(tmp_path, monkeypatch): + """Fresh temp DB; hands back (plan_store, user_id) for a free-plan account.""" + db_path = tmp_path / "plans.db" + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{db_path}") + + import constants + importlib.reload(constants) + import models.engine as engine + importlib.reload(engine) + import models.job # noqa: F401 + import models.user # noqa: F401 + import models.auth_session # noqa: F401 + import models.usage_event # noqa: F401 + import services.auth_store as auth_store + importlib.reload(auth_store) + import services.plan_store as plan_store + importlib.reload(plan_store) + + engine.reset_engine_for_tests() + engine.create_all() + user = auth_store.create_user("limits@example.com", "correct-horse-battery") + yield plan_store, user["id"] + engine.reset_engine_for_tests() + + +def test_new_accounts_start_on_free(plans): + plan_store, user_id = plans + assert plan_store.get_plan(user_id) == "free" + + +def test_free_may_only_run_the_free_model(plans): + plan_store, user_id = plans + assert plan_store.check_inference(user_id, "LesionSegmenter") is None + + blocked = plan_store.check_inference(user_id, "ePAI") + assert blocked is not None + assert blocked["reason"] == "model_locked" + assert blocked["feature"] == "ePAI" + + +def test_upgrading_unlocks_every_model(plans): + plan_store, user_id = plans + plan_store.set_plan(user_id, "pro") + assert plan_store.check_inference(user_id, "ePAI") is None + + +def test_unknown_plan_is_refused(plans): + plan_store, user_id = plans + with pytest.raises(ValueError): + plan_store.set_plan(user_id, "platinum") + + +def test_daily_quota_blocks_the_fourth_scan(plans): + plan_store, user_id = plans + for i in range(3): + assert plan_store.check_inference(user_id, "LesionSegmenter") is None + plan_store.record_inference(user_id, f"session-{i}", "LesionSegmenter") + plan_store.finish_inference(f"session-{i}") # free the concurrency slot + + blocked = plan_store.check_inference(user_id, "LesionSegmenter") + assert blocked is not None + assert blocked["reason"] == "daily_scans" + assert blocked["limit"] == 3 + assert blocked["used"] == 3 + assert blocked["resets_at"] + + +def test_concurrency_blocks_a_second_run_still_in_flight(plans): + plan_store, user_id = plans + plan_store.record_inference(user_id, "session-a", "LesionSegmenter") + + blocked = plan_store.check_inference(user_id, "LesionSegmenter") + assert blocked is not None + assert blocked["reason"] == "concurrent_scans" + + # Finishing the run frees the slot without refunding the quota. + plan_store.finish_inference("session-a") + assert plan_store.check_inference(user_id, "LesionSegmenter") is None + assert plan_store.usage_summary(user_id)["scans"]["used"] == 1 + + +def test_rerunning_a_session_id_does_not_charge_twice(plans): + plan_store, user_id = plans + plan_store.record_inference(user_id, "session-a", "LesionSegmenter") + plan_store.finish_inference("session-a") + plan_store.record_inference(user_id, "session-a", "LesionSegmenter") + + assert plan_store.usage_summary(user_id)["scans"]["used"] == 1 + + +def test_postprocessing_is_gated_but_not_metered(plans): + plan_store, user_id = plans + blocked = plan_store.check_inference(user_id, "ShapeKit") + assert blocked is not None + assert blocked["reason"] == "postprocessing" + + plan_store.set_plan(user_id, "pro") + assert plan_store.check_inference(user_id, "ShapeKit") is None + plan_store.record_inference(user_id, "session-shape", "ShapeKit") + assert plan_store.usage_summary(user_id)["scans"]["used"] == 0 + + +def test_assistant_allowance(plans): + plan_store, user_id = plans + for _ in range(10): + assert plan_store.check_ai_message(user_id) is None + plan_store.record_ai_message(user_id) + + blocked = plan_store.check_ai_message(user_id) + assert blocked is not None + assert blocked["reason"] == "daily_ai_messages" + assert blocked["limit"] == 10 + + +def test_enterprise_is_unmetered(plans): + plan_store, user_id = plans + plan_store.set_plan(user_id, "enterprise") + for i in range(60): + plan_store.record_inference(user_id, f"session-{i}", "ePAI") + assert plan_store.check_inference(user_id, "ePAI") is None + assert plan_store.check_ai_message(user_id) is None + + +def test_usage_summary_reports_the_plan_limits(plans): + plan_store, user_id = plans + summary = plan_store.usage_summary(user_id) + assert summary["plan"] == "free" + assert summary["limits"]["daily_scans"] == 3 + assert summary["scans"] == { + "used": 0, "limit": 3, "in_flight": 0, "resets_at": None + } From a5d7c5d3aad0df30b0fc0c2cb6ae9a85da80fb9b Mon Sep 17 00:00:00 2001 From: Yusufa09 Date: Fri, 7 Aug 2026 17:54:23 -0400 Subject: [PATCH 3/4] finish mock account and plan feature --- .github/workflows/ci.yml | 6 + PanTS-Demo/src/App.tsx | 13 +- PanTS-Demo/src/components/AuthModal.css | 20 ++ PanTS-Demo/src/components/AuthModal.tsx | 89 +++++-- PanTS-Demo/src/contexts/authContext.tsx | 31 ++- PanTS-Demo/src/helpers/accountProfile.test.ts | 18 +- PanTS-Demo/src/helpers/accountProfile.ts | 26 ++- .../routes/Settings/NotificationSettings.tsx | 31 --- .../src/routes/Settings/PlanSettings.tsx | 45 ++-- .../src/routes/Settings/ProfileSettings.tsx | 210 +++++++++-------- PanTS-Demo/src/routes/Settings/Settings.css | 50 +++- PanTS-Demo/src/routes/Settings/index.tsx | 16 +- PanTS-Demo/src/routes/SignupPage.css | 218 ------------------ PanTS-Demo/src/routes/SignupPage.tsx | 133 ----------- PanTS-Demo/src/routes/SignupRedirect.tsx | 21 ++ PanTS-Demo/src/routes/UploadPage.tsx | 6 +- PanTS-Demo/src/test/accountPage.test.tsx | 48 ++-- PanTS-Demo/src/test/accounts.test.tsx | 182 +++++++++++---- PanTS-Demo/src/test/routes.smoke.test.tsx | 24 +- 19 files changed, 566 insertions(+), 621 deletions(-) delete mode 100644 PanTS-Demo/src/routes/Settings/NotificationSettings.tsx delete mode 100644 PanTS-Demo/src/routes/SignupPage.css delete mode 100644 PanTS-Demo/src/routes/SignupPage.tsx create mode 100644 PanTS-Demo/src/routes/SignupRedirect.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b08ce5..05a0c5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,12 @@ jobs: - name: OAuth unit + endpoint tests run: python -m pytest tests/unit/test_oauth_linking.py tests/functional/test_oauth_endpoints.py -v + # Plan limits: which models a plan may run, the rolling daily quota, the + # concurrency slot, and the assistant allowance. These gate real work, so a + # regression here silently hands out free GPU time. + - name: Plan-limit unit tests + run: python -m pytest tests/unit/test_plan_store.py -v + # All migrations must apply cleanly against a fresh SQLite DB and round-trip. - name: Alembic migration smoke env: diff --git a/PanTS-Demo/src/App.tsx b/PanTS-Demo/src/App.tsx index 99a61e2..4e08e1b 100644 --- a/PanTS-Demo/src/App.tsx +++ b/PanTS-Demo/src/App.tsx @@ -21,9 +21,8 @@ const SettingsPage = lazy(() => import("./routes/Settings")); const ProfileSettings = lazy(() => import("./routes/Settings/ProfileSettings")); const PlanSettings = lazy(() => import("./routes/Settings/PlanSettings")); const HistorySettings = lazy(() => import("./routes/Settings/HistorySettings")); -const NotificationSettings = lazy(() => import("./routes/Settings/NotificationSettings")); const PrivacySettings = lazy(() => import("./routes/Settings/PrivacySettings")); -const SignupPage = lazy(() => import("./routes/SignupPage")); +const SignupRedirect = lazy(() => import("./routes/SignupRedirect")); const LegalPage = lazy(() => import("./routes/LegalPage")); const RotatingHeartLoader = lazy(() => import("./components/Loading")); @@ -87,16 +86,16 @@ function App() { /> } /> } /> - {/* Sign in is a popup; sign up is a page. Old /login links land on home. */} + {/* Both sign in and sign up are the popup now. /login and + /signup stay routable so old links don't 404. */} } /> - } /> + } /> {/* Settings is a shell with a left nav; each section is its own URL so a link can point straight at one. */} }> } /> } /> } /> - } /> } /> } /> @@ -113,8 +112,8 @@ function App() { /> - {/* Global sign-in popup, above all routes. Inside the router so it - can link out to /signup. */} + {/* Global auth popup, above all routes. Inside the router so it + can link to the legal pages. */} diff --git a/PanTS-Demo/src/components/AuthModal.css b/PanTS-Demo/src/components/AuthModal.css index ca502b6..b0e0bdb 100644 --- a/PanTS-Demo/src/components/AuthModal.css +++ b/PanTS-Demo/src/components/AuthModal.css @@ -205,8 +205,28 @@ color: #111111; } +/* Signup only — consent by continuing, in place of a checkbox. */ +.authm-fineprint { + width: 100%; + margin: 16px 0 0; + font-size: 11.5px; + line-height: 1.55; + color: #8f8f8f; + text-align: center; +} +.authm-fineprint a { + color: #6a6a6a; + text-decoration: underline; +} +.authm-fineprint a:hover { + color: #002d72; +} + .authm-toggle { + width: 100%; margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgba(0, 0, 0, 0.07); text-align: center; font-size: 13px; color: #6a6a6a; diff --git a/PanTS-Demo/src/components/AuthModal.tsx b/PanTS-Demo/src/components/AuthModal.tsx index fe75df8..1fda64a 100644 --- a/PanTS-Demo/src/components/AuthModal.tsx +++ b/PanTS-Demo/src/components/AuthModal.tsx @@ -4,20 +4,25 @@ import { Link } from "react-router-dom"; import { useAuth } from "../contexts/authContext"; import "./AuthModal.css"; -// Global sign-IN popup (light theme, World Labs layout). Opened via -// authContext.promptAuth() from the header and from gated upload actions. -// Email/password posts to the API; the provider buttons hand the browser to the -// backend's OAuth redirect (and are disabled if that provider isn't configured). +// The one auth popup: signing in and creating an account are the same card with +// a different title, a different submit button, and terms fine print on the +// signup side. Claude and ChatGPT both do exactly this — their log-in and +// create-account screens are the same layout throughout. // -// Signing UP is a full page (routes/SignupPage) — creating an account now -// involves picking an account type and a plan, which doesn't belong in a popup. -// This modal only links there. +// Opened by authContext.promptAuth(mode) from the header, from gated upload +// actions, and from /signup — which redirects here rather than 404ing, so old +// links and bookmarks still land somewhere sensible. +// +// Providers come first with the email form behind "Continue with email", which +// keeps the default card short. const AuthModal: React.FC = () => { const { - authPrompt, closeAuthPrompt, signIn, + authPrompt, closeAuthPrompt, promptAuth, signIn, signUp, signInWithProvider, oauthProviders, oauthError, clearOauthError, } = useAuth(); + const isSignup = authPrompt.mode === "signup"; + // "email mode" reveals the email/password form (World Labs' "Continue with email"). const [emailMode, setEmailMode] = useState(false); const [email, setEmail] = useState(""); @@ -33,6 +38,12 @@ const AuthModal: React.FC = () => { } }, [authPrompt.open]); + // Flipping between sign-in and sign-up clears the password and any error — + // a rejected sign-in shouldn't still be showing over the signup form. + useEffect(() => { + setPassword(""); setError(""); + }, [authPrompt.mode]); + // Surface an error the OAuth callback redirected back with. useEffect(() => { if (oauthError) setError(oauthError); @@ -60,11 +71,12 @@ const AuthModal: React.FC = () => { if (!email.trim() || !password) { setError("Enter an email and password."); return; } setBusy(true); try { - await signIn(email, password); + if (isSignup) await signUp(email, password); + else await signIn(email, password); // authContext auto-closes the popup once the user is set. } catch (err) { - // Surface the API's message ("Invalid email or password", ...) rather - // than a generic string. + // Surface the API's message ("Invalid email or password", "An account + // with that email already exists", ...) rather than a generic string. setError(err instanceof Error && err.message ? err.message : "Something went wrong. Try again."); } finally { setBusy(false); @@ -73,11 +85,17 @@ const AuthModal: React.FC = () => { return (
-
e.stopPropagation()}> +
e.stopPropagation()} + > -

Sign in

+

{isSignup ? "Create your account" : "Sign in"}

{!emailMode ? ( <> @@ -90,7 +108,7 @@ const AuthModal: React.FC = () => { onClick={() => signInWithProvider("google")} > - Sign in with Google + Continue with Google {/* Errors bounced back from the OAuth callback land here. */} @@ -121,22 +139,51 @@ const AuthModal: React.FC = () => { {error &&
{error}
} )} + {/* Consent by continuing rather than a checkbox, on the signup side + only — that's the moment the account is created. */} + {isSignup && ( +

+ By continuing you agree to our{" "} + Terms of Service and{" "} + Privacy Policy. +

+ )} +
- Don't have an account?{" "} - Sign up + {isSignup ? ( + <> + Already have an account?{" "} + + + ) : ( + <> + Don't have an account?{" "} + + + )}
diff --git a/PanTS-Demo/src/contexts/authContext.tsx b/PanTS-Demo/src/contexts/authContext.tsx index 3ebe137..a3479a0 100644 --- a/PanTS-Demo/src/contexts/authContext.tsx +++ b/PanTS-Demo/src/contexts/authContext.tsx @@ -64,6 +64,7 @@ export type PlanUsage = { }; export type AuthProvider2 = "google" | "github"; +export type AuthMode = "signin" | "signup"; type AuthContextValue = { user: AuthUser | null; @@ -97,10 +98,12 @@ type AuthContextValue = { /** Current plan usage, or null until loaded. Refreshed by refreshUsage(). */ usage: PlanUsage | null; refreshUsage: () => Promise; - // Global sign-in popup, opened from the header or any gated action. Signing - // up is a page (/signup), so this has no signup mode. - authPrompt: { open: boolean }; - promptAuth: () => void; + // Global auth popup, opened from the header or any gated action. Signing up + // and signing in are the same card with a different title, the way both + // Claude and ChatGPT do it — `mode` picks which. + authPrompt: { open: boolean; mode: AuthMode }; + /** Defaults to sign-in: most people reaching a gate already have an account. */ + promptAuth: (mode?: AuthMode) => void; closeAuthPrompt: () => void; /** Error surfaced by the OAuth callback redirect (?auth_error=...), if any. */ oauthError: string | null; @@ -167,7 +170,9 @@ const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - const [authPrompt, setAuthPrompt] = useState<{ open: boolean }>({ open: false }); + const [authPrompt, setAuthPrompt] = useState<{ open: boolean; mode: AuthMode }>({ + open: false, mode: "signin", + }); const [oauthProviders, setOauthProviders] = useState | null>(null); const [usage, setUsage] = useState(null); // The OAuth callback redirects back with ?auth_error=... on failure (e.g. an @@ -222,8 +227,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); // If we came back from a failed OAuth attempt, show the popup with the error. + // Sign-in mode: the failure message tells them what to do, and the signup + // side's terms fine print would be noise on top of an error. useEffect(() => { - if (oauthError) setAuthPrompt({ open: true }); + if (oauthError) setAuthPrompt({ open: true, mode: "signin" }); }, [oauthError]); // Cross-tab: another tab signed in/out -> re-check. @@ -289,12 +296,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []); - const promptAuth = useCallback(() => setAuthPrompt({ open: true }), []); - const closeAuthPrompt = useCallback(() => setAuthPrompt({ open: false }), []); + const promptAuth = useCallback( + (mode: AuthMode = "signin") => setAuthPrompt({ open: true, mode }), + [] + ); + const closeAuthPrompt = useCallback( + () => setAuthPrompt((p) => ({ ...p, open: false })), + [] + ); // Auto-close the popup once a user is established. useEffect(() => { - if (user) setAuthPrompt((p) => (p.open ? { open: false } : p)); + if (user) setAuthPrompt((p) => (p.open ? { ...p, open: false } : p)); }, [user]); const updatePreferences = useCallback( diff --git a/PanTS-Demo/src/helpers/accountProfile.test.ts b/PanTS-Demo/src/helpers/accountProfile.test.ts index 5dfbd39..5fa7d42 100644 --- a/PanTS-Demo/src/helpers/accountProfile.test.ts +++ b/PanTS-Demo/src/helpers/accountProfile.test.ts @@ -84,10 +84,20 @@ describe("plan cards", () => { } }); - it("quotes no prices — none have been set", () => { - const text = JSON.stringify(PLANS); - expect(text).not.toMatch(/[$£€]\s?\d/); - expect(text).not.toMatch(/per month|\/mo\b/i); + it("prices the individual plans and leaves Enterprise open", () => { + const byId = Object.fromEntries(PLANS.map((p) => [p.id, p])); + expect(byId.free.price).toBe("$0"); + expect(byId.pro.price).toBe("$1.99"); + expect(byId.team.price).toBe("$4.99"); + expect(byId.enterprise.price).toBe("Custom"); + // A bare figure means nothing without a period attached to it. + for (const plan of PLANS) expect(plan.priceNote).toBeTruthy(); + }); + + it("gives every card a lead line so the bullet lists align", () => { + for (const plan of PLANS) { + expect(plan.inherits ?? plan.pointsLead).toBeTruthy(); + } }); it("has a limits entry for every card and vice versa", () => { diff --git a/PanTS-Demo/src/helpers/accountProfile.ts b/PanTS-Demo/src/helpers/accountProfile.ts index 2cf5bb5..bde76f3 100644 --- a/PanTS-Demo/src/helpers/accountProfile.ts +++ b/PanTS-Demo/src/helpers/accountProfile.ts @@ -84,12 +84,21 @@ export type Plan = { group: PlanGroup; /** One line under the name. Six words is the budget. */ blurb: string; - /** Shown where a price goes. No numbers: pricing hasn't been set. */ + /** + * The headline figure. Billing lands before launch; until it does, /me/plan + * is a column write and switching plans grants the tier immediately. The + * limits attached to a plan are enforced for real either way. + */ price: string; + /** Small print under the price ("per month"). */ + priceNote?: string; /** Small pill in the card's top corner (member counts), if any. */ badge?: string; /** Renders "Everything in , plus:" above the bullets. */ inherits?: PlanId; + /** Lead line above the bullets on a plan that inherits nothing. Present so + * every card has one and the bullet lists line up across the row. */ + pointsLead?: string; /** Short noun phrases, not sentences. */ points: string[]; cta: string; @@ -101,15 +110,14 @@ export const PLANS: Plan[] = [ label: "Free", group: "individual", blurb: "Try BodyMaps", - // Not "Free" — that's the plan's name, and repeating it in the price slot - // reads as a rendering bug. - price: "No cost", + price: "$0", + priceNote: "per month", + pointsLead: "Includes:", points: [ "3 scans a day", "LesionSegmenter model", "Full viewer and 3D reconstruction", "10 assistant messages a day", - "Results kept 7 days", ], cta: "Start free", }, @@ -118,7 +126,8 @@ export const PLANS: Plan[] = [ label: "Pro", group: "individual", blurb: "For everyday clinical and research work", - price: "Not priced yet", + price: "$1.99", + priceNote: "per month", inherits: "free", points: [ "50 scans a day", @@ -126,7 +135,6 @@ export const PLANS: Plan[] = [ "5 scans at once", "Priority in the queue", "Create reports and annotations", - "Results kept a year", ], cta: "Upgrade to Pro", }, @@ -135,7 +143,8 @@ export const PLANS: Plan[] = [ label: "Team", group: "team", blurb: "For a practice or lab", - price: "Not priced yet", + price: "$4.99", + priceNote: "per month", badge: "2–15 members", inherits: "pro", // No "everything in Pro per member" bullet: the inherits line above the @@ -155,6 +164,7 @@ export const PLANS: Plan[] = [ blurb: "For a hospital or institution", // "Talk to us" is the button; the price slot needs to say something else. price: "Custom", + priceNote: "volume pricing", badge: "15+ members", inherits: "team", points: [ diff --git a/PanTS-Demo/src/routes/Settings/NotificationSettings.tsx b/PanTS-Demo/src/routes/Settings/NotificationSettings.tsx deleted file mode 100644 index e73e5d9..0000000 --- a/PanTS-Demo/src/routes/Settings/NotificationSettings.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import { useAuth } from "../../contexts/authContext"; - -// Notifications. One switch today; its own section so adding the next one -// doesn't mean re-cutting the navigation. -const NotificationSettings: React.FC = () => { - const { user, updatePreferences } = useAuth(); - if (!user) return null; - - return ( -
-

Notifications

- -
- Email me when a scan finishes - -
-
- ); -}; - -export default NotificationSettings; diff --git a/PanTS-Demo/src/routes/Settings/PlanSettings.tsx b/PanTS-Demo/src/routes/Settings/PlanSettings.tsx index 274dd7f..fd013cc 100644 --- a/PanTS-Demo/src/routes/Settings/PlanSettings.tsx +++ b/PanTS-Demo/src/routes/Settings/PlanSettings.tsx @@ -19,10 +19,14 @@ const UsageBar: React.FC<{ used: number; limit: number | null; resetsAt: string | null; -}> = ({ label, used, limit, resetsAt }) => { + /** What to say before the window has started (nothing used yet). */ + idleNote: string; +}> = ({ label, used, limit, resetsAt, idleNote }) => { const pct = limit === null ? 0 : Math.min(100, Math.round((used / limit) * 100)); const spent = limit !== null && used >= limit; - const reset = untilLabel(resetsAt); + // resets_at is null until the first event lands, so an untouched allowance + // would otherwise show a bare label with no hint of how the window works. + const reset = untilLabel(resetsAt) ?? (limit === null ? null : idleNote); return (
@@ -74,6 +78,15 @@ const PlanSettings: React.FC = () => {

{planLabel(current)} plan

{currentPlan?.blurb}

+
@@ -87,12 +100,14 @@ const PlanSettings: React.FC = () => { used={usage.scans.used} limit={usage.scans.limit} resetsAt={usage.scans.resets_at} + idleNote="Resets 24h after your first scan" /> ) : ( @@ -100,11 +115,8 @@ const PlanSettings: React.FC = () => { )} -
-

Change plan

-

- Nothing is charged — pricing hasn't been set. Limits apply as listed. -

+
+

Change plan

{([ @@ -135,7 +147,10 @@ const PlanSettings: React.FC = () => { {p.badge && {p.badge}}

{p.label}

{p.blurb}

-
{p.price}
+
+ {p.price} + {p.priceNote && {p.priceNote}} +
    - {p.inherits && ( -
  • - Everything in {planLabel(p.inherits)}, plus: -
  • - )} + {/* Every card gets a lead line, including the one that + inherits nothing, so the bullet lists start at the + same height across the row. */} +
  • + {p.inherits + ? `Everything in ${planLabel(p.inherits)}, plus:` + : p.pointsLead} +
  • + {p.points.map((pt) => (
  • {pt} diff --git a/PanTS-Demo/src/routes/Settings/ProfileSettings.tsx b/PanTS-Demo/src/routes/Settings/ProfileSettings.tsx index 66f8f1c..73c42c9 100644 --- a/PanTS-Demo/src/routes/Settings/ProfileSettings.tsx +++ b/PanTS-Demo/src/routes/Settings/ProfileSettings.tsx @@ -1,125 +1,141 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../../contexts/authContext"; import { ACCOUNT_TYPES, type AccountType } from "../../helpers/accountProfile"; import { useSettings } from "./context"; -// Profile: avatar, name, email, and the self-reported account type. +// Profile: who you are, one preference, and the way out. +// +// The name is a live field rather than an Add/Save/Cancel dance — Claude puts a +// plain text input in the row and commits on blur, which is three interactions +// fewer for the same result. // // The account type used to be a required signup step with four descriptive -// cards. Nothing reads it, so it's a plain optional select here — asking a -// question that changes nothing is worse than not asking it. +// cards. Nothing reads it, so it's an optional select here — asking a question +// that changes nothing is worse than not asking it. const ProfileSettings: React.FC = () => { const navigate = useNavigate(); - const { user, updateName, updateAccountProfile, signOut } = useAuth(); - const { busy, run, notify } = useSettings(); + const { + user, updateName, updateAccountProfile, updatePreferences, signOut, + } = useAuth(); + const { run, notify } = useSettings(); + + // What the server currently holds. A name derived from the email isn't a + // value the user chose, so it shows as a placeholder rather than text they'd + // have to delete before typing their own. + const committed = user?.hasCustomName ? user.name : ""; - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(""); + // Seeded from the account, then owned by the field while it's being edited. + const [draft, setDraft] = useState(committed); + useEffect(() => { + setDraft(committed); + }, [committed]); if (!user) return null; - const save = () => + // Commit on blur (and on Enter). No-op when nothing changed, so tabbing + // through the form doesn't fire a request per field. + const commitName = () => { + const next = draft.trim(); + if (next === committed) return; run(async () => { - await updateName(draft.trim()); - setEditing(false); - notify("Your name has been updated."); + await updateName(next); + notify(next ? "Your name has been updated." : "Your name has been cleared."); }); + }; return ( -
    -

    Profile

    + <> +
    +

    Profile

    -
    - Avatar - - {(user.name || user.email).charAt(0).toUpperCase()} - -
    +
    + Avatar + + {(user.name || user.email).charAt(0).toUpperCase()} + +
    -
    - Name - {editing ? ( -
    - setDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") save(); - if (e.key === "Escape") setEditing(false); - }} - /> - - -
    - ) : ( -
    - {user.name} - -
    - )} -
    +
    + + setDraft(e.target.value)} + onBlur={commitName} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + if (e.key === "Escape") setDraft(committed); + }} + /> +
    -
    - Email - {user.email} +
    + Email + {user.email} +
    + +
    + + +
    -
    - - Role - Optional. Doesn't affect what you can access. - - +
    +

    Notifications

    + +
    + Email me when a scan finishes + +
    -
    - Sign out - +
    +

    Session

    + +
    + {/* Not "log out of all devices": /auth/logout revokes this session + only, so claiming otherwise would be a lie. */} + Signed in on this browser + +
    -
    + ); }; diff --git a/PanTS-Demo/src/routes/Settings/Settings.css b/PanTS-Demo/src/routes/Settings/Settings.css index b926d66..b82b4fc 100644 --- a/PanTS-Demo/src/routes/Settings/Settings.css +++ b/PanTS-Demo/src/routes/Settings/Settings.css @@ -41,6 +41,9 @@ top: 24px; } .set-nav-item { + display: flex; + align-items: center; + gap: 10px; padding: 9px 12px; border-radius: 8px; font-size: 14px; @@ -48,6 +51,13 @@ text-decoration: none; transition: background 0.15s, color 0.15s; } +.set-nav-item svg { + flex-shrink: 0; + opacity: 0.75; +} +.set-nav-item--on svg { + opacity: 1; +} .set-nav-item:hover { background: rgba(0, 0, 0, 0.04); color: #111111; @@ -64,7 +74,6 @@ border: 1px solid rgba(0, 0, 0, 0.08); border-radius: 16px; padding: 26px 28px; - min-height: 340px; } .set-banner { @@ -98,6 +107,17 @@ margin: 0 0 12px; } +/* A rule closing the heading block, the way ChatGPT's settings panel does it — + it's what makes a stack of rows read as a section rather than a list adrift. + Applied as a top border on the first row so the rule lands after the optional + sub-line, with no wrapper element needed. */ +.set-heading + .set-row, +.set-heading + .set-usage, +.set-sub + .set-row, +.set-sub + .set-usage { + border-top: 1px solid rgba(0, 0, 0, 0.07); +} + /* ── The row: label left, control right, hairline between ── */ .set-row { display: flex; @@ -284,7 +304,9 @@ /* Usage bar: label + reset time left, track, "n of m" right. */ .set-usage { display: grid; - grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr) auto; + /* Wide enough for "Resets 24h after your first message" on one line, so the + two usage rows keep the same height. */ + grid-template-columns: minmax(215px, 1fr) minmax(0, 1.6fr) auto; align-items: center; gap: 16px; padding: 16px 0; @@ -351,16 +373,26 @@ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } +/* The whole block is centred — heading, caveat, toggle and cards — rather than + a left-aligned heading over a centred pill, which reads as a mistake. + Claude's upgrade page centres the lot. */ .set-plan-picker { display: flex; flex-direction: column; align-items: center; + text-align: center; +} +.set-plan-picker .set-sub { + max-width: 46ch; } .set-plan-cards { width: 100%; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; + /* Equal height regardless of how many bullets each plan has. */ + align-items: stretch; + text-align: left; } .set-plan-card { position: relative; @@ -400,11 +432,21 @@ margin: 3px 0 16px; } .set-plan-price { - font-size: 15px; - font-weight: 600; + display: flex; + align-items: baseline; + gap: 6px; + font-size: 26px; + font-weight: 700; + letter-spacing: -0.01em; color: #111111; margin-bottom: 16px; } +.set-plan-price-note { + font-size: 12.5px; + font-weight: 400; + color: #8f8f8f; + letter-spacing: 0; +} .set-plan-cta { width: 100%; padding: 10px; diff --git a/PanTS-Demo/src/routes/Settings/index.tsx b/PanTS-Demo/src/routes/Settings/index.tsx index acae86d..693fd39 100644 --- a/PanTS-Demo/src/routes/Settings/index.tsx +++ b/PanTS-Demo/src/routes/Settings/index.tsx @@ -1,3 +1,6 @@ +import { + IconCreditCard, IconHistory, IconShieldLock, IconUser, +} from "@tabler/icons-react"; import React, { useEffect, useState } from "react"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; import Header from "../../components/Header"; @@ -18,12 +21,14 @@ import "./Settings.css"; // file owns the chrome, the signed-out redirect, and the shared busy/notice // state so every action reports success and failure the same way. +// Notifications is deliberately not a section: it's one switch, and a whole +// page for one switch is a mostly-empty panel. It lives on Profile, the way +// Claude keeps small preferences inside General. const SECTIONS = [ - { to: "/account", label: "Profile", end: true }, - { to: "/account/plan", label: "Plan" }, - { to: "/account/history", label: "History" }, - { to: "/account/notifications", label: "Notifications" }, - { to: "/account/privacy", label: "Privacy" }, + { to: "/account", label: "Profile", icon: IconUser, end: true }, + { to: "/account/plan", label: "Plan", icon: IconCreditCard }, + { to: "/account/history", label: "History", icon: IconHistory }, + { to: "/account/privacy", label: "Privacy", icon: IconShieldLock }, ]; const SettingsPage: React.FC = () => { @@ -84,6 +89,7 @@ const SettingsPage: React.FC = () => { `set-nav-item${isActive ? " set-nav-item--on" : ""}` } > +
    } /> - + + + ); -describe("SignupPage", () => { - it("creates the account from one screen and goes straight to the app", async () => { +/** Opens signup and reveals the email/password form behind "Continue with email". */ +const openEmailSignup = async (user: ReturnType) => { + await user.click(await screen.findByRole("button", { name: "open signup" })); + await user.click(await screen.findByRole("button", { name: "Continue with email" })); +}; + +describe("signup popup", () => { + it("creates the account from the popup", async () => { const user = userEvent.setup(); - renderAt(, "/signup"); + renderPopup(); + await openEmailSignup(user); - expect(await screen.findByText("Create your account")).toBeInTheDocument(); await user.type(screen.getByLabelText(/^Email$/i), "test@example.com"); await user.type(screen.getByLabelText(/^Password$/i), "hunter2hunter2"); await user.click(screen.getByRole("button", { name: "Create account" })); - expect(await screen.findByText("Upload page")).toBeInTheDocument(); - expect(registeredWith).toMatchObject({ - email: "test@example.com", password: "hunter2hunter2", - }); + await waitFor(() => + expect(registeredWith).toMatchObject({ + email: "test@example.com", password: "hunter2hunter2", + }) + ); + // authContext closes the popup once a user is established. + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + + it("is a popup, not a page", async () => { + const user = userEvent.setup(); + renderPopup(); + await user.click(await screen.findByRole("button", { name: "open signup" })); + + const dialog = await screen.findByRole("dialog", { name: "Create your account" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); }); it("asks nothing beyond an email and a password", async () => { - renderAt(, "/signup"); - await screen.findByText("Create your account"); + const user = userEvent.setup(); + renderPopup(); + await openEmailSignup(user); - // The steps that used to follow are gone, not merely reordered. expect(screen.queryByLabelText(/^Name$/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/Confirm password/i)).not.toBeInTheDocument(); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); expect(document.body.textContent).not.toMatch(/How will you use|Choose a plan/i); - for (const plan of ["Free", "Pro", "Team", "Enterprise"]) { - expect(screen.queryByRole("button", { name: plan })).not.toBeInTheDocument(); - } }); it("accepts the terms inline rather than as a step", async () => { - renderAt(, "/signup"); - await screen.findByText("Create your account"); + const user = userEvent.setup(); + renderPopup(); + await user.click(await screen.findByRole("button", { name: "open signup" })); expect(screen.getByText(/By continuing you agree to our/i)).toBeInTheDocument(); - // The links are still there for anyone who wants to read them. expect(screen.getByRole("link", { name: "Terms of Service" })).toHaveAttribute("href", "/terms"); expect(screen.getByRole("link", { name: "Privacy Policy" })).toHaveAttribute("href", "/privacy"); }); - it("offers both providers", async () => { - renderAt(, "/signup"); - await screen.findByText("Create your account"); + it("offers both providers, with the same wording as sign-in", async () => { + const user = userEvent.setup(); + renderPopup(); + await user.click(await screen.findByRole("button", { name: "open signup" })); expect(screen.getByRole("button", { name: /Continue with Google/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Continue with GitHub/i })).toBeInTheDocument(); @@ -118,8 +140,9 @@ describe("SignupPage", () => { return jsonResponse({}); }) as unknown as typeof fetch; - renderAt(, "/signup"); - await screen.findByText("Create your account"); + const user = userEvent.setup(); + renderPopup(); + await user.click(await screen.findByRole("button", { name: "open signup" })); await waitFor(() => expect(screen.getByRole("button", { name: /Continue with GitHub/i })).toBeDisabled() @@ -127,7 +150,7 @@ describe("SignupPage", () => { expect(screen.getByRole("button", { name: /Continue with Google/i })).toBeEnabled(); }); - it("reports a rejected registration without leaving the page", async () => { + it("reports a rejected registration without closing", async () => { global.fetch = vi.fn(async (url: RequestInfo | URL) => { const u = String(url); if (u.includes("/api/auth/me")) return jsonResponse({ user: null }); @@ -144,21 +167,98 @@ describe("SignupPage", () => { }) as unknown as typeof fetch; const user = userEvent.setup(); - renderAt(, "/signup"); + renderPopup(); + await openEmailSignup(user); - await user.type(await screen.findByLabelText(/^Email$/i), "taken@example.com"); + await user.type(screen.getByLabelText(/^Email$/i), "taken@example.com"); await user.type(screen.getByLabelText(/^Password$/i), "hunter2hunter2"); await user.click(screen.getByRole("button", { name: "Create account" })); expect( await screen.findByText("An account with that email already exists") ).toBeInTheDocument(); - expect(screen.getByText("Create your account")).toBeInTheDocument(); + expect(screen.getByRole("dialog", { name: "Create your account" })).toBeInTheDocument(); + }); +}); + +describe("switching between the two modes", () => { + it("flips to sign-in and back without closing", async () => { + const user = userEvent.setup(); + renderPopup(); + await user.click(await screen.findByRole("button", { name: "open signup" })); + + await user.click(screen.getByRole("button", { name: "Sign in" })); + expect(await screen.findByRole("dialog", { name: "Sign in" })).toBeInTheDocument(); + // Terms are a signup-side thing. + expect(screen.queryByText(/By continuing you agree/i)).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Sign up" })); + expect(await screen.findByRole("dialog", { name: "Create your account" })).toBeInTheDocument(); + }); + + it("clears a failed attempt when flipping mode", async () => { + global.fetch = vi.fn(async (url: RequestInfo | URL) => { + const u = String(url); + if (u.includes("/api/auth/me")) return jsonResponse({ user: null }); + if (u.includes("/api/auth/oauth/providers")) return jsonResponse({ google: true, github: true }); + if (u.includes("/api/auth/register")) { + return { + ok: false, status: 409, + json: async () => ({ error: "An account with that email already exists" }), + text: async () => "", + headers: { get: () => "application/json" }, + }; + } + return jsonResponse({}); + }) as unknown as typeof fetch; + + const user = userEvent.setup(); + renderPopup(); + await openEmailSignup(user); + await user.type(screen.getByLabelText(/^Email$/i), "taken@example.com"); + await user.type(screen.getByLabelText(/^Password$/i), "hunter2hunter2"); + await user.click(screen.getByRole("button", { name: "Create account" })); + await screen.findByText("An account with that email already exists"); + + await user.click(screen.getByRole("button", { name: "Sign in" })); + await waitFor(() => + expect( + screen.queryByText("An account with that email already exists") + ).not.toBeInTheDocument() + ); + // The email survives the flip; the password does not. + expect(screen.getByLabelText(/^Email$/i)).toHaveValue("taken@example.com"); + expect(screen.getByLabelText(/^Password$/i)).toHaveValue(""); + }); +}); + +describe("/signup", () => { + const renderRoute = () => + render( + + + + } /> + Landing
    } /> + Upload page
    } /> + + + + + ); + + it("still works as a link, opening the popup over the landing page", async () => { + renderRoute(); + expect(await screen.findByText("Landing")).toBeInTheDocument(); + expect( + await screen.findByRole("dialog", { name: "Create your account" }) + ).toBeInTheDocument(); }); - it("sends someone who is already signed in to the app", async () => { + it("sends someone who is already signed in to the app instead", async () => { signedIn = true; - renderAt(, "/signup"); + renderRoute(); await waitFor(() => expect(screen.getByText("Upload page")).toBeInTheDocument()); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); }); diff --git a/PanTS-Demo/src/test/routes.smoke.test.tsx b/PanTS-Demo/src/test/routes.smoke.test.tsx index d3689ea..dbeee16 100644 --- a/PanTS-Demo/src/test/routes.smoke.test.tsx +++ b/PanTS-Demo/src/test/routes.smoke.test.tsx @@ -1,11 +1,11 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { useEffect, type ReactElement } from "react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import AuthModal from "../components/AuthModal"; import { AuthProvider, useAuth } from "../contexts/authContext"; import SettingsPage from "../routes/Settings"; -import NotificationSettings from "../routes/Settings/NotificationSettings"; +import ProfileSettings from "../routes/Settings/ProfileSettings"; import LandingPage from "../routes/LandingPage"; import Homepage from "../routes/Homepage"; import UploadPage from "../routes/UploadPage"; @@ -70,11 +70,11 @@ describe("route smoke tests", () => { , ); - expect(await screen.findByText("Sign in with Google")).toBeInTheDocument(); - expect(screen.getByText("Sign in with GitHub")).toBeInTheDocument(); + expect(await screen.findByText("Continue with Google")).toBeInTheDocument(); + expect(screen.getByText("Continue with GitHub")).toBeInTheDocument(); }); - it("AuthModal offers sign up as a link to the /signup page, not a popup mode", async () => { + it("AuthModal switches to sign up in place, without navigating away", async () => { const Trigger = () => { const { promptAuth } = useAuth(); useEffect(() => promptAuth(), [promptAuth]); @@ -86,10 +86,14 @@ describe("route smoke tests", () => { , ); - expect(await screen.findByRole("link", { name: "Sign up" })).toHaveAttribute( - "href", - "/signup", - ); + // A button, not a link out to a page. + const toggle = await screen.findByRole("button", { name: "Sign up" }); + expect(screen.queryByRole("link", { name: "Sign up" })).not.toBeInTheDocument(); + + fireEvent.click(toggle); + expect( + await screen.findByRole("dialog", { name: "Create your account" }), + ).toBeInTheDocument(); }); it("Settings renders the email-notification setting when signed in", async () => { @@ -108,7 +112,7 @@ describe("route smoke tests", () => { renderRoute( }> - } /> + } /> , ); From ea00af2dd0c9c09b4640f3a5589526e632dd1aa3 Mon Sep 17 00:00:00 2001 From: Yusufa09 Date: Fri, 7 Aug 2026 19:04:39 -0400 Subject: [PATCH 4/4] AI assistant only available when signed in AI assistant is unavailable without an account to prevent users abusing feature --- .../src/components/AIAssistant/AISidebar.tsx | 38 +++++++++++++++- PanTS-Demo/src/test/organStats.test.tsx | 13 +++--- PanTS-Demo/src/test/viewer.smoke.test.tsx | 13 +++--- flask-server/api/api_blueprint.py | 44 +++++++++---------- flask-server/services/plan_store.py | 15 +++++++ flask-server/tests/unit/test_plan_store.py | 21 +++++++++ 6 files changed, 111 insertions(+), 33 deletions(-) diff --git a/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx b/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx index f32be98..0c8ba48 100644 --- a/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx +++ b/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { useAuth } from "../../contexts/authContext"; import { API_BASE } from "../../helpers/constants"; import type { AIAction, @@ -15,6 +16,10 @@ import "./AISidebar.css"; // which would be refused for the same reason. class PlanLimitError extends Error {} +// Signed out (HTTP 401). The assistant needs an account, same as inference. +// Also its own type, for the same no-pointless-retry reason. +class AuthRequiredError extends Error {} + // Bumped to v2 so a previously-stored reasoning model (e.g. qwen3) is reset — // the default now prefers a non-reasoning model that never leaks "thinking". const MODEL_STORAGE_KEY = "bodymaps-ai-model-v2"; @@ -279,6 +284,10 @@ export default function AISidebar({ const [capturing, setCapturing] = useState(false); const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(""); + // The assistant runs on the server and is metered per account, so it needs a + // signed-in user — the same rule the Upload page applies to inference. + const { isAuthenticated, promptAuth } = useAuth(); + const [modelState, setModelState] = useState("loading"); const [modelMenuOpen, setModelMenuOpen] = useState(false); const [copiedId, setCopiedId] = useState(null); @@ -633,6 +642,9 @@ export default function AISidebar({ // 402 = the plan's daily message allowance is spent. Surfaced as the // assistant's own reply rather than a modal: the sidebar is a // conversation, and a dialog over it would lose the thread. + if (response.status === 401) { + throw new AuthRequiredError("Sign in to use the assistant."); + } if (response.status === 402) { const limit = await response.json().catch(() => ({})); throw new PlanLimitError(limit.message || "You've reached today's message limit."); @@ -737,6 +749,9 @@ export default function AISidebar({ signal, }); const data = await response.json(); + if (response.status === 401) { + throw new AuthRequiredError(data.reply || "Sign in to use the assistant."); + } if (response.status === 402) { throw new PlanLimitError(data.message || "You've reached today's message limit."); } @@ -758,6 +773,13 @@ export default function AISidebar({ const outgoingAttachments = attachments; if ((!text && outgoingAttachments.length === 0) || loading) return; + // Caught here as well as server-side: no point sending a request that can + // only come back 401, and the popup is the useful response either way. + if (!isAuthenticated) { + promptAuth(); + return; + } + const conversation = messages .filter((message) => message.role === "user" || message.role === "assistant") .slice(-12) @@ -835,6 +857,11 @@ export default function AISidebar({ } catch (streamError) { if (isAbort(streamError)) { // User pressed Stop — keep whatever was streamed, no error. + } else if (streamError instanceof AuthRequiredError) { + updateMessage(assistantId, (m) => ({ + ...m, content: streamError.message, status: undefined, + })); + promptAuth(); } else if (streamError instanceof PlanLimitError) { // A spent allowance is an answer, not a transport failure: retrying // on the non-streaming endpoint would just be refused again. @@ -846,7 +873,12 @@ export default function AISidebar({ try { await sendNonStreaming(assistantId, payload, controller.signal); } catch (error) { - if (error instanceof PlanLimitError) { + if (error instanceof AuthRequiredError) { + updateMessage(assistantId, (m) => ({ + ...m, content: error.message, status: undefined, + })); + promptAuth(); + } else if (error instanceof PlanLimitError) { updateMessage(assistantId, (m) => ({ ...m, content: error.message, status: undefined, })); @@ -873,6 +905,10 @@ export default function AISidebar({ attachments, loading, messages, + // Without these the guard closes over a stale auth state, and signing in + // mid-session would leave the composer still refusing to send. + isAuthenticated, + promptAuth, caseId, sessionId, availableOrgans, diff --git a/PanTS-Demo/src/test/organStats.test.tsx b/PanTS-Demo/src/test/organStats.test.tsx index c16e4d9..6933e59 100644 --- a/PanTS-Demo/src/test/organStats.test.tsx +++ b/PanTS-Demo/src/test/organStats.test.tsx @@ -1,5 +1,6 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "../contexts/authContext"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Same WebGL/three.js mocks as the viewer smoke test — jsdom has no GPU, so the @@ -146,11 +147,13 @@ afterEach(() => { const renderViewer = () => render( - - - } /> - - + + + + } /> + + + ); describe("Organ Statistics — population percentiles", () => { diff --git a/PanTS-Demo/src/test/viewer.smoke.test.tsx b/PanTS-Demo/src/test/viewer.smoke.test.tsx index e2b591e..21a16ec 100644 --- a/PanTS-Demo/src/test/viewer.smoke.test.tsx +++ b/PanTS-Demo/src/test/viewer.smoke.test.tsx @@ -1,5 +1,6 @@ import { render } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "../contexts/authContext"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // The CT viewer relies on WebGL (Niivue + Cornerstone) and a three.js loader, @@ -106,11 +107,13 @@ afterEach(() => { describe("viewer smoke test", () => { it("VisualizationPage mounts for a dataset case without crashing", () => { const { container } = render( - - - } /> - - + + + + } /> + + + ); expect(container.firstChild).toBeTruthy(); }); diff --git a/flask-server/api/api_blueprint.py b/flask-server/api/api_blueprint.py index cb6844f..bfff74e 100644 --- a/flask-server/api/api_blueprint.py +++ b/flask-server/api/api_blueprint.py @@ -3599,24 +3599,27 @@ def ai_models(): }), 200 -def _ai_quota_block(): - """402 body if the signed-in user is out of assistant messages, else None. +def _ai_gate(): + """Whether this caller may send an assistant message. None means yes. - Signed-out callers pass through unmetered — the assistant has always been - usable without an account on public dataset cases, and closing that is a - product decision rather than part of the plan work. Signed-in users are - metered against their plan's daily allowance. + Two refusals, both shaped like an assistant reply so the sidebar can render + them in the thread rather than needing a special case: + + 401 — signed out. The assistant costs real compute, so it needs an + account, the same rule inference has. Leaving it open also made the + daily allowance pointless: signing out was an unlimited tier. + 402 — signed in, but the plan's daily allowance is spent. """ user = current_user() - if user is None: - return None - blocked = plan_store.check_ai_message(user["id"]) + blocked = plan_store.check_assistant(user["id"] if user else None) if blocked is None: return None + signed_out = blocked["reason"] == "auth_required" + code = "auth_required" if signed_out else "plan_limit" return jsonify({ - "reply": blocked["message"], "actions": [], "source": "plan_limit", - "code": "plan_limit", **blocked, - }), 402 + "reply": blocked["message"], "actions": [], "source": code, + "code": code, **blocked, + }), 401 if signed_out else 402 @api_blueprint.route("/ai-command", methods=["POST"]) @@ -3642,12 +3645,11 @@ def ai_command(): } ), 400 - blocked = _ai_quota_block() + blocked = _ai_gate() if blocked is not None: return blocked - user = current_user() - if user is not None: - plan_store.record_ai_message(user["id"]) + # The gate guarantees a signed-in user past this point. + plan_store.record_ai_message(current_user()["id"]) available_organs = body.get( "available_organs" @@ -3976,14 +3978,12 @@ def ai_command_stream(): message = str(body.get("message") or "").strip() - # Checked before the stream opens, so the client gets a plain 402 it can act - # on rather than an error event buried in a 200 response body. - blocked = _ai_quota_block() + # Checked before the stream opens, so the client gets a plain 401/402 it can + # act on rather than an error event buried in a 200 response body. + blocked = _ai_gate() if blocked is not None: return blocked - _quota_user = current_user() - if _quota_user is not None: - plan_store.record_ai_message(_quota_user["id"]) + plan_store.record_ai_message(current_user()["id"]) available_organs = body.get("available_organs") or [] if not isinstance(available_organs, list): diff --git a/flask-server/services/plan_store.py b/flask-server/services/plan_store.py index e574126..a24540e 100644 --- a/flask-server/services/plan_store.py +++ b/flask-server/services/plan_store.py @@ -207,6 +207,21 @@ def check_inference(user_id: str, model: str) -> dict | None: return None +def check_assistant(user_id: str | None) -> dict | None: + """Whether this caller may send an assistant message. None means yes. + + Signed out is a refusal, not a pass: the assistant costs real compute and is + metered per account, so it needs one — and leaving it open made the daily + allowance meaningless, since signing out was an unlimited tier. + + The decision lives here rather than in the endpoint so it's testable without + importing api_blueprint, which drags in the whole nibabel/scipy stack. + """ + if user_id is None: + return {"reason": "auth_required", "message": "Sign in to use the assistant."} + return check_ai_message(user_id) + + def check_ai_message(user_id: str) -> dict | None: plan = get_plan(user_id) daily = limits_for(plan)["daily_ai_messages"] diff --git a/flask-server/tests/unit/test_plan_store.py b/flask-server/tests/unit/test_plan_store.py index 867d079..d2037f8 100644 --- a/flask-server/tests/unit/test_plan_store.py +++ b/flask-server/tests/unit/test_plan_store.py @@ -112,6 +112,27 @@ def test_postprocessing_is_gated_but_not_metered(plans): assert plan_store.usage_summary(user_id)["scans"]["used"] == 0 +def test_assistant_needs_an_account(plans): + plan_store, _ = plans + blocked = plan_store.check_assistant(None) + assert blocked is not None + assert blocked["reason"] == "auth_required" + + +def test_assistant_lets_a_signed_in_user_through(plans): + plan_store, user_id = plans + assert plan_store.check_assistant(user_id) is None + + +def test_assistant_gate_still_applies_the_daily_allowance(plans): + plan_store, user_id = plans + for _ in range(10): + plan_store.record_ai_message(user_id) + blocked = plan_store.check_assistant(user_id) + assert blocked is not None + assert blocked["reason"] == "daily_ai_messages" + + def test_assistant_allowance(plans): plan_store, user_id = plans for _ in range(10):