mirror of
https://github.com/temetro/temetro.git
synced 2026-08-25 18:07:44 +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,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