Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions PanTS-Demo/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ Pants/ProfileTr/*


.vercel

# Locally generated preview assets (thumbnails + meshes), ~157MB. Built from
# the dataset, not source — regenerate rather than commit.
public/thumbs/
public/meshes/
10 changes: 10 additions & 0 deletions PanTS-Demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { lazy, Suspense } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router";
import "./App.css";
import AnalyticsRouteTracker from "./components/AnalyticsRouteTracker";
import AuthModal from "./components/AuthModal";
import { AnnotationProvider } from "./contexts/annotationContexts";
import { AuthProvider } from "./contexts/authContext";
Expand All @@ -22,6 +23,10 @@ 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"));
// Admin-only sections: split out so the charts and the account list stay out of
// everyone else's bundle.
const AnalyticsSettings = lazy(() => import("./routes/Settings/AnalyticsSettings"));
const PeopleSettings = lazy(() => import("./routes/Settings/PeopleSettings"));
const SignupRedirect = lazy(() => import("./routes/SignupRedirect"));
const LegalPage = lazy(() => import("./routes/LegalPage"));
const RotatingHeartLoader = lazy(() => import("./components/Loading"));
Expand Down Expand Up @@ -62,6 +67,7 @@ function App() {
<AnnotationProvider>
<div className="App">
<BrowserRouter basename={BASENAME}>
<AnalyticsRouteTracker />
<ScrollToTopButton />
<Suspense fallback={<RouteFallback />}>
<Routes>
Expand Down Expand Up @@ -97,6 +103,10 @@ function App() {
<Route path="plan" element={<PlanSettings />} />
<Route path="history" element={<HistorySettings />} />
<Route path="privacy" element={<PrivacySettings />} />
{/* Admin-only. Both check the role themselves and the API
refuses either way — the nav just doesn't offer them. */}
<Route path="analytics" element={<AnalyticsSettings />} />
<Route path="people" element={<PeopleSettings />} />
</Route>
<Route path="/terms" element={<LegalPage kind="terms" />} />
<Route path="/privacy" element={<LegalPage kind="privacy" />} />
Expand Down
2 changes: 2 additions & 0 deletions PanTS-Demo/src/components/AIAssistant/AISidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useAuth } from "../../contexts/authContext";
import { track } from "../../helpers/analytics";
import { API_BASE } from "../../helpers/constants";
import type {
AIAction,
Expand Down Expand Up @@ -785,6 +786,7 @@ export default function AISidebar({
.slice(-12)
.map((message) => ({ role: message.role, content: message.content }));

track("assistant_send_message");
setInput("");
setAttachments([]);
if (textareaRef.current) textareaRef.current.style.height = "auto";
Expand Down
57 changes: 57 additions & 0 deletions PanTS-Demo/src/components/AnalyticsRouteTracker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { flush, routePattern, trackPageView } from "../helpers/analytics";

// Turns navigation into "time spent here" numbers. Mounted once, inside the
// router, renders nothing.
//
// Time is only counted while the tab is actually in front. Without that, a tab
// left open overnight would report the viewer as the most-used feature in the
// product by a wide margin, which is true of the tab and false of the person.
const AnalyticsRouteTracker: React.FC = () => {
const { pathname } = useLocation();
// Held in refs, not state: this component must never re-render anything.
const route = useRef<string | null>(null);
const since = useRef<number>(Date.now());

useEffect(() => {
// Close out the previous route before opening the new one.
const record = () => {
if (route.current) trackPageView(route.current, Date.now() - since.current);
};

record();
route.current = routePattern(pathname);
since.current = Date.now();

return record;
// Only on a route change: the effect's whole job is the transition.
}, [pathname]);

useEffect(() => {
const onHidden = () => {
if (document.visibilityState !== "hidden") {
// Back in front: start a fresh stretch rather than counting the
// time the tab spent in the background.
since.current = Date.now();
return;
}
if (route.current) trackPageView(route.current, Date.now() - since.current);
since.current = Date.now();
// The tab may not come back — get what we have to the server now.
flush(true);
};

document.addEventListener("visibilitychange", onHidden);
// pagehide rather than unload: unload doesn't fire on mobile Safari.
window.addEventListener("pagehide", onHidden);
return () => {
document.removeEventListener("visibilitychange", onHidden);
window.removeEventListener("pagehide", onHidden);
};
}, []);

return null;
};

export default AnalyticsRouteTracker;
3 changes: 3 additions & 0 deletions PanTS-Demo/src/components/AuthModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../contexts/authContext";
import { track } from "../helpers/analytics";
import "./AuthModal.css";

// The one auth popup: signing in and creating an account are the same card with
Expand Down Expand Up @@ -61,7 +62,7 @@
const onKey = (e: KeyboardEvent) => e.key === "Escape" && dismiss();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [authPrompt.open, closeAuthPrompt]);

Check warning on line 65 in PanTS-Demo/src/components/AuthModal.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 22)

React Hook useEffect has a missing dependency: 'dismiss'. Either include it or remove the dependency array

Check warning on line 65 in PanTS-Demo/src/components/AuthModal.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 20)

React Hook useEffect has a missing dependency: 'dismiss'. Either include it or remove the dependency array

if (!authPrompt.open) return null;

Expand All @@ -73,6 +74,8 @@
try {
if (isSignup) await signUp(email, password);
else await signIn(email, password);
// After the await: this counts successful sign-ins, not attempts.
track(isSignup ? "auth_sign_up" : "auth_sign_in");
// authContext auto-closes the popup once the user is set.
} catch (err) {
// Surface the API's message ("Invalid email or password", "An account
Expand Down
12 changes: 9 additions & 3 deletions PanTS-Demo/src/components/UpgradeDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { nextPlanUp, planLabel, PLANS, type PlanId } from "../helpers/accountProfile";
import { track } from "../helpers/analytics";
import "./UpgradeDialog.css";

// The one place a plan limit is explained. Every blocker — a locked model, a
Expand Down Expand Up @@ -75,7 +76,7 @@ const detail = (b: UpgradeBlock): string => {
? `The ${planLabel(b.plan)} plan includes ${b.limit} messages a day. More ${reset}.`
: `The ${planLabel(b.plan)} plan includes ${b.limit} messages a day.`;
case "concurrent_scans":
return "Wait for the current scan to finish, or upgrade to run several at once.";
return "Wait for the current scan to finish, or donate to run several at once.";
case "model_locked":
return `${planLabel(b.plan)} includes LesionSegmenter. Every other model is on Pro.`;
case "postprocessing":
Expand All @@ -93,6 +94,7 @@ const UpgradeDialog: React.FC<{ block: UpgradeBlock | null; onClose: () => void

useEffect(() => {
if (!block) return;
track("plan_limit_hit");
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
Expand Down Expand Up @@ -133,9 +135,13 @@ const UpgradeDialog: React.FC<{ block: UpgradeBlock | null; onClose: () => void
<button
type="button"
className="upg-primary"
onClick={() => { onClose(); navigate("/account/plan"); }}
onClick={() => {
track("plan_limit_dialog_cta");
onClose();
navigate("/account/plan");
}}
>
See plans
See donation options
</button>
)}
<button type="button" className="upg-secondary" onClick={onClose}>
Expand Down
65 changes: 40 additions & 25 deletions PanTS-Demo/src/contexts/authContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@
// 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.
//
// Account type is real too: user_account.account_type, set through PATCH
// /auth/me. It still gates nothing — it exists so activity can be grouped by it
// — but it is no longer a localStorage value the server has never seen.
//
// 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,
Expand All @@ -34,12 +35,11 @@
type ReactNode,
} from "react";
import {
DEFAULT_PROFILE,
loadProfile,
updateProfile as persistProfilePatch,
type AccountProfile,
type AccountType,
type PlanId,
} from "../helpers/accountProfile";
import { track } from "../helpers/analytics";
import { API_BASE } from "../helpers/constants";

export type AuthUser = {
Expand All @@ -52,8 +52,11 @@
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. */
/** Self-reported account type, from user_account.account_type. Gates nothing. */
profile: AccountProfile;
/** Roles held, from the user_role table ("admin", "annotator"). The server
* enforces them; these only decide what the UI offers to draw. */
roles: string[];
};

/** What GET /me/usage returns: the plan's limits and what's been used of them. */
Expand Down Expand Up @@ -91,8 +94,8 @@
* in — resolves with the deadline and the grace period, so the UI can say so.
*/
deleteAccount: () => Promise<{ restoreBy: string; graceDays: number }>;
/** Patch the self-reported account type. */
updateAccountProfile: (patch: Partial<AccountProfile>) => void;
/** Patch the self-reported account type. Persisted server-side. */
updateAccountProfile: (patch: Partial<AccountProfile>) => Promise<void>;
/** Move to another plan. No payment step — pricing isn't set. */
setPlan: (plan: PlanId) => Promise<void>;
/** Current plan usage, or null until loaded. Refreshed by refreshUsage(). */
Expand Down Expand Up @@ -143,7 +146,14 @@
}
};

type ApiUser = { id: string; email: string; name?: string | null; plan?: string | null };
type ApiUser = {
id: string;
email: string;
name?: string | null;
plan?: string | null;
account_type?: string | null;
roles?: string[] | null;
};
const mapApiUser = (u: ApiUser): AuthUser => {
const custom = (u.name || "").trim();
return {
Expand All @@ -154,7 +164,10 @@
hasCustomName: custom.length > 0,
emailNotifications: loadPref(u.id),
plan: (u.plan as PlanId) || "free",
profile: loadProfile(u.id) ?? DEFAULT_PROFILE,
profile: { accountType: (u.account_type as AccountType) || null },
// Defaulted, never invented: an endpoint that forgets to send roles
// leaves you with none, which fails closed.
roles: u.roles ?? [],
};
};

Expand Down Expand Up @@ -286,6 +299,7 @@
}, []);

const signOut = useCallback(async () => {
track("auth_sign_out");
// Clear locally first so the UI updates instantly, then revoke server-side.
setUser(null);
pingOtherTabs();
Expand All @@ -296,10 +310,10 @@
}
}, []);

const promptAuth = useCallback(
(mode: AuthMode = "signin") => setAuthPrompt({ open: true, mode }),
[]
);
const promptAuth = useCallback((mode: AuthMode = "signin") => {
track("auth_open_modal");
setAuthPrompt({ open: true, mode });
}, []);
const closeAuthPrompt = useCallback(
() => setAuthPrompt((p) => ({ ...p, open: false })),
[]
Expand Down Expand Up @@ -363,16 +377,17 @@
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<AccountProfile>) => {
if (!user) return;
const profile = persistProfilePatch(user.id, patch);
setUser((prev) => (prev ? { ...prev, profile } : prev));
},
[user]
);
const updateAccountProfile = useCallback(async (patch: Partial<AccountProfile>) => {
if (!("accountType" in patch)) return;
const res = await authFetch("/api/auth/me", {
method: "PATCH",
// "" clears it: the server reads an empty string as "not set".
body: JSON.stringify({ account_type: patch.accountType ?? "" }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Couldn't save your role. Try again.");
setUser(mapApiUser(data.user));
}, []);

const refreshUsage = useCallback(async () => {
if (!user) {
Expand Down Expand Up @@ -439,7 +454,7 @@
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth(): AuthContextValue {

Check failure on line 457 in PanTS-Demo/src/contexts/authContext.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 22)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

Check failure on line 457 in PanTS-Demo/src/contexts/authContext.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 20)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within an AuthProvider");
return ctx;
Expand Down
70 changes: 1 addition & 69 deletions PanTS-Demo/src/helpers/accountProfile.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
import { beforeEach, describe, expect, it } from "vitest";
import { 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"]);
Expand Down Expand Up @@ -104,59 +92,3 @@ describe("plan cards", () => {
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");
});
});
Loading
Loading