'use client' import * as React from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Loader2, Save, Shield, Trash2, ShieldCheck } from 'lucide-react' import { useT } from '@open-mercato/shared/lib/i18n/context' import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert' import { Badge } from '@open-mercato/ui/primitives/badge' import { Button } from '@open-mercato/ui/primitives/button' import { Checkbox } from '@open-mercato/ui/primitives/checkbox' import { Label } from '@open-mercato/ui/primitives/label' import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall' import { flash } from '@open-mercato/ui/backend/FlashMessages' import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation' type EnvAllowlistConfig = { providers: string[] | null modelsByProvider: Record hasRestrictions: boolean } type TenantAllowlist = { allowedProviders: string[] | null allowedModelsByProvider: Record } type EffectiveAllowlist = { providers: string[] | null modelsByProvider: Record hasRestrictions: boolean tenantOverridesActive: boolean } type ProviderEntry = { id: string name: string defaultModel: string envKey: string | null configured: boolean defaultModels: Array<{ id: string; name: string; contextWindow?: number; tags?: string[] }> } type SettingsResponse = { availableProviders: ProviderEntry[] allowlistProviders?: ProviderEntry[] allowlist: EnvAllowlistConfig tenantAllowlist: TenantAllowlist | null effectiveAllowlist: EffectiveAllowlist } async function fetchSettings(): Promise { const { result, status } = await apiCallOrThrow( '/api/ai_assistant/settings', { method: 'GET', credentials: 'include' }, { errorMessage: 'Failed to load AI settings' }, ) if (!result) throw new Error(`Failed to load settings (${status})`) return result } type EditState = { /** null = "no tenant restriction (inherit env)"; array = explicit tenant pick */ allowedProviders: string[] | null allowedModelsByProvider: Record } function snapshotToEditState(snapshot: TenantAllowlist | null): EditState { return { allowedProviders: snapshot?.allowedProviders ?? null, allowedModelsByProvider: { ...(snapshot?.allowedModelsByProvider ?? {}) }, } } export function AiTenantAllowlistPageClient(): React.JSX.Element { const t = useT() const queryClient = useQueryClient() const settingsQuery = useQuery({ queryKey: ['ai_assistant', 'settings'], queryFn: fetchSettings, staleTime: 0 }) const [editState, setEditState] = React.useState({ allowedProviders: null, allowedModelsByProvider: {}, }) const [dirty, setDirty] = React.useState(false) const [saving, setSaving] = React.useState(false) const [clearing, setClearing] = React.useState(false) const [feedback, setFeedback] = React.useState<{ kind: 'ok' | 'error'; text: string } | null>(null) const { runMutation: runSaveAllowlistMutation } = useGuardedMutation({ contextId: 'ai-tenant-allowlist-save', }) const { runMutation: runClearAllowlistMutation } = useGuardedMutation({ contextId: 'ai-tenant-allowlist-clear', }) React.useEffect(() => { if (settingsQuery.data) { setEditState(snapshotToEditState(settingsQuery.data.tenantAllowlist)) setDirty(false) } }, [settingsQuery.data]) const pageHeader = (

{t('ai_assistant.allowlist.title', 'AI provider & model allowlist')}

{t( 'ai_assistant.allowlist.subtitle', 'Limit which providers and models the runtime, settings, and chat picker may use for this tenant. The env allowlist is the outer constraint — tenant picks narrow it further.', )}

) if (settingsQuery.isLoading) { return (
{pageHeader}
{t('ai_assistant.allowlist.loading', 'Loading allowlist…')}
) } if (settingsQuery.isError || !settingsQuery.data) { return (
{pageHeader} {t('ai_assistant.allowlist.loadError.title', 'Failed to load allowlist')} {settingsQuery.error instanceof Error ? settingsQuery.error.message : t('ai_assistant.allowlist.loadError.body', 'Try refreshing the page.')}
) } const settings = settingsQuery.data const envAllowedProviders = settings.allowlist.providers const envModelsByProvider = settings.allowlist.modelsByProvider // Provider universe to render: env-allowed providers (or all configured if env unset). const editableProviders = settings.allowlistProviders ?? settings.availableProviders const candidateProviders = editableProviders.filter((p) => { if (envAllowedProviders === null) return true return envAllowedProviders.some((id) => id.toLowerCase() === p.id.toLowerCase()) }) const tenantPickedProviders = editState.allowedProviders const isProviderEnabled = (id: string): boolean => { if (tenantPickedProviders === null) return true return tenantPickedProviders.includes(id) } const toggleProvider = (id: string, next: boolean): void => { setDirty(true) setFeedback(null) setEditState((prev) => { const current = prev.allowedProviders if (next) { const list = current === null ? [id] : Array.from(new Set([...current, id])) return { ...prev, allowedProviders: list } } const list = current === null ? candidateProviders.map((p) => p.id).filter((pid) => pid !== id) : current.filter((pid) => pid !== id) return { ...prev, allowedProviders: list } }) } const isModelEnabled = (providerId: string, modelId: string): boolean => { const list = editState.allowedModelsByProvider[providerId] if (list === undefined) return true return list.includes(modelId) } const toggleModel = (providerId: string, modelId: string, next: boolean): void => { setDirty(true) setFeedback(null) const provider = candidateProviders.find((p) => p.id === providerId) const allModelIds = provider?.defaultModels.map((m) => m.id) ?? [] setEditState((prev) => { const current = prev.allowedModelsByProvider[providerId] const allowedModelsByProvider = { ...prev.allowedModelsByProvider } if (next) { const list = current === undefined ? [modelId] : Array.from(new Set([...current, modelId])) allowedModelsByProvider[providerId] = list } else { const baseline = current === undefined ? allModelIds : current const list = baseline.filter((id) => id !== modelId) allowedModelsByProvider[providerId] = list } return { ...prev, allowedModelsByProvider } }) } const resetTenantPicks = (): void => { setDirty(true) setFeedback(null) setEditState({ allowedProviders: null, allowedModelsByProvider: {} }) } const handleSave = async (): Promise => { setSaving(true) setFeedback(null) try { await runSaveAllowlistMutation({ operation: async () => { const { ok, status, result } = await apiCall<{ error?: string; code?: string }>( '/api/ai_assistant/settings/allowlist', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allowedProviders: editState.allowedProviders, allowedModelsByProvider: editState.allowedModelsByProvider, }), }, ) if (!ok) { throw new Error( result?.error ?? t('ai_assistant.allowlist.save.error', `Save failed (${status})`), ) } }, context: {}, }) const successText = t('ai_assistant.allowlist.save.success', 'Allowlist saved.') setFeedback({ kind: 'ok', text: successText }) flash(successText, 'success') setDirty(false) await queryClient.invalidateQueries({ queryKey: ['ai_assistant', 'settings'] }) } catch (err) { const message = err instanceof Error ? err.message : String(err) setFeedback({ kind: 'error', text: message }) flash(message, 'error') } finally { setSaving(false) } } const handleClear = async (): Promise => { setClearing(true) setFeedback(null) try { await runClearAllowlistMutation({ operation: async () => { const { ok, status, result } = await apiCall<{ error?: string; cleared?: boolean }>( '/api/ai_assistant/settings/allowlist', { method: 'DELETE', credentials: 'include' }, ) if (!ok) { throw new Error( result?.error ?? t('ai_assistant.allowlist.clear.error', `Clear failed (${status})`), ) } }, context: {}, }) const successText = t( 'ai_assistant.allowlist.clear.success', 'Tenant allowlist cleared. Env-only enforcement applies.', ) setFeedback({ kind: 'ok', text: successText }) flash(successText, 'success') setDirty(false) await queryClient.invalidateQueries({ queryKey: ['ai_assistant', 'settings'] }) } catch (err) { const message = err instanceof Error ? err.message : String(err) setFeedback({ kind: 'error', text: message }) flash(message, 'error') } finally { setClearing(false) } } const envBanner = envAllowedProviders === null && Object.keys(envModelsByProvider).length === 0 ? null : ( {t('ai_assistant.allowlist.envBanner.title', 'Env allowlist is in effect')} {envAllowedProviders ? (
{t('ai_assistant.allowlist.envBanner.providers', 'OM_AI_AVAILABLE_PROVIDERS')}: {envAllowedProviders.join(', ')}
) : null} {Object.keys(envModelsByProvider).map((pid) => (
OM_AI_AVAILABLE_MODELS_{pid.toUpperCase()}: {envModelsByProvider[pid].join(', ')}
))}

{t('ai_assistant.allowlist.envBanner.note', 'Tenant picks may not widen the env list — values outside it are hidden.')}

) return (
{pageHeader} {envBanner} {feedback ? ( {feedback.text} ) : null}

{t('ai_assistant.allowlist.providers.title', 'Providers')}

{t( 'ai_assistant.allowlist.providers.help', 'Untick to forbid the runtime from using a provider for this tenant. Tick all to inherit the env allowlist.', )}

{settings.effectiveAllowlist.tenantOverridesActive ? ( ) : ( )}
{candidateProviders.length === 0 ? (

{t('ai_assistant.allowlist.providers.empty', 'No configured providers within the env allowlist.')}

) : (
{candidateProviders.map((provider) => { const enabled = isProviderEnabled(provider.id) const envModels = envModelsByProvider[provider.id] const candidateModels = envModels ? provider.defaultModels.filter((m) => envModels.includes(m.id)) : provider.defaultModels return (
toggleProvider(provider.id, value === true)} /> {provider.configured ? ( {t('ai_assistant.allowlist.providers.configured', 'configured')} ) : ( {t('ai_assistant.allowlist.providers.notConfigured', 'not configured')} )}
{enabled && candidateModels.length > 0 ? (
{t('ai_assistant.allowlist.models.help', 'Tick the models tenants may pick. Empty = no model restriction (inherit env).')}
{candidateModels.map((model) => { const checked = isModelEnabled(provider.id, model.id) return ( ) })}
) : null}
) })}
)}
) } export default AiTenantAllowlistPageClient