/* Módulo de Configurações — RF-CONFIG-01 a 14 (ESP-0010, ADR-0008). Vive dentro do shell, como `view_users.jsx`, e não como takeover. Duas seções, sem endpoint nem tela em comum entre elas (RF-CONFIG-02): Parâmetros (RF-CONFIG-07, valor sempre visível) e Segredos (RF-CONFIG-03, write-only — o valor nunca volta do backend, só `prefixo`/`sufixo` mascarados). Esconder o item de menu para quem não é administrador não é proteção — a autorização é do backend (RF-CONFIG-01). */ const TIPOS_DE_PARAMETRO = [ ["bool", "Sim/Não"], ["enum", "Lista de opções"], ["int", "Número inteiro"], ]; const CATEGORIAS_DE_SEGREDO = [ ["llm", "Modelo de linguagem (LiteLLM)"], ["tts", "Síntese de voz (TTS)"], ["litellm_admin", "Master key do LiteLLM"], ]; const NOME_DA_CATEGORIA = Object.fromEntries(CATEGORIAS_DE_SEGREDO); // Só os provedores com testador implementado em `services/credential_test.py` // (ADR-0008 item 8: documentado por provedor, não genérico). const PROVIDERS_POR_CATEGORIA = { llm: [ ["openai", "OpenAI"], ["anthropic", "Anthropic"], ["gemini", "Google Gemini"], ], tts: [ ["openai", "OpenAI TTS"], ["azure_speech", "Azure Speech"], ["elevenlabs", "ElevenLabs"], ], litellm_admin: [["litellm", "LiteLLM"]], }; const ERROS_DE_CONFIGURACAO = { parametro_invalido: null, // a mensagem do backend já orienta segredo_invalido: null, segredo_nao_encontrado: "Credencial não encontrada. Atualize a listagem.", cooldown_ativo: null, // o backend já informa o tempo restante etapa_indisponivel: null, litellm_registro_falhou: null, }; function mensagemDeErroDeConfiguracao(erro) { if (!erro) return ""; const especifica = ERROS_DE_CONFIGURACAO[erro.code]; if (especifica) return especifica; return erro.message || "Não foi possível concluir a solicitação."; } /* --- Parâmetros simples (RF-CONFIG-07) ------------------------------------ */ // RF-CONFIG-15: inteiro não-negativo. O `` pode devolver // "" (campo vazio) — nesse estado o Salvar fica travado, o backend recusaria. function IntInput({ valor, onChange }) { return (
{ const cru = e.target.value; if (cru === "") { onChange(""); return; } const n = parseInt(cru, 10); onChange(Number.isFinite(n) ? Math.max(0, n) : ""); }} />
); } function ParameterRow({ parametro, onSalvar, busy }) { const [valor, setValor] = React.useState(parametro.valor); const alterado = JSON.stringify(valor) !== JSON.stringify(parametro.valor); const intInvalido = parametro.tipo === "int" && !Number.isInteger(valor); return ( {parametro.rotulo ? ( <>
{parametro.rotulo}
{parametro.chave}
) : ( {parametro.chave} )} {parametro.descricao || sem descrição} {parametro.tipo === "bool" ? ( ) : parametro.tipo === "int" ? ( ) : ( [o, o])} /> )} onSalvar({ ...parametro, valor })}> Salvar ); } function NovoParametroForm({ onCriar, busy }) { const [chave, setChave] = React.useState(""); const [tipo, setTipo] = React.useState("bool"); const [valorBool, setValorBool] = React.useState(false); const [valorEnum, setValorEnum] = React.useState(""); const [valorInt, setValorInt] = React.useState(0); const [opcoesTexto, setOpcoesTexto] = React.useState(""); const [descricao, setDescricao] = React.useState(""); const [rotulo, setRotulo] = React.useState(""); const listaDeOpcoes = opcoesTexto.split(",").map(o => o.trim()).filter(Boolean); const podeCriar = chave.trim() && ( tipo === "bool" || (tipo === "int" && Number.isInteger(valorInt)) || (tipo === "enum" && listaDeOpcoes.length > 0 && valorEnum) ); const valorPorTipo = { bool: valorBool, int: valorInt, enum: valorEnum }; const submeter = () => { if (!podeCriar || busy) return; onCriar({ chave: chave.trim(), tipo, valor: valorPorTipo[tipo], opcoes: tipo === "enum" ? listaDeOpcoes : null, descricao: descricao.trim(), rotulo: rotulo.trim() || null, }); setChave(""); setDescricao(""); setRotulo(""); setOpcoesTexto(""); setValorEnum(""); setValorBool(false); setValorInt(0); }; return (
{tipo === "bool" ? (
) : tipo === "int" ? (
) : ( <> )}
Adicionar parâmetro
); } function ParametersTab({ onToast }) { const [itens, setItens] = React.useState([]); const [carregando, setCarregando] = React.useState(true); const [erro, setErro] = React.useState(null); const [busy, setBusy] = React.useState(false); const carregar = React.useCallback(() => { setCarregando(true); setErro(null); window.API.listSettings().then(itens => { setItens(itens || []); setCarregando(false); }).catch(erro => { setErro(erro); setCarregando(false); }); }, []); React.useEffect(() => { carregar(); }, [carregar]); const salvar = (parametro) => { setBusy(true); setErro(null); window.API.updateSetting(parametro.chave, { tipo: parametro.tipo, valor: parametro.valor, opcoes: parametro.opcoes, descricao: parametro.descricao, rotulo: parametro.rotulo, }).then(() => { setBusy(false); onToast("Parâmetro salvo."); carregar(); }).catch(erro => { setBusy(false); setErro(erro); }); }; return (
{erro && ( setErro(null)}> {mensagemDeErroDeConfiguracao(erro)} )}
{carregando ? (
Carregando...
) : ( {itens.map(parametro => ( ))}
ChaveDescriçãoValorAções
)} {!carregando && !itens.length && (
Nenhum parâmetro cadastrado ainda.
)}
); } /* --- Segredos / credenciais externas (RF-CONFIG-03 a 14) ------------------ */ function TestResultBadge({ resultado }) { if (!resultado) return nunca testada nesta sessão; if (resultado.sucesso) { return etapa {resultado.etapa === "litellm" ? "LiteLLM" : "provedor"} ok; } return {resultado.mensagem || "falhou"}; } function SecretForm({ segredo, busy, erro, testando, resultadoTeste, onSalvar, onTestar, onCancelar }) { const editando = !!segredo; const [chave, setChave] = React.useState(segredo ? segredo.chave : ""); const [categoria, setCategoria] = React.useState(segredo ? segredo.categoria : "llm"); const [provider, setProvider] = React.useState( segredo ? segredo.provider : PROVIDERS_POR_CATEGORIA.llm[0][0] ); const [roteado, setRoteado] = React.useState(segredo ? segredo.roteado_por_litellm : false); const [modelName, setModelName] = React.useState(segredo && segredo.model_name ? segredo.model_name : ""); const [regiao, setRegiao] = React.useState( segredo && segredo.parametros ? (segredo.parametros.regiao || "") : "" ); const [valor, setValor] = React.useState(""); // Trocar de categoria muda o conjunto de provedores válidos; RF-CONFIG-06 // proíbe TTS/master-key roteados pelo LiteLLM. React.useEffect(() => { const opcoes = PROVIDERS_POR_CATEGORIA[categoria] || []; if (!opcoes.some(([v]) => v === provider)) setProvider(opcoes[0] ? opcoes[0][0] : ""); if (categoria !== "llm" && roteado) setRoteado(false); }, [categoria]); const precisaRegiao = provider === "azure_speech"; const podeTestar = chave.trim() && valor.trim() && !testando; const podeSalvar = chave.trim() && provider && valor.trim() && (!roteado || modelName.trim()) && (!precisaRegiao || regiao.trim()); const submeter = () => { if (!podeSalvar || busy) return; onSalvar(chave.trim(), { categoria, provider, roteado_por_litellm: roteado, model_name: roteado ? modelName.trim() : null, parametros: precisaRegiao ? { regiao: regiao.trim() } : null, valor, }); }; return (
{erro && {mensagemDeErroDeConfiguracao(erro)}}
{categoria === "llm" && (
)}
{roteado && (
)} {precisaRegiao && (
)}
{resultadoTeste && ( {resultadoTeste.sucesso ? "O provedor aceitou a chave." : resultadoTeste.mensagem} )}
onTestar({ chave: chave.trim(), etapa: "provedor", valor, provider })}> {testando ? "Testando..." : "Testar antes de salvar"}
Cancelar {busy ? "Salvando..." : (editando ? "Rotacionar credencial" : "Cadastrar credencial")}
); } function SecretRow({ segredo, onEditar, onTestar, testando, resultadoTeste }) { return (
{segredo.chave}
{segredo.provider} {segredo.roteado_por_litellm && segredo.model_name ? " · " + segredo.model_name : ""}
{NOME_DA_CATEGORIA[segredo.categoria] || segredo.categoria} {segredo.roteado_por_litellm && roteada} {segredo.prefixo}••••••{segredo.sufixo} {new Date(segredo.atualizado_em).toLocaleString("pt-BR")} {testando ? testando... : }
onEditar(segredo)} /> onTestar(segredo, "provedor")} /> {segredo.roteado_por_litellm && ( onTestar(segredo, "litellm")} /> )}
); } function SecretsTab({ onToast }) { const [itens, setItens] = React.useState([]); const [carregando, setCarregando] = React.useState(true); const [erroLista, setErroLista] = React.useState(null); const [editando, setEditando] = React.useState(null); // null | "novo" | segredo const [busy, setBusy] = React.useState(false); const [erroForm, setErroForm] = React.useState(null); const [testandoForm, setTestandoForm] = React.useState(false); const [resultadoForm, setResultadoForm] = React.useState(null); // Resultado do último teste por linha — só nesta sessão de tela. O backend // não guarda "último resultado" no segredo, só em registros de auditoria // (RF-CONFIG-14), que não são o que esta lista consulta. const [resultadosPorChave, setResultadosPorChave] = React.useState({}); const [testandoChave, setTestandoChave] = React.useState(null); const carregar = React.useCallback(() => { setCarregando(true); setErroLista(null); window.API.listSecrets().then(itens => { setItens(itens || []); setCarregando(false); }).catch(erro => { setErroLista(erro); setCarregando(false); }); }, []); React.useEffect(() => { carregar(); }, [carregar]); const salvar = (chave, dados) => { setBusy(true); setErroForm(null); window.API.updateSecret(chave, dados).then(() => { setBusy(false); const eraNovo = editando === "novo"; setEditando(null); setResultadoForm(null); onToast(eraNovo ? "Credencial cadastrada." : "Credencial rotacionada."); carregar(); }).catch(erro => { setBusy(false); setErroForm(erro); }); }; const testarNoFormulario = (pedido) => { setTestandoForm(true); setResultadoForm(null); const { chave, ...corpo } = pedido; window.API.testSecret(chave, corpo).then(resultado => { setTestandoForm(false); setResultadoForm(resultado); }).catch(erro => { setTestandoForm(false); setResultadoForm({ sucesso: false, mensagem: mensagemDeErroDeConfiguracao(erro) }); }); }; const testarNaLista = (segredo, etapa) => { setTestandoChave(segredo.chave); setErroLista(null); window.API.testSecret(segredo.chave, { etapa }).then(resultado => { setTestandoChave(null); setResultadosPorChave(anterior => ({ ...anterior, [segredo.chave]: resultado })); }).catch(erro => { setTestandoChave(null); setResultadosPorChave(anterior => ({ ...anterior, [segredo.chave]: { sucesso: false, etapa, mensagem: mensagemDeErroDeConfiguracao(erro) }, })); }); }; if (editando) { return (

{editando === "novo" ? "Nova credencial" : "Rotacionar " + editando.chave}

O valor nunca é reexibido depois de salvo — só os últimos caracteres, como metadado.

{ setEditando(null); setErroForm(null); setResultadoForm(null); }} />
); } return (
{erroLista && ( setErroLista(null)}> {mensagemDeErroDeConfiguracao(erroLista)} )}
setEditando("novo")}>Nova credencial
{carregando ? (
Carregando...
) : ( {itens.map(segredo => ( ))}
CredencialCategoriaValor Atualizada emÚltimo testeAções
)} {!carregando && !itens.length && (
Nenhuma credencial cadastrada ainda.
)}
); } /* --- Tela --------------------------------------------------------------- */ function SettingsView({ onToast }) { const [aba, setAba] = React.useState("parametros"); return (
Administração

Configurações

Parâmetros do Portal e credenciais externas de IA — sem redeploy.

{aba === "parametros" ? : }
); } Object.assign(window, { SettingsView });