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
+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 />