mirror of
https://github.com/temetro/temetro.git
synced 2026-08-20 23:22:18 +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:
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user