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 7233695..4e08e1b 100644 --- a/PanTS-Demo/src/App.tsx +++ b/PanTS-Demo/src/App.tsx @@ -17,7 +17,13 @@ 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 PrivacySettings = lazy(() => import("./routes/Settings/PrivacySettings")); +const SignupRedirect = lazy(() => import("./routes/SignupRedirect")); +const LegalPage = lazy(() => import("./routes/LegalPage")); const RotatingHeartLoader = lazy(() => import("./components/Loading")); const BASENAME = import.meta.env.VITE_BASENAME; @@ -80,9 +86,20 @@ function App() { /> } /> } /> - {/* Sign in/up is a popup, so old /login links just 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. */} + }> + } /> + } /> + } /> + } /> + + } /> + } /> } @@ -95,10 +112,11 @@ function App() { /> + {/* Global auth popup, above all routes. Inside the router so it + can link to the legal pages. */} + - {/* Global sign-in / sign-up popup, above all routes. */} - diff --git a/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx b/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx index 1f885a9..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, @@ -10,6 +11,15 @@ import type { } from "./types"; import "./AISidebar.css"; +// The plan's daily message allowance is spent (HTTP 402). Distinguished from a +// transport error so the streaming path doesn't retry on the non-streaming one, +// 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"; @@ -274,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); @@ -622,8 +636,19 @@ export default function AISidebar({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), + credentials: "include", signal, }); + // 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."); + } if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`); const reader = response.body.getReader(); @@ -720,9 +745,16 @@ 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 === 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."); + } 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); @@ -741,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) @@ -818,12 +857,32 @@ 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. + 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 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, + })); + } else if (!isAbort(error)) { console.error("[BodyMaps AI send error]", error); updateMessage(assistantId, (m) => ({ ...m, @@ -846,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/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.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 f5fbdb6..1fda64a 100644 --- a/PanTS-Demo/src/components/AuthModal.tsx +++ b/PanTS-Demo/src/components/AuthModal.tsx @@ -1,34 +1,48 @@ 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 -// 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. +// +// 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, promptAuth, closeAuthPrompt, signIn, signUp, + 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(""); 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]); + + // 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(() => { @@ -55,7 +69,6 @@ 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); @@ -72,11 +85,17 @@ const AuthModal: React.FC = () => { return (
-
e.stopPropagation()}> +
e.stopPropagation()} + > -

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

+

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

{!emailMode ? ( <> @@ -89,7 +108,7 @@ const AuthModal: React.FC = () => { onClick={() => signInWithProvider("google")} > - {isSignup ? "Sign up with Google" : "Sign in with Google"} + Continue with Google {/* Errors bounced back from the OAuth callback land here. */} @@ -120,33 +139,50 @@ const AuthModal: React.FC = () => { - {isSignup && ( - - )} {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. +

+ )} +
{isSignup ? ( - <>Already have an account?{" "} - + <> + Already have an account?{" "} + + ) : ( - <>Don't have an account?{" "} - + <> + Don't have an account?{" "} + + )}
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 63ae331..a3479a0 100644 --- a/PanTS-Demo/src/contexts/authContext.tsx +++ b/PanTS-Demo/src/contexts/authContext.tsx @@ -16,7 +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 (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, @@ -26,6 +33,13 @@ import { useState, type ReactNode, } from "react"; +import { + DEFAULT_PROFILE, + loadProfile, + updateProfile as persistProfilePatch, + type AccountProfile, + type PlanId, +} from "../helpers/accountProfile"; import { API_BASE } from "../helpers/constants"; export type AuthUser = { @@ -36,9 +50,21 @@ 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 + /** 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"; +export type AuthMode = "signin" | "signup"; type AuthContextValue = { user: AuthUser | null; @@ -46,7 +72,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 +91,19 @@ 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 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 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; @@ -106,7 +143,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 { @@ -116,6 +153,8 @@ 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, }; }; @@ -131,11 +170,11 @@ 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; 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 // 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. @@ -188,6 +227,8 @@ 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, mode: "signin" }); }, [oauthError]); @@ -209,22 +250,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,12 +296,14 @@ 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( + (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(() => { @@ -312,6 +363,46 @@ 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 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( @@ -329,6 +420,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { exportData, deleteScanHistory, deleteAccount, + updateAccountProfile, + setPlan, + usage, + refreshUsage, authPrompt, promptAuth, closeAuthPrompt, @@ -337,7 +432,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, 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 new file mode 100644 index 0000000..5fa7d42 --- /dev/null +++ b/PanTS-Demo/src/helpers/accountProfile.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + canPostprocess, + clearProfile, + DEFAULT_PROFILE, + isModelLocked, + limitsFor, + loadProfile, + maxConcurrentScans, + nextPlanUp, + persistProfile, + PLAN_LIMITS, + PLANS, + PROFILE_KEY_PREFIX, + updateProfile, +} from "./accountProfile"; + +const USER = "user-1"; + +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("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", () => { + 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(); + }); + + it("returns null when storage holds malformed JSON", () => { + localStorage.setItem(`${PROFILE_KEY_PREFIX}${USER}`, "{not json"); + expect(loadProfile(USER)).toBeNull(); + }); + + 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: "enterprise", onboardingCompletedAt: "2026-01-01", accountType: "clinician" }) + ); + expect(loadProfile(USER)).toEqual({ accountType: "clinician" }); + }); + + it("keeps profiles for different users separate", () => { + persistProfile(USER, { accountType: "clinician" }); + persistProfile("user-2", { accountType: "researcher" }); + expect(loadProfile(USER)?.accountType).toBe("clinician"); + expect(loadProfile("user-2")?.accountType).toBe("researcher"); + }); +}); + +describe("updateProfile", () => { + it("starts from the defaults when nothing is stored yet", () => { + expect(updateProfile(USER, { accountType: "clinician" })).toEqual({ + ...DEFAULT_PROFILE, + accountType: "clinician", + }); + }); + + it("persists across a reload", () => { + updateProfile(USER, { accountType: "researcher" }); + expect(loadProfile(USER)?.accountType).toBe("researcher"); + }); + + 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, { accountType: "clinician" }); + persistProfile("user-2", { accountType: "researcher" }); + clearProfile(USER); + expect(loadProfile(USER)).toBeNull(); + expect(loadProfile("user-2")?.accountType).toBe("researcher"); + }); +}); diff --git a/PanTS-Demo/src/helpers/accountProfile.ts b/PanTS-Demo/src/helpers/accountProfile.ts new file mode 100644 index 0000000..bde76f3 --- /dev/null +++ b/PanTS-Demo/src/helpers/accountProfile.ts @@ -0,0 +1,269 @@ +// Plans, what each one allows, and the one remaining client-only profile field. +// +// 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. +// +// 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"; + +/** 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 PLAN_LIMITS: Record = { + free: { + dailyScans: 3, + concurrentScans: 1, + models: ["LesionSegmenter"], + postprocessing: false, + dailyAiMessages: 10, + createReports: false, + retentionDays: 7, + priorityQueue: false, + }, + pro: { + dailyScans: 50, + concurrentScans: 5, + models: null, + postprocessing: true, + dailyAiMessages: 200, + createReports: true, + retentionDays: 365, + priorityQueue: true, + }, + team: { + dailyScans: 50, + concurrentScans: 5, + models: null, + postprocessing: true, + dailyAiMessages: 200, + createReports: true, + retentionDays: 365, + priorityQueue: true, + }, + 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; + /** + * 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; +}; + +export const PLANS: Plan[] = [ + { + id: "free", + label: "Free", + group: "individual", + blurb: "Try BodyMaps", + price: "$0", + priceNote: "per month", + pointsLead: "Includes:", + points: [ + "3 scans a day", + "LesionSegmenter model", + "Full viewer and 3D reconstruction", + "10 assistant messages a day", + ], + cta: "Start free", + }, + { + id: "pro", + label: "Pro", + group: "individual", + blurb: "For everyday clinical and research work", + price: "$1.99", + priceNote: "per month", + inherits: "free", + points: [ + "50 scans a day", + "Every model", + "5 scans at once", + "Priority in the queue", + "Create reports and annotations", + ], + cta: "Upgrade to Pro", + }, + { + id: "team", + label: "Team", + group: "team", + blurb: "For a practice or lab", + price: "$4.99", + priceNote: "per month", + badge: "2–15 members", + inherits: "pro", + // No "everything in Pro per member" bullet: the inherits line above the + // list already says it. + points: [ + "Shared case library", + "Shared annotations and reports", + "Usage pooled across the team", + "Central billing", + ], + cta: "Choose Team", + }, + { + id: "enterprise", + label: "Enterprise", + group: "team", + 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: [ + "Single sign-on", + "Signed BAA and DPA", + "On-premise processing", + "Access audit log", + "Retention set to your policy", + "PACS integration", + ], + cta: "Talk to us", + }, +]; + +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 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; + } +}; + +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); + } +}; 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 3824696..0000000 --- a/PanTS-Demo/src/routes/AccountPage.css +++ /dev/null @@ -1,273 +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); -} - -/* 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 a14ecf3..0000000 --- a/PanTS-Demo/src/routes/AccountPage.tsx +++ /dev/null @@ -1,324 +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 "./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, promptAuth, - } = useAuth(); - - const [editingName, setEditingName] = 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("signin"); - } - }, [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; - - // 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; - } - // authContext parks the "still restorable" message on the sign-in - // popup, which is where deleting drops you and where you'd act on it. - 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 && ( - - )} -
-
-
- - {/* 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/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/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/PlanSettings.tsx b/PanTS-Demo/src/routes/Settings/PlanSettings.tsx new file mode 100644 index 0000000..fd013cc --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/PlanSettings.tsx @@ -0,0 +1,187 @@ +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; + /** 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; + // 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 ( +
+ + {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

+ +
+ {([ + ["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.priceNote && {p.priceNote}} +
+ +
    + {/* 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} +
  • + ))} +
+
+ ); + })} +
+
+ + ); +}; + +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..73c42c9 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/ProfileSettings.tsx @@ -0,0 +1,142 @@ +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: 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 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, 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 : ""; + + // 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; + + // 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(next); + notify(next ? "Your name has been updated." : "Your name has been cleared."); + }); + }; + + return ( + <> +
+

Profile

+ +
+ Avatar + + {(user.name || user.email).charAt(0).toUpperCase()} + +
+ +
+ + setDraft(e.target.value)} + onBlur={commitName} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + if (e.key === "Escape") setDraft(committed); + }} + /> +
+ +
+ Email + {user.email} +
+ +
+ + +
+
+ +
+

Notifications

+ +
+ Email me when a scan finishes + +
+
+ +
+

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 + +
+
+ + ); +}; + +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..b82b4fc --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/Settings.css @@ -0,0 +1,572 @@ +/* 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 { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: 8px; + font-size: 14px; + color: #6a6a6a; + 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; +} +.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; +} + +.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; +} + +/* 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; + 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; + /* 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; + 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); +} + +/* 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; + 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 { + 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; + 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..693fd39 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/index.tsx @@ -0,0 +1,113 @@ +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"; +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. + +// 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", 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 = () => { + 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/SignupRedirect.tsx b/PanTS-Demo/src/routes/SignupRedirect.tsx new file mode 100644 index 0000000..7fd9934 --- /dev/null +++ b/PanTS-Demo/src/routes/SignupRedirect.tsx @@ -0,0 +1,21 @@ +import { useEffect } from "react"; +import { Navigate } from "react-router-dom"; +import { useAuth } from "../contexts/authContext"; + +// /signup used to be a page. Creating an account is the auth popup's signup mode +// now, so this route sends you home and opens it — old links, bookmarks and any +// external references still land on the right thing instead of a 404. +export default function SignupRedirect() { + const { isAuthenticated, loading, promptAuth } = useAuth(); + + useEffect(() => { + // Nothing to open for someone who is already signed in; the redirect + // below takes them to the app. + if (!loading && !isAuthenticated) promptAuth("signup"); + }, [loading, isAuthenticated, promptAuth]); + + // Wait for the session check before choosing a destination, so a signed-in + // visitor isn't bounced to the landing page for a frame. + if (loading) return null; + return ; +} diff --git a/PanTS-Demo/src/routes/UploadPage.css b/PanTS-Demo/src/routes/UploadPage.css index 3ebdcec..5845da4 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 85ffec5..ec2fbdc 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 { @@ -110,13 +118,22 @@ 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. - const { isAuthenticated, promptAuth } = useAuth(); + // out opens the auth popup instead of proceeding. It opens on sign-in: most + // people hitting this already have an account, and the popup switches to + // sign-up in one click for the ones who don't. + const { isAuthenticated, promptAuth, user, refreshUsage } = useAuth(); const ensureAccount = (): boolean => { if (isAuthenticated) return true; - promptAuth("signup"); + 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); @@ -241,7 +258,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)), @@ -587,11 +604,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}`); @@ -989,6 +1022,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); @@ -1381,11 +1426,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); }} @@ -1399,7 +1456,8 @@ const UploadPage: React.FC = () => {
- {selectedModel === m.id && ( + {locked && Upgrade} + {!locked && selectedModel === m.id && ( { )}
- ))} + ); + })}
)}
@@ -1494,11 +1553,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); }} @@ -1512,7 +1581,8 @@ const UploadPage: React.FC = () => {
- {postValue === opt.id && ( + {locked && Upgrade} + {!locked && postValue === opt.id && ( { )}
- ))} + ); + })} )} @@ -1621,10 +1692,10 @@ const UploadPage: React.FC = () => { {!isAuthenticated && (
- {" "} - to run inference on the server and get notified when it's done. + to run inference.
)} @@ -1636,7 +1707,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 @@ -1851,11 +1924,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..b14033e 100644 --- a/PanTS-Demo/src/test/accountPage.test.tsx +++ b/PanTS-Demo/src/test/accountPage.test.tsx @@ -1,16 +1,34 @@ 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 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 +43,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 +59,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 +82,19 @@ 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,27 +103,57 @@ 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"], + ["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(); - 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(); + it("shows the email-derived name as a placeholder, not a value to delete", async () => { + renderAt(); + const field = await screen.findByLabelText("Name"); + expect(field).toHaveValue(""); + expect(field).toHaveAttribute("placeholder", "Test User"); }); - it("saves a new name and stops calling it a guess", async () => { + it("saves on blur without an edit mode", async () => { const user = userEvent.setup(); - renderPage(); + renderAt(); - await user.click(await screen.findByRole("button", { name: "Add name" })); - await user.type(screen.getByLabelText(/Display name/i), "Ada Lovelace"); - await user.click(screen.getByRole("button", { name: "Save" })); + await user.type(await screen.findByLabelText("Name"), "Ada Lovelace"); + await user.tab(); await waitFor(() => 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(); + expect(await screen.findByText(/Your name has been updated/i)).toBeInTheDocument(); + }); + + it("does not fire a request when the field is left unchanged", async () => { + const user = userEvent.setup(); + renderAt(); + + await user.click(await screen.findByLabelText("Name")); + await user.tab(); + + expect(lastCall("PATCH", "/api/auth/me")).toBeUndefined(); }); it("surfaces a save failure instead of silently doing nothing", async () => { @@ -106,19 +167,99 @@ describe("display name", () => { return json({}); }) as unknown as typeof fetch; - renderPage(); - await user.click(await screen.findByRole("button", { name: "Add name" })); - await user.type(screen.getByLabelText(/Display name/i), "x"); - await user.click(screen.getByRole("button", { name: "Save" })); + renderAt(); + await user.type(await screen.findByLabelText("Name"), "x"); + await user.tab(); expect(await screen.findByText("Name must be text")).toBeInTheDocument(); }); + + it("keeps the notification switch on Profile rather than a page of its own", async () => { + renderAt(); + expect(await screen.findByText("Email me when a scan finishes")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Notifications" })).not.toBeInTheDocument(); + }); +}); + +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("shows each plan's price with the period it covers", async () => { + renderAt("/account/plan"); + await screen.findByRole("heading", { name: "Change plan" }); + expect(screen.getByText("$0")).toBeInTheDocument(); + expect(screen.getByText("$1.99")).toBeInTheDocument(); + expect(screen.getAllByText("per month").length).toBe(2); + }); +}); + +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 +267,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 +290,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 +329,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 +347,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 +362,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 new file mode 100644 index 0000000..295f893 --- /dev/null +++ b/PanTS-Demo/src/test/accounts.test.tsx @@ -0,0 +1,264 @@ +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 AuthModal from "../components/AuthModal"; +import { AuthProvider, useAuth } from "../contexts/authContext"; +import SignupRedirect from "../routes/SignupRedirect"; + +// Creating an account, at the component level. It's the auth popup's signup +// mode — the same card as signing in, with a different title and submit button. +// No account type, no plan picker, no terms checkbox: a new account lands on +// Free and meets its limits when it reaches them. +// +// Settings has its own suite (accountPage.test.tsx). + +const USER = { id: "u1", email: "test@example.com", plan: "free" }; + +let signedIn = false; +// What the register endpoint received, so the request can be asserted on. +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(); +}); + +/** Opens the popup straight into signup mode, the way a "Sign up" click does. */ +const OpenSignup: React.FC = () => { + const { promptAuth } = useAuth(); + return ( + + ); +}; + +const renderPopup = () => + render( + + + + + + + ); + +/** 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(); + renderPopup(); + await openEmailSignup(user); + + 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" })); + + 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 () => { + const user = userEvent.setup(); + renderPopup(); + await openEmailSignup(user); + + 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); + }); + + it("accepts the terms inline rather than as a step", async () => { + 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(); + 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, 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(); + }); + + 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; + + 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() + ); + expect(screen.getByRole("button", { name: /Continue with Google/i })).toBeEnabled(); + }); + + 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 }); + 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(); + 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" })); + + expect( + await screen.findByText("An account with that email already exists") + ).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 instead", async () => { + signedIn = true; + renderRoute(); + await waitFor(() => expect(screen.getByText("Upload page")).toBeInTheDocument()); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); 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/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 b07897e..98ddb3c 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, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } 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 ProfileSettings from "../routes/Settings/ProfileSettings"; import LandingPage from "../routes/LandingPage"; import Homepage from "../routes/Homepage"; import UploadPage from "../routes/UploadPage"; @@ -67,20 +68,43 @@ 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(); + expect(await screen.findByText("Continue with Google")).toBeInTheDocument(); + expect(screen.getByText("Continue with GitHub")).toBeInTheDocument(); }); - it("AccountPage renders the email-notification setting when signed in", async () => { + it("AuthModal switches to sign up in place, without navigating away", async () => { + const Trigger = () => { + const { promptAuth } = useAuth(); + useEffect(() => promptAuth(), [promptAuth]); + return null; + }; + renderRoute( + <> + + + , + ); + // 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 () => { // 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) => ({ @@ -93,9 +117,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/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/.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 f572548..8813b99 100644 --- a/flask-server/api/api_blueprint.py +++ b/flask-server/api/api_blueprint.py @@ -68,6 +68,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(): @@ -969,6 +971,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): @@ -1013,6 +1023,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) @@ -1036,6 +1059,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. @@ -3700,6 +3727,29 @@ def ai_models(): }), 200 +def _ai_gate(): + """Whether this caller may send an assistant message. None means yes. + + 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() + 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": code, + "code": code, **blocked, + }), 401 if signed_out else 402 + + @api_blueprint.route("/ai-command", methods=["POST"]) def ai_command(): try: @@ -3723,6 +3773,12 @@ def ai_command(): } ), 400 + blocked = _ai_gate() + if blocked is not None: + return blocked + # The gate guarantees a signed-in user past this point. + plan_store.record_ai_message(current_user()["id"]) + available_organs = body.get( "available_organs" ) or [] @@ -4050,6 +4106,13 @@ def ai_command_stream(): message = str(body.get("message") or "").strip() + # 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 + plan_store.record_ai_message(current_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..a24540e --- /dev/null +++ b/flask-server/services/plan_store.py @@ -0,0 +1,309 @@ +"""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_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"] + 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..d2037f8 --- /dev/null +++ b/flask-server/tests/unit/test_plan_store.py @@ -0,0 +1,164 @@ +"""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_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): + 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 + }