-
+
{
{[
- "Privacy Policy",
- "Terms of Service",
- "Compliance",
- "Support",
- ].map((label) => (
-
(
+
- {label}
-
+ {link.label}
+
))}
@@ -847,6 +892,14 @@ const LandingPage = () => {
export const IndexPage = () => {
const accessToken = useAuthStore((state) => state.accessToken);
+ const role = useAuthStore((state) => state.user?.role);
+ if (accessToken && role === "SUPER_ADMIN") {
+ return (
+
+
+
+ );
+ }
if (accessToken) return
;
return
;
};
diff --git a/client/src/features/landing/route.tsx b/client/src/features/landing/route.tsx
index d733307..f058988 100644
--- a/client/src/features/landing/route.tsx
+++ b/client/src/features/landing/route.tsx
@@ -1,9 +1,18 @@
-import { createRoute } from "@tanstack/react-router";
+import { createRoute, redirect } from "@tanstack/react-router";
import { rootRoute } from "../../rootRoute";
+import { useAuthStore } from "../../store/auth.store";
import { IndexPage } from ".";
export const AppLayoutRoute = createRoute({
getParentRoute: () => rootRoute,
id: "app-layout",
+ beforeLoad: ({ location }) => {
+ if (
+ useAuthStore.getState().user?.role === "SUPER_ADMIN" &&
+ location.pathname !== "/"
+ ) {
+ throw redirect({ to: "/" });
+ }
+ },
component: IndexPage
});
\ No newline at end of file
diff --git a/client/src/features/legal/Compliance.tsx b/client/src/features/legal/Compliance.tsx
new file mode 100644
index 0000000..4255252
--- /dev/null
+++ b/client/src/features/legal/Compliance.tsx
@@ -0,0 +1,158 @@
+import { LegalLayout } from "./components/LegalLayout";
+import { MI } from "../../components/logo";
+
+const CERTIFICATIONS = [
+ {
+ icon: "health_and_safety",
+ title: "HIPAA Aligned",
+ desc: "Safeguards modeled on the HIPAA Security and Privacy Rules for handling PHI.",
+ },
+ {
+ icon: "public",
+ title: "GDPR Ready",
+ desc: "Lawful processing, data-subject rights, and EU data-transfer protections.",
+ },
+ {
+ icon: "verified_user",
+ title: "SOC 2 Practices",
+ desc: "Security, availability, and confidentiality controls across our infrastructure.",
+ },
+ {
+ icon: "lock",
+ title: "AES-256 Encryption",
+ desc: "Data encrypted at rest and in transit with industry-standard ciphers.",
+ },
+];
+
+const SECTIONS = [
+ {
+ heading: "Our Commitment to Compliance",
+ body: (
+
+ TumorLens handles some of the most sensitive data in medicine. Compliance is not
+ an afterthought — it is built into how the platform is designed, operated, and
+ audited. This page summarizes the regulatory frameworks we align with and the
+ controls we maintain to protect patients and the professionals who care for them.
+
+ ),
+ },
+ {
+ heading: "Healthcare Data Protection (HIPAA)",
+ body: (
+
+ For customers subject to U.S. healthcare regulation, we apply administrative,
+ physical, and technical safeguards consistent with the HIPAA Security Rule. This
+ includes role-based access control, encryption of Protected Health Information,
+ tamper-evident audit logging, and Business Associate Agreements (BAAs) available
+ to eligible organizations.
+
+ ),
+ },
+ {
+ heading: "Data Privacy (GDPR & Beyond)",
+ body: (
+
+ We support the rights granted under the EU General Data Protection Regulation and
+ comparable privacy laws, including the rights to access, rectification, erasure,
+ and portability. We process personal data on a lawful basis, minimize what we
+ collect, and apply appropriate safeguards to any cross-border data transfers.
+
+ ),
+ },
+ {
+ heading: "Infrastructure & Security Controls",
+ body: (
+
+ - End-to-end encryption (TLS in transit, AES-256 at rest).
+ - Multi-factor authentication and short-lived session tokens.
+ - Least-privilege access with per-organization isolation.
+ - Immutable audit logs for every access to clinical records.
+ - Continuous monitoring and regular security assessments.
+
+ ),
+ },
+ {
+ heading: "Auditability & Accountability",
+ body: (
+
+ Every meaningful action — scan uploads, report access, membership changes — is
+ recorded in an audit log scoped to your organization. Administrators can review
+ this activity at any time, providing the accountability and traceability required
+ by healthcare oversight bodies and internal governance teams.
+
+ ),
+ },
+ {
+ heading: "Intended Use & Regulatory Status",
+ body: (
+
+ TumorLens is intended as a clinical decision-support tool to assist qualified
+ medical professionals. It is not a substitute for professional diagnosis. Where
+ medical-device regulation applies in your jurisdiction, it is the responsibility
+ of the deploying organization to ensure the tool is used within the bounds of
+ applicable law and local clinical governance.
+
+ ),
+ },
+ {
+ heading: "Reporting a Concern",
+ body: (
+
+ If you believe you have found a security vulnerability or a compliance issue, we
+ want to hear from you. Contact our security team at{" "}
+
+ security@tumorlens.com
+
+ . We investigate every report and respond promptly to legitimate concerns.
+
+ ),
+ },
+];
+
+export const CompliancePage = () => (
+
+
+
+ Standards We Align With
+
+
+ {CERTIFICATIONS.map((cert) => (
+
+
+
+
+
+
+ {cert.title}
+
+
{cert.desc}
+
+
+ ))}
+
+
+
+);
diff --git a/client/src/features/legal/PrivacyPolicy.tsx b/client/src/features/legal/PrivacyPolicy.tsx
new file mode 100644
index 0000000..73e44fc
--- /dev/null
+++ b/client/src/features/legal/PrivacyPolicy.tsx
@@ -0,0 +1,145 @@
+import { LegalLayout, type LegalSection } from "./components/LegalLayout";
+
+const SECTIONS: LegalSection[] = [
+ {
+ heading: "Introduction",
+ body: (
+
+ TumorLens (“we”, “us”, or “our”) provides AI-assisted
+ brain tumor detection and clinical reporting tools for radiologists,
+ clinicians, and healthcare organizations. This Privacy Policy explains
+ what information we collect, how we use it, and the rights you have over
+ your data. Because TumorLens processes sensitive medical information, we
+ hold ourselves to strict privacy and security standards.
+
+ ),
+ },
+ {
+ heading: "Information We Collect",
+ body: (
+ <>
+
We collect the following categories of information:
+
+ -
+ Account data — your name, email address,
+ professional role, and organization details when you register.
+
+ -
+ Clinical data — MRI scans, patient identifiers,
+ and the diagnostic results generated for each scan you upload.
+
+ -
+ Usage data — log information such as device, browser,
+ IP address, and the actions you take within the platform.
+
+ -
+ Billing data — subscription tier and payment
+ metadata processed securely through our payment provider, Stripe.
+
+
+ >
+ ),
+ },
+ {
+ heading: "How We Use Your Information",
+ body: (
+
+ - To run AI analysis on uploaded scans and generate clinical reports.
+ - To authenticate you and secure access to your account and records.
+ - To maintain audit logs required for compliance and accountability.
+ - To process subscriptions and deliver the features of your plan.
+ - To notify you of results, account activity, and service updates.
+ - To improve the accuracy, reliability, and safety of our models.
+
+ ),
+ },
+ {
+ heading: "Protected Health Information",
+ body: (
+
+ Patient scans and identifiers are treated as Protected Health Information
+ (PHI). We process PHI solely to deliver the diagnostic service you request.
+ We never sell PHI, and we do not use identifiable patient data to train
+ models without a separate, explicit agreement. Access to PHI is restricted
+ to authorized members of your organization and logged in tamper-evident
+ audit trails.
+
+ ),
+ },
+ {
+ heading: "Data Storage & Security",
+ body: (
+
+ All data is encrypted in transit (TLS) and at rest using AES-256. Scans and
+ reports are stored in access-controlled, S3-compatible object storage.
+ Authentication uses short-lived access tokens with HTTP-only refresh cookies,
+ and optional two-factor authentication (TOTP). We apply the principle of least
+ privilege across our infrastructure and continuously monitor for unauthorized
+ access.
+
+ ),
+ },
+ {
+ heading: "Data Sharing",
+ body: (
+ <>
+
+ We do not sell your personal or clinical data. We share information only
+ with:
+
+
+ - Members of your own organization, as governed by your access controls.
+ -
+ Sub-processors who help operate the service (cloud hosting, payment
+ processing, transactional email) under strict data-protection terms.
+
+ - Authorities where required by law or to protect patient safety.
+
+ >
+ ),
+ },
+ {
+ heading: "Data Retention",
+ body: (
+
+ We retain clinical records for as long as your account is active or as required
+ to meet legal and regulatory obligations. You may request deletion of your data
+ at any time; we will honor such requests except where retention is legally
+ mandated. Audit logs may be retained separately to preserve their integrity.
+
+ ),
+ },
+ {
+ heading: "Your Rights",
+ body: (
+
+ Depending on your jurisdiction, you may have the right to access, correct,
+ export, or delete your personal data, and to object to or restrict certain
+ processing. To exercise any of these rights, contact us using the details
+ below and we will respond within the timeframe required by applicable law.
+
+ ),
+ },
+ {
+ heading: "Changes to This Policy",
+ body: (
+
+ We may update this Privacy Policy as our service evolves or as regulations
+ change. When we make material changes, we will notify you through the platform
+ or by email. Continued use of TumorLens after an update constitutes acceptance
+ of the revised policy.
+
+ ),
+ },
+];
+
+export const PrivacyPolicyPage = () => (
+
+);
diff --git a/client/src/features/legal/Support.tsx b/client/src/features/legal/Support.tsx
new file mode 100644
index 0000000..50edb8b
--- /dev/null
+++ b/client/src/features/legal/Support.tsx
@@ -0,0 +1,180 @@
+import { Link } from "@tanstack/react-router";
+import { LegalLayout, type LegalSection } from "./components/LegalLayout";
+import { MI } from "../../components/logo";
+
+const CHANNELS = [
+ {
+ icon: "mail",
+ title: "Email Support",
+ desc: "Get help with your account, billing, or scans.",
+ action: "support@tumorlens.com",
+ href: "mailto:support@tumorlens.com",
+ },
+ {
+ icon: "shield",
+ title: "Security & Privacy",
+ desc: "Report a vulnerability or ask about compliance.",
+ action: "security@tumorlens.com",
+ href: "mailto:security@tumorlens.com",
+ },
+ {
+ icon: "apartment",
+ title: "Enterprise & Sales",
+ desc: "Plans for hospitals, clinics, and large teams.",
+ action: "sales@tumorlens.com",
+ href: "mailto:sales@tumorlens.com",
+ },
+];
+
+const FAQS = [
+ {
+ q: "How do I upload an MRI scan?",
+ a: "Once signed in, open the dashboard and use “Upload Scan”. You can drag and drop a DICOM or standard image file, and analysis begins automatically.",
+ },
+ {
+ q: "How accurate is the AI detection?",
+ a: "TumorLens reports a confidence breakdown for every classification. It is a decision-support tool and should always be reviewed by a qualified clinician — it is not a substitute for professional diagnosis.",
+ },
+ {
+ q: "Is my patient data secure?",
+ a: "Yes. All data is encrypted in transit and at rest with AES-256, access is role-based and audited, and we never sell clinical data. See our Compliance and Privacy Policy pages for details.",
+ },
+ {
+ q: "How do I upgrade or cancel my plan?",
+ a: "Plans are managed from your account settings. You can upgrade at any time, and cancellations take effect at the end of your current billing period.",
+ },
+ {
+ q: "Can my whole organization use one account?",
+ a: "The Organization plan supports multiple members, role-based permissions, shared patient records, and audit logs across your team.",
+ },
+];
+
+const SECTIONS: LegalSection[] = [
+ {
+ heading: "Getting Started",
+ body: (
+
+ New to TumorLens? Create an account, sign in, and head to your dashboard to
+ upload your first MRI scan. The platform handles analysis and report generation
+ automatically — most scans return results in seconds. If you run into anything
+ along the way, the channels below will get you to a real person.
+
+ ),
+ },
+ {
+ heading: "Response Times",
+ body: (
+
+ We aim to respond to all support requests within one business day. Security
+ reports and issues affecting patient-facing functionality are prioritized.
+ Organization and Enterprise customers receive expedited support as part of their
+ plan.
+
+ ),
+ },
+];
+
+export const SupportPage = () => (
+
+ {/* Contact channels */}
+
+
+ Contact Channels
+
+
+
+
+ {/* FAQ */}
+
+
+ Frequently Asked Questions
+
+
+ {FAQS.map((faq) => (
+
+
+
+ {faq.q}
+
+
+
+
+ {faq.a}
+
+
+ ))}
+
+
+
+ {/* In-app help pointer */}
+
+
+ Already have an account? You can also reach support directly from the{" "}
+
+ in-app Help & Support center
+ {" "}
+ after signing in.
+
+
+
+);
diff --git a/client/src/features/legal/TermsOfService.tsx b/client/src/features/legal/TermsOfService.tsx
new file mode 100644
index 0000000..a1c4dae
--- /dev/null
+++ b/client/src/features/legal/TermsOfService.tsx
@@ -0,0 +1,137 @@
+import { LegalLayout, type LegalSection } from "./components/LegalLayout";
+
+const SECTIONS: LegalSection[] = [
+ {
+ heading: "Acceptance of Terms",
+ body: (
+
+ These Terms of Service (“Terms”) govern your access to and use of
+ TumorLens. By creating an account or using the platform, you agree to be bound
+ by these Terms. If you are using TumorLens on behalf of an organization, you
+ represent that you have authority to bind that organization to these Terms.
+
+ ),
+ },
+ {
+ heading: "Clinical Decision-Support Only",
+ body: (
+
+ TumorLens is a decision-support tool. Its AI outputs, classifications, and
+ generated reports are intended to assist qualified medical professionals and are
+ not a substitute for professional diagnosis, clinical judgment,
+ or direct patient care. You are responsible for independently reviewing all
+ results and for any clinical decision made using the platform.
+
+ ),
+ },
+ {
+ heading: "Eligibility & Accounts",
+ body: (
+
+ You must provide accurate registration information and keep your credentials
+ secure. You are responsible for all activity that occurs under your account.
+ Notify us immediately of any unauthorized access. We may suspend or terminate
+ accounts that violate these Terms or that pose a risk to patient safety or
+ platform integrity.
+
+ ),
+ },
+ {
+ heading: "Acceptable Use",
+ body: (
+ <>
+
You agree not to:
+
+ - Upload data you are not legally authorized to process.
+ - Reverse-engineer, scrape, or interfere with the platform or its models.
+ - Use the service to provide unsupervised, autonomous diagnosis.
+ - Share access credentials or circumvent plan and access controls.
+ - Attempt to access data belonging to other users or organizations.
+
+ >
+ ),
+ },
+ {
+ heading: "Subscriptions & Billing",
+ body: (
+
+ Paid plans are billed in advance on a recurring basis through our payment
+ provider. Fees are non-refundable except where required by law. You may cancel
+ at any time; cancellation takes effect at the end of the current billing period.
+ We may change pricing with reasonable advance notice. Failure to pay may result
+ in suspension of paid features.
+
+ ),
+ },
+ {
+ heading: "Intellectual Property",
+ body: (
+
+ TumorLens, including its software, models, and branding, is owned by us and
+ protected by intellectual property laws. You retain all rights to the clinical
+ data and scans you upload. You grant us a limited license to process that data
+ solely to provide the service. We grant you a non-exclusive, non-transferable
+ right to use the platform in accordance with these Terms.
+
+ ),
+ },
+ {
+ heading: "Disclaimer of Warranties",
+ body: (
+
+ The platform is provided “as is” and “as available”
+ without warranties of any kind, whether express or implied, including
+ merchantability, fitness for a particular purpose, and non-infringement. We do
+ not warrant that AI outputs will be error-free or that the service will be
+ uninterrupted. Medical accuracy remains the responsibility of the treating
+ clinician.
+
+ ),
+ },
+ {
+ heading: "Limitation of Liability",
+ body: (
+
+ To the maximum extent permitted by law, TumorLens shall not be liable for any
+ indirect, incidental, special, or consequential damages, or for any clinical
+ decision made in reliance on the platform. Our total aggregate liability arising
+ from the service shall not exceed the amount you paid to us in the twelve months
+ preceding the claim.
+
+ ),
+ },
+ {
+ heading: "Termination",
+ body: (
+
+ You may stop using TumorLens at any time. We may suspend or terminate your access
+ if you breach these Terms, if required by law, or to protect the platform and its
+ users. Upon termination, your right to use the service ends, though certain
+ provisions — including those on liability, intellectual property, and data
+ retention — survive.
+
+ ),
+ },
+ {
+ heading: "Changes to These Terms",
+ body: (
+
+ We may revise these Terms from time to time. When changes are material, we will
+ provide notice through the platform or by email. Your continued use of TumorLens
+ after the effective date of the revised Terms constitutes acceptance of those
+ changes.
+
+ ),
+ },
+];
+
+export const TermsOfServicePage = () => (
+
+);
diff --git a/client/src/features/legal/components/LegalLayout.tsx b/client/src/features/legal/components/LegalLayout.tsx
new file mode 100644
index 0000000..b5a53f0
--- /dev/null
+++ b/client/src/features/legal/components/LegalLayout.tsx
@@ -0,0 +1,250 @@
+import { Link } from "@tanstack/react-router";
+import { useEffect, useState, type ReactNode } from "react";
+import Logo from "../../../components/logo";
+import { MI } from "../../../components/logo";
+
+export type LegalSection = {
+ heading: string;
+ body: ReactNode;
+};
+
+const FOOTER_LINKS = [
+ { label: "Privacy Policy", to: "/privacy-policy" },
+ { label: "Terms of Service", to: "/terms-of-service" },
+ { label: "Compliance", to: "/compliance" },
+ { label: "Support", to: "/support" },
+] as const;
+
+type LegalLayoutProps = {
+ icon: string;
+ eyebrow: string;
+ title: string;
+ intro: string;
+ lastUpdated?: string;
+ sections: LegalSection[];
+ children?: ReactNode;
+};
+
+export const LegalLayout = ({
+ icon,
+ eyebrow,
+ title,
+ intro,
+ lastUpdated,
+ sections,
+ children,
+}: LegalLayoutProps) => {
+ const [scrolled, setScrolled] = useState(false);
+
+ useEffect(() => {
+ const onScroll = () => setScrolled(window.scrollY > 10);
+ window.addEventListener("scroll", onScroll);
+ return () => window.removeEventListener("scroll", onScroll);
+ }, []);
+
+ useEffect(() => {
+ window.scrollTo({ top: 0 });
+ }, []);
+
+ return (
+
+ {/* ════════════ NAVBAR ════════════ */}
+
+
+
+
+
+
+ {/* ════════════ HERO ════════════ */}
+
+
+
+
+
+ {eyebrow}
+
+
+ {title}
+
+
+ {intro}
+
+ {lastUpdated && (
+
+ Last updated · {lastUpdated}
+
+ )}
+
+
+
+
+ {/* ════════════ BODY ════════════ */}
+
+
+
+
+ {sections.map((section, idx) => (
+
+
+
+ {String(idx + 1).padStart(2, "0")}
+
+
+ {section.heading}
+
+
+
+ {section.body}
+
+
+ ))}
+
+
+ {children}
+
+
+ {/* Contact strip */}
+
+
+
+ Contact Support
+
+
+
+
+
+ {/* ════════════ FOOTER ════════════ */}
+
+
+ );
+};
diff --git a/client/src/features/legal/route.ts b/client/src/features/legal/route.ts
new file mode 100644
index 0000000..4672809
--- /dev/null
+++ b/client/src/features/legal/route.ts
@@ -0,0 +1,30 @@
+import { createRoute } from "@tanstack/react-router";
+import { rootRoute } from "../../rootRoute";
+import { PrivacyPolicyPage } from "./PrivacyPolicy";
+import { TermsOfServicePage } from "./TermsOfService";
+import { CompliancePage } from "./Compliance";
+import { SupportPage } from "./Support";
+
+export const PrivacyPolicyRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/privacy-policy",
+ component: PrivacyPolicyPage,
+});
+
+export const TermsOfServiceRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/terms-of-service",
+ component: TermsOfServicePage,
+});
+
+export const ComplianceRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/compliance",
+ component: CompliancePage,
+});
+
+export const SupportRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/support",
+ component: SupportPage,
+});
diff --git a/client/src/features/superadmin/api/superadmin.api.ts b/client/src/features/superadmin/api/superadmin.api.ts
new file mode 100644
index 0000000..29ba3d2
--- /dev/null
+++ b/client/src/features/superadmin/api/superadmin.api.ts
@@ -0,0 +1,327 @@
+import axios from "../../../lib/axios";
+
+export type OverviewResponse = {
+ totalUsers: number;
+ usersDelta7d: number;
+ activeSubscriptions: { doctor: number; org: number };
+ scansProcessed: { today: number; last7d: number; last30d: number };
+ reportsGenerated: { today: number; last7d: number; last30d: number };
+ mrr: number;
+ openTicketsCount: number;
+ signups30d: { date: string; count: number }[];
+ scansPerDay30d: { date: string; count: number }[];
+ planDistribution: { free: number; doctor: number; org: number };
+ recentSignups: Array<{
+ id: string;
+ username: string;
+ email: string;
+ plan: string;
+ role: string;
+ profile_image_url: string | null;
+ createdAt: string;
+ }>;
+ recentTickets: Array<{
+ id: string;
+ code: string;
+ type: string;
+ status: string;
+ priority: string;
+ reason: string;
+ createdAt: string;
+ submittedBy: { id: string; username: string; email: string };
+ }>;
+};
+
+export type UserListItem = {
+ id: string;
+ username: string;
+ email: string;
+ role: string;
+ plan: string;
+ is_active: boolean;
+ profile_image_url: string | null;
+ total_scans: number;
+ total_reports: number;
+ createdAt: string;
+ ownedOrg: { id: string; name: string } | null;
+ orgMember: { id: string; orgId: string } | null;
+};
+
+export type Paginated
= { items: T[]; total: number; page: number; pageSize: number };
+
+export type UserProfile = {
+ id: string;
+ username: string;
+ email: string;
+ role: string;
+ plan: string;
+ is_active: boolean;
+ profile_image_url: string | null;
+ total_scans: number;
+ total_reports: number;
+ twoFactorEnabled: boolean;
+ createdAt: string;
+ updatedAt: string;
+ ownedOrg: { id: string; name: string; createdAt: string } | null;
+ orgMember: { id: string; isActive: boolean; org: { id: string; name: string } } | null;
+ subscription: {
+ status: string;
+ currentPeriodEnd: string;
+ cancelAtPeriodEnd: boolean;
+ stripeSubscriptionId: string;
+ } | null;
+};
+
+export type ListUsersParams = {
+ plan?: string;
+ role?: string;
+ status?: "active" | "inactive";
+ hasOrg?: "true" | "false";
+ q?: string;
+ page?: number;
+ pageSize?: number;
+};
+
+export type Subscription = {
+ id: string;
+ stripeCustomerId: string;
+ stripeSubscriptionId: string;
+ status: string;
+ currentPeriodEnd: string;
+ cancelAtPeriodEnd: boolean;
+ createdAt: string;
+ user: {
+ id: string;
+ username: string;
+ email: string;
+ plan: string;
+ profile_image_url: string | null;
+ };
+};
+
+export type BillingKPIs = {
+ activeDoctor: number;
+ activeOrg: number;
+ pastDue: number;
+ canceled: number;
+ mrr: number;
+};
+
+export type Payment = {
+ id: string;
+ amount: number;
+ status: string;
+ stripePaymentId: string;
+ plan: string;
+ mode: string;
+ createdAt: string;
+ user: { id: string; username: string; email: string };
+};
+
+export type AuditLogItem = {
+ id: string;
+ orgId: string | null;
+ organization: { id: string; name: string } | null;
+ actorId: string | null;
+ actorName: string;
+ actorEmail: string;
+ action: string;
+ category: string;
+ status: string;
+ severity: string;
+ target: string;
+ targetId: string | null;
+ ip: string | null;
+ userAgent: string | null;
+ createdAt: string;
+};
+
+export type TicketListItem = {
+ id: string;
+ code: string;
+ type: "RE_ANALYSIS" | "RE_REPORT";
+ status: "OPEN" | "IN_PROGRESS" | "RESOLVED" | "DISMISSED";
+ priority: "LOW" | "MEDIUM" | "HIGH";
+ reason: string;
+ createdAt: string;
+ updatedAt: string;
+ resolvedAt: string | null;
+ submittedBy: { id: string; username: string; email: string; profile_image_url: string | null };
+ assignedTo: { id: string; username: string; email: string } | null;
+ scan: { id: string; scan_code: string; class: string } | null;
+ report: { id: string; code: string } | null;
+};
+
+export type TicketDetail = TicketListItem & {
+ resolutionNote: string | null;
+ reanalysisScan: { id: string; scan_code: string; class: string; confidence_score: number } | null;
+ reanalysisReport: { id: string; code: string } | null;
+ submittedBy: TicketListItem["submittedBy"] & { plan: string };
+ scan: (TicketListItem["scan"] & {
+ patient: { id: string; full_name: string } | null;
+ scanImage: { id: string; url: string; size: number; resolution: string; format: string; name: string } | null;
+ report: { id: string; code: string } | null;
+ }) | null;
+ report: (TicketListItem["report"] & {
+ scanId: string;
+ content: unknown;
+ scan: { id: string; scan_code: string; patient: { id: string; full_name: string } | null };
+ }) | null;
+};
+
+// Overview
+export const getOverview = async (): Promise => {
+ const { data } = await axios.get("/superadmin/overview");
+ return data;
+};
+
+// Users
+export const listUsers = async (params: ListUsersParams): Promise> => {
+ const { data } = await axios.get>("/superadmin/users", { params });
+ return data;
+};
+
+export const getUserProfile = async (id: string): Promise => {
+ const { data } = await axios.get(`/superadmin/users/${id}`);
+ return data;
+};
+
+export const getUserScans = async (id: string, page = 1, pageSize = 20) => {
+ const { data } = await axios.get(`/superadmin/users/${id}/scans`, {
+ params: { page, pageSize },
+ });
+ return data;
+};
+
+export const getUserReports = async (id: string, page = 1, pageSize = 20) => {
+ const { data } = await axios.get(`/superadmin/users/${id}/reports`, {
+ params: { page, pageSize },
+ });
+ return data;
+};
+
+export const getUserPatients = async (id: string, page = 1, pageSize = 20) => {
+ const { data } = await axios.get(`/superadmin/users/${id}/patients`, {
+ params: { page, pageSize },
+ });
+ return data;
+};
+
+export const getUserOrganization = async (id: string) => {
+ const { data } = await axios.get(`/superadmin/users/${id}/organization`);
+ return data;
+};
+
+export const setUserStatus = async (id: string, isActive: boolean) => {
+ const { data } = await axios.patch(`/superadmin/users/${id}/status`, { is_active: isActive });
+ return data;
+};
+
+export const forceResetUser = async (id: string) => {
+ const { data } = await axios.post(`/superadmin/users/${id}/force-reset`);
+ return data as { success: boolean; resetToken: string; expiresAt: string };
+};
+
+export const deleteUser = async (id: string) => {
+ const { data } = await axios.delete(`/superadmin/users/${id}`);
+ return data;
+};
+
+// Billing
+export const getBillingKpis = async (): Promise => {
+ const { data } = await axios.get("/superadmin/billing/kpis");
+ return data;
+};
+
+export const listSubscriptions = async (params: {
+ status?: string;
+ plan?: string;
+ q?: string;
+ page?: number;
+ pageSize?: number;
+}): Promise> => {
+ const { data } = await axios.get>("/superadmin/billing/subscriptions", { params });
+ return data;
+};
+
+export const syncSubscription = async (stripeSubscriptionId: string) => {
+ const { data } = await axios.post(`/superadmin/billing/subscriptions/${stripeSubscriptionId}/sync`);
+ return data;
+};
+
+export const cancelSubscription = async (stripeSubscriptionId: string) => {
+ const { data } = await axios.post(`/superadmin/billing/subscriptions/${stripeSubscriptionId}/cancel`);
+ return data;
+};
+
+export const recentPayments = async (limit = 20): Promise => {
+ const { data } = await axios.get("/superadmin/billing/payments", { params: { limit } });
+ return data;
+};
+
+// Audit Logs
+export const listAuditLogs = async (params: {
+ search?: string;
+ category?: string;
+ status?: string;
+ from?: string;
+ to?: string;
+ page?: number;
+ pageSize?: number;
+}): Promise> => {
+ const { data } = await axios.get>("/superadmin/audit-logs", { params });
+ return data;
+};
+
+export const exportAuditLogs = async (params: Record) => {
+ const search = new URLSearchParams();
+ Object.entries(params).forEach(([k, v]) => {
+ if (v !== undefined && v !== null && v !== "") search.append(k, String(v));
+ });
+ const base = (axios.defaults.baseURL ?? "").replace(/\/$/, "");
+ const url = `${base}/superadmin/audit-logs/export?${search.toString()}`;
+ window.open(url, "_blank");
+};
+
+// Tickets
+export const listTickets = async (params: {
+ status?: string;
+ type?: string;
+ priority?: string;
+ q?: string;
+ page?: number;
+ pageSize?: number;
+}): Promise> => {
+ const { data } = await axios.get>("/superadmin/tickets", { params });
+ return data;
+};
+
+export const getTicket = async (code: string): Promise => {
+ const { data } = await axios.get(`/superadmin/tickets/${code}`);
+ return data;
+};
+
+export const assignTicket = async (code: string, assignedToId: string) => {
+ const { data } = await axios.patch(`/superadmin/tickets/${code}/assign`, { assignedToId });
+ return data;
+};
+
+export const reanalyzeTicket = async (code: string) => {
+ const { data } = await axios.post(`/superadmin/tickets/${code}/reanalyze`);
+ return data;
+};
+
+export const regenerateTicket = async (code: string) => {
+ const { data } = await axios.post(`/superadmin/tickets/${code}/regenerate`);
+ return data;
+};
+
+export const resolveTicket = async (code: string, resolutionNote: string) => {
+ const { data } = await axios.patch(`/superadmin/tickets/${code}/resolve`, { resolutionNote });
+ return data;
+};
+
+export const dismissTicket = async (code: string, resolutionNote?: string) => {
+ const { data } = await axios.patch(`/superadmin/tickets/${code}/dismiss`, { resolutionNote });
+ return data;
+};
diff --git a/client/src/features/superadmin/components/AreaChart.tsx b/client/src/features/superadmin/components/AreaChart.tsx
new file mode 100644
index 0000000..6ace9f3
--- /dev/null
+++ b/client/src/features/superadmin/components/AreaChart.tsx
@@ -0,0 +1,271 @@
+import { useMemo, useState, useRef } from "react";
+
+type Point = { date: string; count: number };
+
+type Props = {
+ data: Point[];
+ color?: string;
+ height?: number;
+ label?: string;
+ valueLabel?: string;
+};
+
+const PAD = { top: 18, right: 14, bottom: 26, left: 36 };
+const W = 600;
+
+const niceMax = (raw: number) => {
+ if (raw <= 1) return 1;
+ const pow = Math.pow(10, Math.floor(Math.log10(raw)));
+ const norm = raw / pow;
+ let nice;
+ if (norm <= 1) nice = 1;
+ else if (norm <= 2) nice = 2;
+ else if (norm <= 5) nice = 5;
+ else nice = 10;
+ return nice * pow;
+};
+
+const fmtTick = (d: string) => {
+ const dt = new Date(d);
+ return `${dt.getUTCMonth() + 1}/${dt.getUTCDate()}`;
+};
+
+export const AreaChart = ({
+ data,
+ color = "#2563eb",
+ height = 240,
+ valueLabel = "value",
+}: Props) => {
+ const containerRef = useRef(null);
+ const [hoverIdx, setHoverIdx] = useState(null);
+
+ const { yMax, yTicks, xPositions, areaPath, linePath, total, peak } = useMemo(() => {
+ const counts = data.map((d) => d.count);
+ const rawMax = Math.max(...counts, 1);
+ const yMax = niceMax(rawMax);
+ const innerW = W - PAD.left - PAD.right;
+ const innerH = height - PAD.top - PAD.bottom;
+
+ const xPositions = data.map((_, i) =>
+ data.length === 1 ? PAD.left + innerW / 2 : PAD.left + (i * innerW) / (data.length - 1),
+ );
+ const yFor = (v: number) => PAD.top + innerH - (v / yMax) * innerH;
+ const points = data.map((d, i) => [xPositions[i], yFor(d.count)] as const);
+
+ const linePath = points.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" ");
+ const baselineY = PAD.top + innerH;
+ const areaPath = `${linePath} L${points[points.length - 1][0].toFixed(1)},${baselineY} L${points[0][0].toFixed(1)},${baselineY} Z`;
+
+ const yTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => ({
+ y: yFor(yMax * f),
+ value: Math.round(yMax * f),
+ }));
+
+ const total = counts.reduce((s, c) => s + c, 0);
+ const peakIdx = counts.indexOf(Math.max(...counts));
+ const peak = { idx: peakIdx, count: counts[peakIdx], date: data[peakIdx]?.date };
+
+ return { yMax, yTicks, xPositions, areaPath, linePath, total, peak };
+ }, [data, height]);
+
+ const xTickIndexes = useMemo(() => {
+ if (data.length <= 6) return data.map((_, i) => i);
+ const step = Math.floor((data.length - 1) / 5);
+ const idxs = [];
+ for (let i = 0; i < data.length; i += step) idxs.push(i);
+ if (idxs[idxs.length - 1] !== data.length - 1) idxs.push(data.length - 1);
+ return idxs;
+ }, [data]);
+
+ const onMouseMove = (e: React.MouseEvent) => {
+ const svg = e.currentTarget;
+ const rect = svg.getBoundingClientRect();
+ const x = ((e.clientX - rect.left) / rect.width) * W;
+ let nearest = 0;
+ let minDist = Infinity;
+ for (let i = 0; i < xPositions.length; i++) {
+ const d = Math.abs(xPositions[i] - x);
+ if (d < minDist) {
+ minDist = d;
+ nearest = i;
+ }
+ }
+ setHoverIdx(nearest);
+ };
+
+ const hoverPoint = hoverIdx !== null && data[hoverIdx]
+ ? {
+ x: xPositions[hoverIdx],
+ y: PAD.top + (height - PAD.top - PAD.bottom) - (data[hoverIdx].count / yMax) * (height - PAD.top - PAD.bottom),
+ data: data[hoverIdx],
+ }
+ : null;
+
+ const gradientId = `area-grad-${color.replace("#", "")}`;
+
+ return (
+
+
+
+
+ {total.toLocaleString()}
+
+ total
+
+ {peak.count > 0 && (
+
+
+ {peak.count}
+
+
+ peak · {peak.date ? fmtTick(peak.date) : ""}
+
+
+ )}
+
+
+
+
+ {hoverPoint && (
+
+
+ {new Date(hoverPoint.data.date).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })}
+
+
+ {hoverPoint.data.count} {valueLabel}
+
+
+ )}
+
+ );
+};
diff --git a/client/src/features/superadmin/components/KPICard.tsx b/client/src/features/superadmin/components/KPICard.tsx
new file mode 100644
index 0000000..5136dd1
--- /dev/null
+++ b/client/src/features/superadmin/components/KPICard.tsx
@@ -0,0 +1,57 @@
+import { Card } from "antd";
+import type { ReactNode } from "react";
+
+type Props = {
+ label: string;
+ value: ReactNode;
+ sub?: ReactNode;
+ accent?: string;
+ icon?: ReactNode;
+};
+
+export const KPICard = ({ label, value, sub, accent = "#004ac6", icon }: Props) => (
+
+
+
+
+ {label}
+
+
+ {value}
+
+ {sub && (
+
{sub}
+ )}
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+
+);
diff --git a/client/src/features/superadmin/components/SuperAdminLayout.tsx b/client/src/features/superadmin/components/SuperAdminLayout.tsx
new file mode 100644
index 0000000..fb72838
--- /dev/null
+++ b/client/src/features/superadmin/components/SuperAdminLayout.tsx
@@ -0,0 +1,209 @@
+import { Outlet, Link, useRouterState } from "@tanstack/react-router";
+import { useAuthStore } from "../../../store/auth.store";
+import {
+ MdSpaceDashboard,
+ MdPeopleAlt,
+ MdAttachMoney,
+ MdReceiptLong,
+ MdSupportAgent,
+ MdLogout,
+} from "react-icons/md";
+import { message } from "antd";
+import { useLogout } from "../../auth/hooks";
+
+type NavItem = { to: string; label: string; Icon: React.ComponentType<{ size?: number; color?: string }>; exact?: boolean };
+const NAV: NavItem[] = [
+ { to: "/", label: "Overview", Icon: MdSpaceDashboard, exact: true },
+ { to: "/users", label: "Users", Icon: MdPeopleAlt },
+ { to: "/billing", label: "Billing", Icon: MdAttachMoney },
+ { to: "/audit-logs", label: "Audit Logs", Icon: MdReceiptLong },
+ { to: "/tickets", label: "Tickets", Icon: MdSupportAgent },
+];
+
+export const SuperAdminLayout = ({ children }: { children?: React.ReactNode }) => {
+ const currentPath = useRouterState({ select: (s) => s.location.pathname });
+ const user = useAuthStore((s) => s.user);
+ const clear = useAuthStore((s) => s.clear);
+ const { mutate: logout } = useLogout();
+
+ const handleLogout = () => {
+ logout(undefined, {
+ onSuccess: () => {
+ message.success("Logged out");
+ clear();
+ },
+ });
+ };
+
+ return (
+
+
+
+
+
+
+ Super Admin Console
+
+
+
+ {user?.profile_image_url ? (
+

+ ) : (
+ user?.username?.slice(0, 2).toUpperCase() ?? "SA"
+ )}
+
+
+
{user?.username}
+
{user?.email}
+
+
+
+
+
+ {children ?? }
+
+
+
+ );
+};
diff --git a/client/src/features/superadmin/components/UserDetailModal.tsx b/client/src/features/superadmin/components/UserDetailModal.tsx
new file mode 100644
index 0000000..10cece8
--- /dev/null
+++ b/client/src/features/superadmin/components/UserDetailModal.tsx
@@ -0,0 +1,169 @@
+import { useMemo, useState } from "react";
+import { Modal, Tabs, Dropdown, Button, Popconfirm, message } from "antd";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { MdMoreVert } from "react-icons/md";
+import {
+ getUserProfile,
+ setUserStatus,
+ forceResetUser,
+ deleteUser,
+ getUserPatients,
+} from "../api/superadmin.api";
+import { ProfileTab } from "./UserDetailTabs/ProfileTab";
+import { ScansTab } from "./UserDetailTabs/ScansTab";
+import { ReportsTab } from "./UserDetailTabs/ReportsTab";
+import { PatientsTab } from "./UserDetailTabs/PatientsTab";
+import { OrganizationTab } from "./UserDetailTabs/OrganizationTab";
+
+type Props = {
+ open: boolean;
+ userId: string | null;
+ onClose: () => void;
+};
+
+export const UserDetailModal = ({ open, userId, onClose }: Props) => {
+ const qc = useQueryClient();
+ const [activeKey, setActiveKey] = useState("profile");
+
+ const profileQ = useQuery({
+ queryKey: ["superadmin", "user", userId, "profile"],
+ queryFn: () => getUserProfile(userId!),
+ enabled: !!userId && open,
+ });
+
+ const profile = profileQ.data;
+
+ // Probe whether DOCTOR user has patients (cheap — just need count > 0)
+ const patientsProbe = useQuery({
+ queryKey: ["superadmin", "user", userId, "patients-probe"],
+ queryFn: () => getUserPatients(userId!, 1, 1),
+ enabled: !!userId && open && profile?.plan === "DOCTOR",
+ });
+
+ const showPatients = profile?.plan === "DOCTOR" && (patientsProbe.data?.total ?? 0) > 0;
+ const showOrg = profile?.plan === "ORGANIZATION" && !!profile?.ownedOrg;
+
+ const tabs = useMemo(() => {
+ if (!userId) return [];
+ const base = [
+ { key: "profile", label: "Profile", children: },
+ { key: "scans", label: "Scans", children: },
+ { key: "reports", label: "Reports", children: },
+ ];
+ if (showPatients) {
+ base.push({ key: "patients", label: "Patients", children: });
+ }
+ if (showOrg) {
+ base.push({ key: "organization", label: "Organization", children: });
+ }
+ return base;
+ }, [userId, showPatients, showOrg]);
+
+ const statusMut = useMutation({
+ mutationFn: ({ active }: { active: boolean }) => setUserStatus(userId!, active),
+ onSuccess: (_, { active }) => {
+ message.success(active ? "User activated" : "User deactivated");
+ qc.invalidateQueries({ queryKey: ["superadmin", "user", userId] });
+ qc.invalidateQueries({ queryKey: ["superadmin", "users"] });
+ },
+ onError: (err: unknown) => {
+ const e = err as { response?: { data?: { message?: string } } };
+ message.error(e.response?.data?.message ?? "Action failed");
+ },
+ });
+
+ const resetMut = useMutation({
+ mutationFn: () => forceResetUser(userId!),
+ onSuccess: (data) => {
+ Modal.info({
+ title: "Reset token issued",
+ content: (
+
+
Share this reset link with the user (valid for 1 hour):
+
+ /reset-password?token={data.resetToken}
+
+
+ ),
+ width: 560,
+ });
+ },
+ onError: (err: unknown) => {
+ const e = err as { response?: { data?: { message?: string } } };
+ message.error(e.response?.data?.message ?? "Action failed");
+ },
+ });
+
+ const deleteMut = useMutation({
+ mutationFn: () => deleteUser(userId!),
+ onSuccess: () => {
+ message.success("User deleted");
+ qc.invalidateQueries({ queryKey: ["superadmin", "users"] });
+ onClose();
+ },
+ onError: (err: unknown) => {
+ const e = err as { response?: { data?: { message?: string } } };
+ message.error(e.response?.data?.message ?? "Action failed");
+ },
+ });
+
+ const actionMenu = {
+ items: [
+ {
+ key: "status",
+ label: profile?.is_active ? "Deactivate user" : "Activate user",
+ },
+ { key: "reset", label: "Force password reset" },
+ { type: "divider" as const },
+ { key: "delete", label: Delete user, danger: true },
+ ],
+ onClick: ({ key }: { key: string }) => {
+ if (key === "status") statusMut.mutate({ active: !profile?.is_active });
+ if (key === "reset") resetMut.mutate();
+ if (key === "delete") {
+ Modal.confirm({
+ title: "Delete user?",
+ content: "This soft-deletes the user (deactivates + scrambles credentials). This cannot be undone.",
+ okType: "danger",
+ okText: "Delete",
+ onOk: () => deleteMut.mutateAsync(),
+ });
+ }
+ },
+ };
+
+ // Suppress unused Popconfirm import warning by keeping it available for future use
+ void Popconfirm;
+
+ return (
+
+
+
+ {profile?.username ?? "User detail"}
+
+
{profile?.email}
+
+ {profile && (
+
+ }>Actions
+
+ )}
+
+ }
+ >
+