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 (
-