Expose plugin secret approval UI
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s

This commit is contained in:
2026-10-25 06:14:57 +01:00
parent 7682abdc9f
commit 3d29bd5544
+18 -1
View File
@@ -187,7 +187,7 @@
let secretDescription = '';
let secretValue = '';
let secretError = '';
let plugins: Array<{ id: string; plugin_id: string; name: string; version: string; manifest: { permissions: string[]; api_version: string; entrypoint: string }; enabled: boolean; installed_at: string; updated_at: string }> = [];
let plugins: Array<{ id: string; plugin_id: string; name: string; version: string; manifest: { permissions: string[]; required_secrets?: string[]; api_version: string; entrypoint: string }; enabled: boolean; installed_at: string; updated_at: string }> = [];
let pluginError = '';
let pluginBusy = false;
let pluginDropActive = false;
@@ -1122,6 +1122,22 @@
}
}
async function bindPluginSecret(plugin: (typeof plugins)[number]) {
const secretName = window.prompt('Enter the existing secret name to approve for this plugin:');
if (!secretName) return;
pluginBusy = true;
pluginError = '';
try {
const response = await fetch(`${serverAddress}/api/v1/admin/plugins/${encodeURIComponent(plugin.plugin_id)}/secrets`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body: JSON.stringify({ secret_name: secretName, approved: true }) });
if (!response.ok) throw new Error('The plugin secret binding could not be approved.');
pluginError = `Approved ${secretName} for ${plugin.name}. Enable the plugin when all requirements are satisfied.`;
} catch (error) {
pluginError = error instanceof Error ? error.message : 'The plugin secret binding could not be approved.';
} finally {
pluginBusy = false;
}
}
async function removePlugin(plugin: (typeof plugins)[number]) {
if (!window.confirm(`Remove plugin ${plugin.name}?`)) return;
pluginBusy = true;
@@ -1456,6 +1472,7 @@
</section>
{:else if activeView === 'Plugins'}
<section class="plugins-page panel"><div class="panel-header"><span>WEBASSEMBLY PLUGINS</span><button class="secondary" onclick={loadPlugins}>REFRESH</button></div><div class="settings-body"><p class="settings-note">Packages are validated and started in a resource-limited WebAssembly runtime. Only explicitly declared Trixie permissions are accepted; installation never grants filesystem or process access.</p><label class:drop-active={pluginDropActive} class="plugin-drop-zone" ondragover={(event) => { event.preventDefault(); pluginDropActive = true; }} ondragleave={() => (pluginDropActive = false)} ondrop={handlePluginDrop}><strong>DROP PLUGIN PACKAGE HERE</strong><span>or choose a `.trixie-plugin` JSON package</span><input class="visually-hidden" type="file" accept=".trixie-plugin,.json,application/json" onchange={handlePluginFileInput} /></label><div class="overlay-controls"><label class="primary plugin-file-button">CHOOSE PACKAGE<input class="visually-hidden" type="file" accept=".trixie-plugin,.json,application/json" onchange={handlePluginFileInput} /></label></div>{#if pluginError}<div class="auth-error">{pluginError}</div>{/if}{#if plugins.length}<div class="plugin-list">{#each plugins as plugin}<article class="plugin-row"><div><strong>{plugin.name}</strong><span class="bot-command-description">{plugin.plugin_id} · v{plugin.version}</span><div class="micro">API {plugin.manifest.api_version} · {plugin.manifest.permissions.length ? plugin.manifest.permissions.join(' · ') : 'NO PERMISSIONS'}</div></div><div class="bot-command-meta"><span class:good={plugin.enabled}>{plugin.enabled ? 'ENABLED' : 'DISABLED'}</span><button class="secondary-action" onclick={() => setPluginEnabled(plugin)} disabled={pluginBusy}>{plugin.enabled ? 'DISABLE' : 'ENABLE'}</button><button class="secondary-action" onclick={() => rollbackPlugin(plugin)} disabled={pluginBusy}>ROLLBACK</button><button class="secondary-action danger" onclick={() => removePlugin(plugin)} disabled={pluginBusy}>REMOVE</button></div></article>{/each}</div>{:else}<div class="empty-row">No plugins are installed.</div>{/if}</div></section>
<section class="panel plugin-secrets-panel"><div class="panel-header"><span>PLUGIN SECRET APPROVAL</span></div><div class="settings-body"><p class="settings-note">Plugins cannot read secrets until an administrator explicitly approves a binding. The secret value is never shown here.</p>{#if plugins.some((plugin) => plugin.manifest.required_secrets?.length)}<div class="bot-command-list">{#each plugins.filter((plugin) => plugin.manifest.required_secrets?.length) as plugin}<article class="bot-command-row"><div><strong>{plugin.name}</strong><span class="bot-command-description">Required: {plugin.manifest.required_secrets?.join(' · ')}</span></div><button class="secondary-action" onclick={() => bindPluginSecret(plugin)} disabled={pluginBusy}>APPROVE SECRET</button></article>{/each}</div>{:else}<div class="empty-row">Installed plugins have no secret requirements.</div>{/if}</div></section>
{:else if activeView === 'Bot'}
<section class="bot-command-editor panel"><div class="panel-header"><span>{commandFormId ? 'EDIT COMMAND' : 'NEW COMMAND'}</span><button class="secondary" onclick={resetCommandForm}>CLEAR</button></div><div class="settings-body command-form"><div class="command-form-grid"><label class="micro" for="command-name">NAME</label><input id="command-name" class="control" bind:value={commandFormName} maxlength="64" placeholder="uptime" /><label class="micro" for="command-description">DESCRIPTION</label><input id="command-description" class="control" bind:value={commandFormDescription} maxlength="500" placeholder="What this command does" /><label class="micro" for="command-permission">PERMISSION</label><select id="command-permission" class="control" bind:value={commandFormPermission}><option value="everyone">EVERYONE</option><option value="subscriber">SUBSCRIBER</option><option value="moderator">MODERATOR</option><option value="broadcaster">BROADCASTER</option></select><label class="micro" for="command-aliases">ALIASES</label><input id="command-aliases" class="control" bind:value={commandFormAliases} placeholder="alias1, alias2" /><label class="micro" for="command-tags">TAGS</label><input id="command-tags" class="control" bind:value={commandFormTags} placeholder="utility, fun" /><label class="micro" for="command-cooldown">COOLDOWN (SECONDS)</label><input id="command-cooldown" class="control" type="number" min="0" max="86400" bind:value={commandFormCooldown} /><label class="micro" for="command-cooldown-scope">COOLDOWN SCOPE</label><select id="command-cooldown-scope" class="control" bind:value={commandFormCooldownScope}><option value="global">GLOBAL</option><option value="user">PER USER</option><option value="role">PER ROLE</option></select><label class="micro" for="command-response">RESPONSE TEMPLATE</label><textarea id="command-response" class="control command-template" bind:value={commandFormResponse} oninput={() => updateCommandVariableSuggestions(commandFormResponse)} maxlength="2000" placeholder="Hello ${user_name}"></textarea>{#if commandVariableSuggestions.length}<div class="command-variable-picker" role="listbox" aria-label="Command variables">{#each commandVariableSuggestions as variable}<button class="secondary-action" type="button" onclick={() => insertCommandVariable(variable)}>${`{${variable}}`}</button>{/each}</div>{/if}<label class="micro" for="command-error-response">ERROR RESPONSE (OPTIONAL)</label><textarea id="command-error-response" class="control command-template" bind:value={commandFormErrorResponse} maxlength="2000" placeholder="Something went wrong."></textarea></div><label class="overlay-enabled"><input type="checkbox" bind:checked={commandFormEnabled} /> ENABLED</label><div class="command-simulation panel"><div class="micro">SIMULATION</div><p class="bot-secret-note">Preview with literal sample data. Real requests are opt-in and use the same server-side safety checks.</p><textarea class="control command-template" bind:value={commandSimulationSampleResponse} aria-label="Sample JSON response" placeholder="status ok"></textarea><div class="command-simulation-actions"><button class="secondary" type="button" onclick={() => simulateCommand(false)} disabled={commandSimulationBusy || !commandFormResponse}>SAMPLE REQUEST</button><button class="secondary" type="button" onclick={() => simulateCommand(true)} disabled={commandSimulationBusy || !commandFormResponse}>REAL REQUEST</button></div>{#if commandSimulationError}<div class="auth-error">{commandSimulationError}</div>{/if}{#if commandSimulationResult}<div class="command-simulation-result"><strong>{commandSimulationResult.rendered ?? commandSimulationResult.error ?? 'Simulation completed.'}</strong>{#if commandSimulationResult.extracted_variables && Object.keys(commandSimulationResult.extracted_variables).length}<span>Extracted: {formatExtractedVariables(commandSimulationResult.extracted_variables)}</span>{/if}</div>{/if}</div><button class="primary control-button" onclick={saveCommand} disabled={commandFormBusy || !commandFormName || !commandFormDescription || !commandFormResponse}>{commandFormBusy ? 'SAVING…' : commandFormId ? 'SAVE COMMAND' : 'CREATE COMMAND'}</button></div></section>
<section class="bot-page panel"><div class="panel-header"><span>BOT COMMANDS</span><button class="secondary" onclick={loadBotCommands}>REFRESH</button></div><div class="bot-page-body"><div class="micro">PUBLIC COMMAND CATALOG</div>{#if botCommandError}<div class="auth-error">{botCommandError}</div>{:else if botCommands.length}<div class="bot-command-list">{#each botCommands as command}<article class="bot-command-row"><div><strong>!{command.name}</strong><span class="bot-command-description">{command.description}</span>{#if command.tags.length}<div class="micro">{command.tags.join(' · ')}</div>{/if}</div><div class="bot-command-meta"><span>{command.permission_level.toUpperCase()}</span>{#if command.cooldown_seconds}<span>{command.cooldown_seconds}s / {command.cooldown_scope}</span>{/if}</div></article>{/each}</div>{:else}<div class="empty-state"><strong>No enabled commands</strong><span>Create commands on the server to make them available here.</span></div>{/if}<div class="bot-secret-section"><div class="micro">SECRETS MANAGEMENT</div><div class="bot-secret-note">Values are encrypted and cannot be read after saving.</div><div class="secret-form"><input class="control" bind:value={secretName} maxlength="100" placeholder="Name" /><input class="control" bind:value={secretDescription} maxlength="500" placeholder="Description" /><input class="control" type="password" bind:value={secretValue} maxlength="65536" placeholder="Value" /><button class="primary" onclick={createSecret} disabled={!secretName || !secretValue}>ADD SECRET</button></div>{#if botSecrets.length}<div class="bot-secret-list">{#each botSecrets as secret}<div class="bot-secret-row"><div><strong>{secret.name}</strong><span>{secret.description || 'No description'}</span>{#if secret.used_by.length}<div class="micro">USED BY: {secret.used_by.join(', ')}</div>{/if}</div><button class="secondary-action" onclick={() => deleteSecret(secret.id)}>DELETE</button></div>{/each}</div>{:else}<div class="empty-row">There are no secrets yet.</div>{/if}{#if secretError}<div class="auth-error">{secretError}</div>{/if}</div></div></section>