mirror of
https://github.com/temetro/temetro.git
synced 2026-08-16 13:28:35 +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:
@@ -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