mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user