feat(ai): clinic-wide AI kill-switch (admin-controlled)

- new org_ai_policy table + GET/PUT /api/ai/policy (read for any member,
  write owner/admin only); migration 0018
- /api/chat hard-blocks (403) when AI is off for the caller
- Settings → AI "Availability" section: enable AI, or disable for
  employees only (owners/admins keep access); read-only for non-admins
- sidebar, command palette and route guard hide/redirect the AI chat
  when it's disabled for the current user

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-15 18:42:50 +03:00
parent 6fdb7bdb46
commit addddc8972
14 changed files with 3589 additions and 6 deletions
@@ -0,0 +1,8 @@
CREATE TABLE "org_ai_policy" (
"organization_id" text PRIMARY KEY NOT NULL,
"ai_enabled" boolean DEFAULT true NOT NULL,
"disabled_for_employees" boolean DEFAULT false NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "org_ai_policy" ADD CONSTRAINT "org_ai_policy_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -127,6 +127,13 @@
"when": 1781537419038,
"tag": "0017_wealthy_northstar",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1781537902724,
"tag": "0018_clean_doctor_strange",
"breakpoints": true
}
]
}
+1
View File
@@ -12,3 +12,4 @@ export * from "./notifications.js";
export * from "./settings.js";
export * from "./ai.js";
export * from "./ai-chat.js";
export * from "./org-ai-policy.js";
+22
View File
@@ -0,0 +1,22 @@
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { organization } from "./auth.js";
// Clinic-wide AI availability, controlled by owners/admins. One row per clinic
// (organization). Absent row = AI enabled for everyone (the default).
// aiEnabled = false → AI hidden + blocked for the whole clinic.
// disabledForEmployees = true → AI hidden + blocked for non-admins; owners
// and admins keep access.
export const orgAiPolicy = pgTable("org_ai_policy", {
organizationId: text("organization_id")
.primaryKey()
.references(() => organization.id, { onDelete: "cascade" }),
aiEnabled: boolean("ai_enabled").notNull().default(true),
disabledForEmployees: boolean("disabled_for_employees")
.notNull()
.default(false),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
+45
View File
@@ -18,10 +18,55 @@ import {
saveAiConfig,
toAiConfig,
} from "../services/ai/config.js";
import { getPolicy, savePolicy } from "../services/ai/policy.js";
import * as patients from "../services/patients.js";
export const aiRouter = Router();
// --- Clinic-wide AI policy (admin-controlled kill-switch) -------------------
// Any member can READ the policy (the frontend needs it to gate nav/routes);
// only owners/admins can change it.
aiRouter.get("/policy", requireAuth, requireOrg, async (req, res, next) => {
try {
res.json(await getPolicy(req.organizationId!));
} catch (err) {
next(err);
}
});
aiRouter.put("/policy", requireAuth, requireOrg, async (req, res, next) => {
try {
const roles = String(req.memberRole ?? "")
.split(",")
.map((s) => s.trim());
const isAdmin = roles.some((r) => r === "owner" || r === "admin");
if (!isAdmin) {
throw new HttpError(403, "Only owners and admins can change this.");
}
const body = req.body as {
aiEnabled?: unknown;
disabledForEmployees?: unknown;
};
const saved = await savePolicy(req.organizationId!, {
aiEnabled: Boolean(body.aiEnabled),
disabledForEmployees: Boolean(body.disabledForEmployees),
});
void recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: saved.aiEnabled
? saved.disabledForEmployees
? "Restricted AI to owners and admins"
: "Enabled the AI assistant clinic-wide"
: "Disabled the AI assistant clinic-wide",
entityType: "patient",
});
res.json(saved);
} catch (err) {
next(err);
}
});
// --- Per-user AI config (no clinic/RBAC needed, like /api/settings) ---------
aiRouter.get("/config", requireAuth, async (req, res, next) => {
try {
+8
View File
@@ -21,6 +21,7 @@ import {
import { recordActivity } from "../services/activity.js";
import * as aiChat from "../services/ai-chat.js";
import { getAiSettings } from "../services/ai/config.js";
import { aiAllowedFor, getPolicy } from "../services/ai/policy.js";
import { resolveModel } from "../services/ai/provider.js";
import { createChatTools } from "../services/ai/tools.js";
import { createVeil } from "../services/ai/veil.js";
@@ -146,6 +147,13 @@ chatRouter.post("/", async (req, res, next) => {
return;
}
// Honour the clinic's AI kill-switch — employees can't reach the agent even
// by bypassing the (also-gated) UI.
const policy = await getPolicy(req.organizationId!);
if (!aiAllowedFor(policy, req.memberRole)) {
throw new HttpError(403, "The AI assistant is disabled for your account.");
}
const settings = await getAiSettings(req.user!.id);
const modelId = requestedModel || settings.defaultModel;
const resolved = resolveModel(settings, modelId);
+53
View File
@@ -0,0 +1,53 @@
import { eq } from "drizzle-orm";
import { db } from "../../db/index.js";
import { orgAiPolicy } from "../../db/schema/org-ai-policy.js";
export type AiPolicy = {
aiEnabled: boolean;
disabledForEmployees: boolean;
};
const DEFAULT_POLICY: AiPolicy = {
aiEnabled: true,
disabledForEmployees: false,
};
// Read a clinic's AI policy, falling back to the permissive default when no row
// exists yet.
export async function getPolicy(orgId: string): Promise<AiPolicy> {
const [row] = await db
.select({
aiEnabled: orgAiPolicy.aiEnabled,
disabledForEmployees: orgAiPolicy.disabledForEmployees,
})
.from(orgAiPolicy)
.where(eq(orgAiPolicy.organizationId, orgId))
.limit(1);
return row ?? DEFAULT_POLICY;
}
export async function savePolicy(
orgId: string,
policy: AiPolicy,
): Promise<AiPolicy> {
await db
.insert(orgAiPolicy)
.values({ organizationId: orgId, ...policy })
.onConflictDoUpdate({
target: orgAiPolicy.organizationId,
set: { ...policy, updatedAt: new Date() },
});
return policy;
}
// Whether a member with `role` may use the AI under `policy`. Owners/admins keep
// access when AI is only disabled for employees; a full disable blocks everyone.
export function aiAllowedFor(policy: AiPolicy, role: string | undefined): boolean {
if (!policy.aiEnabled) return false;
if (!policy.disabledForEmployees) return true;
const names = String(role ?? "")
.split(",")
.map((s) => s.trim());
return names.some((r) => r === "owner" || r === "admin");
}
+9 -1
View File
@@ -3,6 +3,7 @@
import { usePathname, useRouter } from "next/navigation";
import { type ReactNode, useEffect, useRef } from "react";
import { useAiAccess } from "@/lib/ai-policy";
import { authClient } from "@/lib/auth-client";
import { canAccessRoute, defaultLandingFor, useActiveRole } from "@/lib/roles";
@@ -14,6 +15,7 @@ export function AppAuthGuard({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const role = useActiveRole();
const { allowed: aiAllowed, loading: aiLoading } = useAiAccess();
const { data: session, isPending } = authClient.useSession();
const { data: orgs, isPending: orgsPending } =
authClient.useListOrganizations();
@@ -52,8 +54,14 @@ export function AppAuthGuard({ children }: { children: ReactNode }) {
if (!ready || role == null) return;
if (!canAccessRoute(pathname, role)) {
router.replace(defaultLandingFor(role));
return;
}
}, [ready, role, pathname, router]);
// AI kill-switch: the chat home ("/") is off for this user — send them to
// patients (clinical roles always have it; non-clinical never land on "/").
if (!aiLoading && !aiAllowed && pathname === "/") {
router.replace("/patients");
}
}, [ready, role, pathname, router, aiAllowed, aiLoading]);
if (!ready) {
return (
+8 -3
View File
@@ -27,6 +27,7 @@ import {
CommandPanel,
} from "@/components/ui/command";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { useAiAccess } from "@/lib/ai-policy";
import { useActiveRole, visibleNavItems } from "@/lib/roles";
type CommandPaletteContextValue = { open: () => void };
@@ -51,6 +52,7 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
const router = useRouter();
const { t } = useTranslation();
const role = useActiveRole();
const { allowed: aiAllowed } = useAiAccess();
const [open, setOpen] = useState(false);
useEffect(() => {
@@ -71,8 +73,11 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
value: "pages",
label: t("nav.commandGroup"),
// Flatten sub-pages so e.g. "Appointments & Schedule" is reachable.
// Filtered by role so reception can't jump to clinical pages.
items: visibleNavItems(role).flatMap((item) =>
// Filtered by role so reception can't jump to clinical pages, and by
// the AI kill-switch so the disabled chat isn't listed.
items: visibleNavItems(role)
.filter((item) => aiAllowed || item.id !== "new-chat")
.flatMap((item) =>
item.subs?.length
? item.subs.map((sub) => ({
id: sub.id,
@@ -91,7 +96,7 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
),
},
],
[t, role],
[t, role, aiAllowed],
);
type Group = (typeof groups)[number];
@@ -17,7 +17,13 @@ import {
FieldLabel,
SettingsCard,
SettingsSection,
ToggleRow,
} from "@/components/settings/settings-parts";
import {
type AiPolicy,
getAiPolicy,
saveAiPolicy,
} from "@/lib/ai-policy";
import { AI_MODELS, EFFORT_LEVELS, type Effort } from "@/lib/ai-models";
import {
type AiConfig,
@@ -28,6 +34,7 @@ import {
saveAiConfig,
testAiConnection,
} from "@/lib/ai-settings";
import { useActiveRole } from "@/lib/roles";
import { notify } from "@/lib/toast";
const PROVIDERS: ApiProvider[] = ["openai", "anthropic", "gemini"];
@@ -52,6 +59,55 @@ export function AIPanel() {
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
// Clinic-wide AI availability (admin-controlled kill-switch).
const role = useActiveRole();
const isAdmin = role === "owner" || role === "admin";
const [policy, setPolicy] = useState<AiPolicy | null>(null);
const [policyBaseline, setPolicyBaseline] = useState<AiPolicy | null>(null);
const [savingPolicy, setSavingPolicy] = useState(false);
useEffect(() => {
let cancelled = false;
getAiPolicy()
.then((p) => {
if (cancelled) return;
setPolicy(p);
setPolicyBaseline(p);
})
.catch(() => {
/* leave null; section just won't render its controls */
});
return () => {
cancelled = true;
};
}, []);
const policyDirty =
policy != null &&
policyBaseline != null &&
JSON.stringify(policy) !== JSON.stringify(policyBaseline);
const savePolicy = async () => {
if (!policy) return;
setSavingPolicy(true);
try {
const saved = await saveAiPolicy(policy);
setPolicy(saved);
setPolicyBaseline(saved);
notify.success(
t("settings.ai.availability.savedTitle"),
t("settings.ai.availability.savedBody"),
);
} catch {
notify.error(
t("settings.ai.saveFailedTitle"),
t("settings.ai.saveFailedBody"),
);
} finally {
setSavingPolicy(false);
}
};
useEffect(() => {
let cancelled = false;
getAiConfig()
@@ -142,6 +198,63 @@ export function AIPanel() {
return (
<>
{policy ? (
<SettingsSection
description={t("settings.ai.availability.description")}
title={t("settings.ai.availability.title")}
>
{isAdmin ? (
<div className="space-y-3">
<ToggleRow
checked={policy.aiEnabled}
description={t("settings.ai.availability.enabledHint")}
onCheckedChange={(checked) =>
setPolicy((p) => (p ? { ...p, aiEnabled: checked } : p))
}
title={t("settings.ai.availability.enabled")}
/>
{policy.aiEnabled ? (
<ToggleRow
checked={policy.disabledForEmployees}
description={t(
"settings.ai.availability.employeesOnlyHint",
)}
onCheckedChange={(checked) =>
setPolicy((p) =>
p ? { ...p, disabledForEmployees: checked } : p,
)
}
title={t("settings.ai.availability.employeesOnly")}
/>
) : null}
{policyDirty ? (
<div className="flex justify-end">
<Button
disabled={savingPolicy}
onClick={savePolicy}
size="sm"
>
{savingPolicy
? t("settings.ai.saving")
: t("settings.ai.saveChanges")}
</Button>
</div>
) : null}
</div>
) : (
<SettingsCard className="px-4 py-3.5">
<p className="text-sm text-muted-foreground">
{policy.aiEnabled
? policy.disabledForEmployees
? t("settings.ai.availability.readonlyEmployeesOnly")
: t("settings.ai.availability.readonlyEnabled")
: t("settings.ai.availability.readonlyDisabled")}
</p>
</SettingsCard>
)}
</SettingsSection>
) : null}
<SettingsSection
description={t("settings.ai.modeDescription")}
title={t("settings.ai.modeTitle")}
@@ -14,6 +14,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useAiAccess } from "@/lib/ai-policy";
import { useActiveRole, visibleNavItems } from "@/lib/roles";
import { motion } from "framer-motion";
import Image from "next/image";
@@ -28,10 +29,14 @@ export function DashboardSidebar() {
const { state } = useSidebar();
const { t } = useTranslation();
const role = useActiveRole();
const { allowed: aiAllowed } = useAiAccess();
const isCollapsed = state === "collapsed";
// Hide clinical nav from non-clinical roles (e.g. reception). See lib/roles.ts.
const dashboardRoutes: Route[] = visibleNavItems(role).map((item) => ({
// Also drop the AI "New chat" entry when the clinic's AI kill-switch applies.
const dashboardRoutes: Route[] = visibleNavItems(role)
.filter((item) => aiAllowed || item.id !== "new-chat")
.map((item) => ({
id: item.id,
title: t(item.labelKey),
icon: <item.icon className="size-4" />,
@@ -92,7 +97,7 @@ export function DashboardSidebar() {
</SidebarHeader>
<SidebarContent className="gap-4 px-2 py-4">
<DashboardNavigation routes={dashboardRoutes} />
<NavChatHistory />
{aiAllowed && <NavChatHistory />}
</SidebarContent>
<SidebarFooter className="p-2">
<NavUser />
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api-client";
import { useActiveRole } from "@/lib/roles";
// Mirrors backend/src/services/ai/policy.ts. Clinic-wide AI availability, set by
// owners/admins. Absent/default = AI enabled for everyone.
export type AiPolicy = {
aiEnabled: boolean;
disabledForEmployees: boolean;
};
export function getAiPolicy(): Promise<AiPolicy> {
return apiFetch<AiPolicy>("/api/ai/policy");
}
export function saveAiPolicy(policy: AiPolicy): Promise<AiPolicy> {
return apiFetch<AiPolicy>("/api/ai/policy", {
method: "PUT",
body: JSON.stringify(policy),
});
}
function isAdminRole(role: string | null): boolean {
return String(role ?? "")
.split(",")
.map((s) => s.trim())
.some((r) => r === "owner" || r === "admin");
}
// Whether a member with `role` may use the AI under `policy`. Owners/admins keep
// access when AI is only disabled for employees.
export function aiAllowedFor(
policy: AiPolicy | null,
role: string | null,
): boolean {
if (!policy) return true; // optimistic while loading — avoids nav flicker
if (!policy.aiEnabled) return false;
if (!policy.disabledForEmployees) return true;
return isAdminRole(role);
}
// Whether the current user may use the AI (clinic policy + their role). Returns
// `allowed: true` while loading so the AI nav doesn't flash out then back in.
export function useAiAccess(): { allowed: boolean; loading: boolean } {
const role = useActiveRole();
const [policy, setPolicy] = useState<AiPolicy | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
getAiPolicy()
.then((p) => {
if (active) setPolicy(p);
})
.catch(() => {
/* leave permissive default; backend still enforces */
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []);
return { allowed: aiAllowedFor(policy, role), loading };
}
@@ -1274,6 +1274,19 @@
"backupDesc": "Export an encrypted backup of your signing key to restore it on a new device."
},
"ai": {
"availability": {
"title": "Availability",
"description": "Control who in your clinic can use the AI assistant. When disabled, the AI page and sidebar entry are hidden and the assistant cannot be reached.",
"enabled": "Enable AI assistant",
"enabledHint": "Turn the AI assistant on for your clinic. Off hides it for everyone.",
"employeesOnly": "Disable for employees only",
"employeesOnlyHint": "Hide the AI from staff; owners and admins keep access.",
"savedTitle": "AI availability updated",
"savedBody": "The change applies across your clinic.",
"readonlyEnabled": "The AI assistant is enabled for your clinic.",
"readonlyEmployeesOnly": "The AI assistant is restricted to owners and admins.",
"readonlyDisabled": "The AI assistant is disabled for your clinic."
},
"modeTitle": "Inference mode",
"modeDescription": "Choose how temetro runs the AI. A cloud API key sends data off your infrastructure; a local model keeps everything on your machine.",
"mode": "Mode",