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
+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",