feat: add workspace secrets ui
This commit is contained in:
@@ -2,19 +2,20 @@
|
||||
|
||||
## Current
|
||||
|
||||
### `feat/runtime-upstream-auth`
|
||||
### `feat/secrets-ui`
|
||||
|
||||
Status: completed
|
||||
|
||||
DoD:
|
||||
- runtime resolves `auth_profile_ref` into decrypted secret-backed auth before upstream execution
|
||||
- `admin-api` test-runs and `mcp-server` tool calls use the same auth-aware runtime path
|
||||
- secret usage updates `last_used_at` when auth-backed execution succeeds at resolution time
|
||||
- tests and clippy pass for `crank-runtime`, `crank-registry`, `admin-api`, and `mcp-server`
|
||||
- separate `Secrets` page exists next to `API Keys`
|
||||
- UI supports list/create/rotate/delete for workspace secrets on live backend endpoints
|
||||
- UI shows read-only auth profile usage references for each secret
|
||||
- no UI flow pretends to reveal plaintext after create/rotate when backend does not return it
|
||||
- node checks, UI docker build, and workspace check pass
|
||||
|
||||
## Next
|
||||
|
||||
- `feat/secrets-ui`
|
||||
- `feat/wizard-auth-selector`
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link active" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
@@ -90,6 +91,7 @@
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link active" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link active" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
@@ -106,6 +107,7 @@
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link active" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
@@ -68,6 +69,7 @@
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link active" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
@@ -87,6 +88,7 @@
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link active" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Secrets</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<style>
|
||||
.secret-value-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.secret-inline-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.secret-ref-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.secret-ref-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-canvas);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.secret-ref-pill strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/ui-feedback.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>window.CrankAuth.guardProtectedPage();</script>
|
||||
<script src="js/workspace.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" style="display:none">
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link active" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
<div class="nav-kbd"><svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg><span>⌘K</span></div>
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications"><svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg></button>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="user-menu">
|
||||
<div class="nav-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown-header"><div class="user-dropdown-name">Crank</div><div class="user-dropdown-role">—</div></div>
|
||||
<button class="user-dropdown-item" data-action="settings-link"><svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg><span data-i18n="nav.settings">Settings</span></button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" data-action="logout"><svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg><span data-i18n="nav.logout">Log out</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav">
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link active" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" data-i18n="secrets.title">Secrets</h1>
|
||||
<p class="page-subtitle" data-i18n="secrets.subtitle">Encrypted upstream credentials for bearer tokens, basic auth, and API keys used by auth profiles.</p>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-primary" id="btn-create-secret" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||
<span data-i18n="secrets.new">Create secret</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout info">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
<path d="M8 11V8M8 5.5V5"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong data-i18n="secrets.callout.title">Secret plaintext is write-only.</strong>
|
||||
<span data-i18n="secrets.callout.body">Crank encrypts secret values with the workspace master key. After create or rotate, the API returns only metadata, so this page focuses on lifecycle and usage references.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="margin-top: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="secrets.active.title">Workspace secrets</div>
|
||||
<div class="section-card-subtitle" id="secrets-summary"></div>
|
||||
</div>
|
||||
<div class="filter-bar-search" style="max-width: 240px; margin: 0;">
|
||||
<svg width="13" height="13" style="position:absolute; left:9px; top:50%; transform:translateY(-50%); color:var(--text-muted);" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.749.749 0 01-.326 1.275.749.749 0 01-.734-.215zM11.5 7a4.499 4.499 0 11-8.997 0A4.499 4.499 0 0111.5 7z"/></svg>
|
||||
<input type="text" id="secret-search" data-i18n-ph="secrets.search" placeholder="Filter secrets…" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="padding-top: 0;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="secrets.th.name">Name</th>
|
||||
<th data-i18n="secrets.th.kind">Kind</th>
|
||||
<th data-i18n="secrets.th.version">Version</th>
|
||||
<th data-i18n="secrets.th.last_used">Last used</th>
|
||||
<th data-i18n="secrets.th.used_by">Used by</th>
|
||||
<th data-i18n="secrets.th.status">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="secrets-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="margin-top: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="secrets.profiles.title">Auth profile references</div>
|
||||
<div class="section-card-subtitle" id="secret-profiles-summary" data-i18n="secrets.profiles.subtitle">Profiles resolve secrets at runtime before upstream execution.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body">
|
||||
<div class="resource-list" id="auth-profiles-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="secret-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="secret-modal-title" data-i18n="secrets.modal.create_title">Create secret</span>
|
||||
<button class="modal-close" id="secret-modal-close-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.name">Secret name</label>
|
||||
<input class="field-input" id="secret-name" type="text" autocomplete="off">
|
||||
<div class="field-hint" data-i18n="secrets.modal.name_hint">Use a stable operator-facing label. Plaintext values are never returned after storage.</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.kind">Secret kind</label>
|
||||
<select class="field-select" id="secret-kind">
|
||||
<option value="token" data-i18n="secrets.kind.token">Token</option>
|
||||
<option value="username_password" data-i18n="secrets.kind.username_password">Username/password</option>
|
||||
<option value="header" data-i18n="secrets.kind.header">Header value</option>
|
||||
<option value="generic" data-i18n="secrets.kind.generic">Generic JSON</option>
|
||||
</select>
|
||||
<div class="field-hint" id="secret-kind-hint"></div>
|
||||
</div>
|
||||
|
||||
<div class="secret-value-grid" id="secret-value-fields">
|
||||
<div class="field-group" data-kind-field="string">
|
||||
<label class="field-label" id="secret-string-label" data-i18n="secrets.modal.value">Secret value</label>
|
||||
<input class="field-input" id="secret-string-value" type="text" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field-row" data-kind-field="basic" style="display:none;">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.username">Username</label>
|
||||
<input class="field-input" id="secret-username" type="text" autocomplete="off">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.password">Password</label>
|
||||
<input class="field-input" id="secret-password" type="password" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group" data-kind-field="json" style="display:none;">
|
||||
<label class="field-label" data-i18n="secrets.modal.json_payload">JSON payload</label>
|
||||
<textarea class="field-textarea code-textarea" id="secret-json-value" rows="8" spellcheck="false"></textarea>
|
||||
<div class="field-hint" data-i18n="secrets.modal.json_hint">Useful for generic secret payloads. The value must be valid JSON.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="secret-inline-note" id="secret-modal-note" data-i18n="secrets.modal.create_note">Creation stores encrypted plaintext and returns metadata only.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" id="secret-modal-cancel-btn" type="button" data-i18n="secrets.modal.cancel">Cancel</button>
|
||||
<button class="btn-primary" id="secret-modal-submit-btn" type="button" data-i18n="secrets.modal.create_action">Create secret</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/nav.js"></script>
|
||||
<script src="js/secrets.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -49,6 +49,7 @@
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
@@ -87,6 +88,7 @@
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
@@ -68,6 +69,7 @@
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link active" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
@@ -87,6 +88,7 @@
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link active" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<a href="/" class="nav-link active" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
@@ -93,6 +94,7 @@
|
||||
<a href="/" class="mobile-nav-link active" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
<a href="/" class="nav-link active" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
@@ -98,6 +99,7 @@
|
||||
<a href="/" class="mobile-nav-link active" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
@@ -323,6 +323,24 @@
|
||||
deletePlatformApiKey: function(workspaceId, keyId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId));
|
||||
},
|
||||
listSecrets: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets');
|
||||
},
|
||||
createSecret: function(workspaceId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets', payload);
|
||||
},
|
||||
getSecret: function(workspaceId, secretId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets/' + encodeURIComponent(secretId));
|
||||
},
|
||||
rotateSecret: function(workspaceId, secretId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets/' + encodeURIComponent(secretId) + '/rotate', payload);
|
||||
},
|
||||
deleteSecret: function(workspaceId, secretId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets/' + encodeURIComponent(secretId));
|
||||
},
|
||||
listAuthProfiles: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/auth-profiles');
|
||||
},
|
||||
listLogs: function(workspaceId, params) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs' + query(params));
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
login: '/login',
|
||||
agents: '/agents',
|
||||
apiKeys: '/api-keys',
|
||||
secrets: '/secrets',
|
||||
logs: '/logs',
|
||||
usage: '/usage',
|
||||
streamSessions: '/stream-sessions',
|
||||
|
||||
@@ -9,6 +9,7 @@ var TRANSLATIONS = {
|
||||
'nav.operations': 'Operations',
|
||||
'nav.agents': 'Agents',
|
||||
'nav.apikeys': 'API Keys',
|
||||
'nav.secrets': 'Secrets',
|
||||
'nav.logs': 'Logs',
|
||||
'nav.usage': 'Usage',
|
||||
'nav.settings': 'Settings',
|
||||
@@ -157,6 +158,97 @@ var TRANSLATIONS = {
|
||||
'apikeys.action.delete': 'Delete',
|
||||
'apikeys.creating': 'Creating…',
|
||||
|
||||
// Secrets page
|
||||
'secrets.title': 'Secrets',
|
||||
'secrets.subtitle': 'Encrypted upstream credentials for bearer tokens, basic auth, and API keys used by auth profiles.',
|
||||
'secrets.new': 'Create secret',
|
||||
'secrets.callout.title': 'Secret plaintext is write-only.',
|
||||
'secrets.callout.body': 'Crank encrypts secret values with the workspace master key. After create or rotate, the API returns only metadata, so this page focuses on lifecycle and usage references.',
|
||||
'secrets.active.title': 'Workspace secrets',
|
||||
'secrets.active.subtitle': '{active} active · {total} total',
|
||||
'secrets.search': 'Filter secrets…',
|
||||
'secrets.th.name': 'NAME',
|
||||
'secrets.th.kind': 'KIND',
|
||||
'secrets.th.version': 'VERSION',
|
||||
'secrets.th.last_used': 'LAST USED',
|
||||
'secrets.th.used_by': 'USED BY',
|
||||
'secrets.th.status': 'STATUS',
|
||||
'secrets.kind.token': 'Token',
|
||||
'secrets.kind.username_password': 'Username/password',
|
||||
'secrets.kind.header': 'Header value',
|
||||
'secrets.kind.generic': 'Generic JSON',
|
||||
'secrets.status.active': 'Active',
|
||||
'secrets.status.disabled': 'Disabled',
|
||||
'secrets.last_used.never': 'Never',
|
||||
'secrets.used_by_count': '{count} profiles',
|
||||
'secrets.loading': 'Loading…',
|
||||
'secrets.empty.none': 'No secrets yet',
|
||||
'secrets.empty.search': 'No secrets match your search',
|
||||
'secrets.error.workspace': 'Workspace is not selected',
|
||||
'secrets.error.load': 'Failed to load secrets',
|
||||
'secrets.action.rotate': 'Rotate',
|
||||
'secrets.action.delete': 'Delete',
|
||||
'secrets.confirm.delete': 'Delete secret {name}? This cannot be undone while the secret is still referenced by an auth profile.',
|
||||
'secrets.toast.create_title': 'Secret created',
|
||||
'secrets.toast.create_message': 'The secret was stored encrypted. Plaintext cannot be viewed again through the API.',
|
||||
'secrets.toast.create_error_title': 'Secret creation failed',
|
||||
'secrets.toast.create_error_message': 'Failed to create secret',
|
||||
'secrets.toast.rotate_title': 'Secret rotated',
|
||||
'secrets.toast.rotate_message': 'A new secret version is now active.',
|
||||
'secrets.toast.rotate_error_title': 'Secret rotation failed',
|
||||
'secrets.toast.rotate_error_message': 'Failed to rotate secret',
|
||||
'secrets.toast.delete_title': 'Secret deleted',
|
||||
'secrets.toast.delete_message': '{name} was deleted.',
|
||||
'secrets.toast.delete_error_title': 'Secret deletion failed',
|
||||
'secrets.toast.delete_error_message': 'Failed to delete secret',
|
||||
'secrets.modal.create_title': 'Create secret',
|
||||
'secrets.modal.rotate_title': 'Rotate secret',
|
||||
'secrets.modal.name': 'Secret name',
|
||||
'secrets.modal.name_hint': 'Use a stable operator-facing label. Plaintext values are never returned after storage.',
|
||||
'secrets.modal.kind': 'Secret kind',
|
||||
'secrets.modal.value': 'Secret value',
|
||||
'secrets.modal.header_value': 'Header value',
|
||||
'secrets.modal.username': 'Username',
|
||||
'secrets.modal.password': 'Password',
|
||||
'secrets.modal.json_payload': 'JSON payload',
|
||||
'secrets.modal.json_hint': 'Useful for generic secret payloads. The value must be valid JSON.',
|
||||
'secrets.modal.create_note': 'Creation stores encrypted plaintext and returns metadata only.',
|
||||
'secrets.modal.rotate_note': 'Rotation creates a new encrypted version and keeps the same secret id.',
|
||||
'secrets.modal.cancel': 'Cancel',
|
||||
'secrets.modal.create_action': 'Create secret',
|
||||
'secrets.modal.rotate_action': 'Rotate secret',
|
||||
'secrets.modal.creating': 'Creating…',
|
||||
'secrets.modal.rotating': 'Rotating…',
|
||||
'secrets.hint.token': 'Best for bearer tokens and simple API key values.',
|
||||
'secrets.hint.username_password': 'Stores username and password as one encrypted payload.',
|
||||
'secrets.hint.header': 'Stores a raw header value. Header name is configured in the auth profile.',
|
||||
'secrets.hint.generic': 'Stores arbitrary JSON for advanced future integrations.',
|
||||
'secrets.validation.name_required': 'Secret name is required.',
|
||||
'secrets.validation.value_required': 'Secret value is required.',
|
||||
'secrets.validation.username_password_required': 'Username and password are both required.',
|
||||
'secrets.validation.json_invalid': 'Generic secret payload must be valid JSON.',
|
||||
'secrets.profiles.title': 'Auth profile references',
|
||||
'secrets.profiles.subtitle': 'Profiles resolve secrets at runtime before upstream execution.',
|
||||
'secrets.profiles.subtitle_count': '{count} auth profiles',
|
||||
'secrets.profiles.error_title': 'Unable to load auth profile references',
|
||||
'secrets.profiles.loading_title': 'Loading auth profile references…',
|
||||
'secrets.profiles.loading_body': 'Fetching auth profiles for the current workspace.',
|
||||
'secrets.profiles.empty_title': 'No auth profiles yet',
|
||||
'secrets.profiles.empty_body': 'Create auth profiles in the next slice to bind secrets to upstream authentication.',
|
||||
'secrets.profiles.references': 'Referenced secrets',
|
||||
'secrets.profiles.reference_count': '{count} refs',
|
||||
'secrets.profiles.meta.created': 'Created',
|
||||
'secrets.profiles.meta.updated': 'Updated',
|
||||
'secrets.profiles.summary_bearer': 'Bearer auth via {header} using {secret}.',
|
||||
'secrets.profiles.summary_basic': 'Basic auth using {username} and {password}.',
|
||||
'secrets.profiles.summary_api_key_header': 'API key header {header} using {secret}.',
|
||||
'secrets.profiles.summary_api_key_query': 'API key query param {param} using {secret}.',
|
||||
'secrets.profiles.summary_unknown': 'Unknown auth profile configuration.',
|
||||
'secrets.auth_kind.bearer': 'Bearer auth',
|
||||
'secrets.auth_kind.basic': 'Basic auth',
|
||||
'secrets.auth_kind.api_key_header': 'API key header',
|
||||
'secrets.auth_kind.api_key_query': 'API key query',
|
||||
|
||||
// Logs page
|
||||
'logs.title': 'Logs',
|
||||
'logs.subtitle': 'Real-time invocation log for all operations in this workspace.',
|
||||
@@ -797,6 +889,7 @@ var TRANSLATIONS = {
|
||||
'nav.operations': 'Операции',
|
||||
'nav.agents': 'Агенты',
|
||||
'nav.apikeys': 'API Ключи',
|
||||
'nav.secrets': 'Секреты',
|
||||
'nav.logs': 'Логи',
|
||||
'nav.usage': 'Использование',
|
||||
'nav.settings': 'Настройки',
|
||||
@@ -945,6 +1038,97 @@ var TRANSLATIONS = {
|
||||
'apikeys.action.delete': 'Удалить',
|
||||
'apikeys.creating': 'Создание…',
|
||||
|
||||
// Secrets page
|
||||
'secrets.title': 'Секреты',
|
||||
'secrets.subtitle': 'Зашифрованные upstream-учетные данные для bearer token, basic auth и API key, которые используют auth profiles.',
|
||||
'secrets.new': 'Создать секрет',
|
||||
'secrets.callout.title': 'Plaintext секрета доступен только на запись.',
|
||||
'secrets.callout.body': 'Crank шифрует secret values мастер-ключом воркспейса. После создания или ротации API возвращает только metadata, поэтому эта страница сфокусирована на lifecycle и usage references.',
|
||||
'secrets.active.title': 'Секреты воркспейса',
|
||||
'secrets.active.subtitle': '{active} активных · {total} всего',
|
||||
'secrets.search': 'Фильтр секретов…',
|
||||
'secrets.th.name': 'НАЗВАНИЕ',
|
||||
'secrets.th.kind': 'ТИП',
|
||||
'secrets.th.version': 'ВЕРСИЯ',
|
||||
'secrets.th.last_used': 'ПОСЛЕДНЕЕ ИСПОЛЬЗОВАНИЕ',
|
||||
'secrets.th.used_by': 'ИСПОЛЬЗУЕТСЯ В',
|
||||
'secrets.th.status': 'СТАТУС',
|
||||
'secrets.kind.token': 'Токен',
|
||||
'secrets.kind.username_password': 'Логин/пароль',
|
||||
'secrets.kind.header': 'Значение заголовка',
|
||||
'secrets.kind.generic': 'Общий JSON',
|
||||
'secrets.status.active': 'Активен',
|
||||
'secrets.status.disabled': 'Отключен',
|
||||
'secrets.last_used.never': 'Никогда',
|
||||
'secrets.used_by_count': '{count} профилей',
|
||||
'secrets.loading': 'Загрузка…',
|
||||
'secrets.empty.none': 'Секретов пока нет',
|
||||
'secrets.empty.search': 'Нет секретов по текущему поиску',
|
||||
'secrets.error.workspace': 'Воркспейс не выбран',
|
||||
'secrets.error.load': 'Не удалось загрузить секреты',
|
||||
'secrets.action.rotate': 'Ротировать',
|
||||
'secrets.action.delete': 'Удалить',
|
||||
'secrets.confirm.delete': 'Удалить секрет {name}? Это нельзя сделать, пока на секрет ссылается auth profile.',
|
||||
'secrets.toast.create_title': 'Секрет создан',
|
||||
'secrets.toast.create_message': 'Секрет сохранен в зашифрованном виде. Повторно посмотреть plaintext через API нельзя.',
|
||||
'secrets.toast.create_error_title': 'Не удалось создать секрет',
|
||||
'secrets.toast.create_error_message': 'Не удалось создать секрет',
|
||||
'secrets.toast.rotate_title': 'Секрет ротирован',
|
||||
'secrets.toast.rotate_message': 'Новая версия секрета теперь активна.',
|
||||
'secrets.toast.rotate_error_title': 'Не удалось ротировать секрет',
|
||||
'secrets.toast.rotate_error_message': 'Не удалось ротировать секрет',
|
||||
'secrets.toast.delete_title': 'Секрет удален',
|
||||
'secrets.toast.delete_message': 'Секрет {name} удален.',
|
||||
'secrets.toast.delete_error_title': 'Не удалось удалить секрет',
|
||||
'secrets.toast.delete_error_message': 'Не удалось удалить секрет',
|
||||
'secrets.modal.create_title': 'Создать секрет',
|
||||
'secrets.modal.rotate_title': 'Ротировать секрет',
|
||||
'secrets.modal.name': 'Имя секрета',
|
||||
'secrets.modal.name_hint': 'Используйте стабильную операторскую метку. Plaintext после сохранения не возвращается.',
|
||||
'secrets.modal.kind': 'Тип секрета',
|
||||
'secrets.modal.value': 'Значение секрета',
|
||||
'secrets.modal.header_value': 'Значение заголовка',
|
||||
'secrets.modal.username': 'Логин',
|
||||
'secrets.modal.password': 'Пароль',
|
||||
'secrets.modal.json_payload': 'JSON payload',
|
||||
'secrets.modal.json_hint': 'Подходит для общих secret payloads. Значение должно быть валидным JSON.',
|
||||
'secrets.modal.create_note': 'Создание сохраняет зашифрованный plaintext и возвращает только metadata.',
|
||||
'secrets.modal.rotate_note': 'Ротация создает новую зашифрованную версию и сохраняет тот же secret id.',
|
||||
'secrets.modal.cancel': 'Отмена',
|
||||
'secrets.modal.create_action': 'Создать секрет',
|
||||
'secrets.modal.rotate_action': 'Ротировать секрет',
|
||||
'secrets.modal.creating': 'Создание…',
|
||||
'secrets.modal.rotating': 'Ротация…',
|
||||
'secrets.hint.token': 'Лучший вариант для bearer tokens и простых API key values.',
|
||||
'secrets.hint.username_password': 'Хранит логин и пароль как один зашифрованный payload.',
|
||||
'secrets.hint.header': 'Хранит сырое значение заголовка. Имя заголовка задается в auth profile.',
|
||||
'secrets.hint.generic': 'Хранит произвольный JSON для advanced future integrations.',
|
||||
'secrets.validation.name_required': 'Нужно указать имя секрета.',
|
||||
'secrets.validation.value_required': 'Нужно указать значение секрета.',
|
||||
'secrets.validation.username_password_required': 'Нужно указать и логин, и пароль.',
|
||||
'secrets.validation.json_invalid': 'Generic secret payload должен быть валидным JSON.',
|
||||
'secrets.profiles.title': 'Ссылки auth profiles',
|
||||
'secrets.profiles.subtitle': 'Профили резолвят секреты в runtime перед upstream execution.',
|
||||
'secrets.profiles.subtitle_count': '{count} auth profiles',
|
||||
'secrets.profiles.error_title': 'Не удалось загрузить ссылки auth profiles',
|
||||
'secrets.profiles.loading_title': 'Загрузка ссылок auth profiles…',
|
||||
'secrets.profiles.loading_body': 'Получаем auth profiles для текущего воркспейса.',
|
||||
'secrets.profiles.empty_title': 'Auth profiles пока нет',
|
||||
'secrets.profiles.empty_body': 'В следующем срезе появится UI для привязки секретов к upstream authentication через auth selector.',
|
||||
'secrets.profiles.references': 'Связанные секреты',
|
||||
'secrets.profiles.reference_count': '{count} ссылок',
|
||||
'secrets.profiles.meta.created': 'Создан',
|
||||
'secrets.profiles.meta.updated': 'Обновлен',
|
||||
'secrets.profiles.summary_bearer': 'Bearer auth через {header} с использованием {secret}.',
|
||||
'secrets.profiles.summary_basic': 'Basic auth с использованием {username} и {password}.',
|
||||
'secrets.profiles.summary_api_key_header': 'API key header {header} с использованием {secret}.',
|
||||
'secrets.profiles.summary_api_key_query': 'API key query param {param} с использованием {secret}.',
|
||||
'secrets.profiles.summary_unknown': 'Неизвестная конфигурация auth profile.',
|
||||
'secrets.auth_kind.bearer': 'Bearer auth',
|
||||
'secrets.auth_kind.basic': 'Basic auth',
|
||||
'secrets.auth_kind.api_key_header': 'API key header',
|
||||
'secrets.auth_kind.api_key_query': 'API key query',
|
||||
|
||||
// Logs page
|
||||
'logs.title': 'Логи',
|
||||
'logs.subtitle': 'Живой журнал вызовов по всем операциям этого воркспейса.',
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var state = {
|
||||
workspaceId: null,
|
||||
secrets: [],
|
||||
profiles: [],
|
||||
search: '',
|
||||
loading: false,
|
||||
error: '',
|
||||
modalMode: 'create',
|
||||
modalSecretId: null,
|
||||
};
|
||||
|
||||
var modal = document.getElementById('secret-modal');
|
||||
var tbody = document.getElementById('secrets-tbody');
|
||||
var profilesList = document.getElementById('auth-profiles-list');
|
||||
var summary = document.getElementById('secrets-summary');
|
||||
var profilesSummary = document.getElementById('secret-profiles-summary');
|
||||
var searchInput = document.getElementById('secret-search');
|
||||
var modalTitle = document.getElementById('secret-modal-title');
|
||||
var modalName = document.getElementById('secret-name');
|
||||
var modalKind = document.getElementById('secret-kind');
|
||||
var modalNote = document.getElementById('secret-modal-note');
|
||||
var modalSubmit = document.getElementById('secret-modal-submit-btn');
|
||||
var kindHint = document.getElementById('secret-kind-hint');
|
||||
var stringLabel = document.getElementById('secret-string-label');
|
||||
var stringField = document.getElementById('secret-string-value');
|
||||
var usernameField = document.getElementById('secret-username');
|
||||
var passwordField = document.getElementById('secret-password');
|
||||
var jsonField = document.getElementById('secret-json-value');
|
||||
|
||||
function tKey(key) {
|
||||
return typeof t === 'function' ? t(key) : key;
|
||||
}
|
||||
|
||||
function tfKey(key, vars) {
|
||||
return typeof tf === 'function' ? tf(key, vars) : tKey(key);
|
||||
}
|
||||
|
||||
function currentWorkspaceId() {
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
return workspace ? workspace.id : null;
|
||||
}
|
||||
|
||||
function currentWorkspace() {
|
||||
return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
}
|
||||
|
||||
function secretKindLabel(kind) {
|
||||
return tKey('secrets.kind.' + String(kind || '').toLowerCase());
|
||||
}
|
||||
|
||||
function authKindLabel(kind) {
|
||||
return tKey('secrets.auth_kind.' + String(kind || '').toLowerCase());
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
|
||||
function secretIdsForProfile(profile) {
|
||||
var config = profile && profile.config ? profile.config : {};
|
||||
if (config.bearer) return [config.bearer.secret_id];
|
||||
if (config.basic) return [config.basic.username_secret_id, config.basic.password_secret_id];
|
||||
if (config.api_key_header) return [config.api_key_header.secret_id];
|
||||
if (config.api_key_query) return [config.api_key_query.secret_id];
|
||||
return [];
|
||||
}
|
||||
|
||||
function profileSummary(profile, secretsById) {
|
||||
var config = profile && profile.config ? profile.config : {};
|
||||
if (config.bearer) {
|
||||
return tfKey('secrets.profiles.summary_bearer', {
|
||||
header: config.bearer.header_name,
|
||||
secret: secretName(secretsById, config.bearer.secret_id),
|
||||
});
|
||||
}
|
||||
if (config.basic) {
|
||||
return tfKey('secrets.profiles.summary_basic', {
|
||||
username: secretName(secretsById, config.basic.username_secret_id),
|
||||
password: secretName(secretsById, config.basic.password_secret_id),
|
||||
});
|
||||
}
|
||||
if (config.api_key_header) {
|
||||
return tfKey('secrets.profiles.summary_api_key_header', {
|
||||
header: config.api_key_header.header_name,
|
||||
secret: secretName(secretsById, config.api_key_header.secret_id),
|
||||
});
|
||||
}
|
||||
if (config.api_key_query) {
|
||||
return tfKey('secrets.profiles.summary_api_key_query', {
|
||||
param: config.api_key_query.param_name,
|
||||
secret: secretName(secretsById, config.api_key_query.secret_id),
|
||||
});
|
||||
}
|
||||
return tKey('secrets.profiles.summary_unknown');
|
||||
}
|
||||
|
||||
function secretName(secretsById, secretId) {
|
||||
var secret = secretsById[secretId];
|
||||
return secret ? secret.name : secretId;
|
||||
}
|
||||
|
||||
function usageBySecret() {
|
||||
var usage = {};
|
||||
state.profiles.forEach(function (profile) {
|
||||
secretIdsForProfile(profile).forEach(function (secretId) {
|
||||
if (!secretId) return;
|
||||
if (!usage[secretId]) usage[secretId] = [];
|
||||
usage[secretId].push(profile);
|
||||
});
|
||||
});
|
||||
return usage;
|
||||
}
|
||||
|
||||
function resetModalFields() {
|
||||
modalName.value = '';
|
||||
modalKind.value = 'token';
|
||||
stringField.value = '';
|
||||
usernameField.value = '';
|
||||
passwordField.value = '';
|
||||
jsonField.value = '{\n "value": ""\n}';
|
||||
}
|
||||
|
||||
function openModal(mode, secret) {
|
||||
state.modalMode = mode;
|
||||
state.modalSecretId = secret ? secret.id : null;
|
||||
resetModalFields();
|
||||
|
||||
if (mode === 'rotate' && secret) {
|
||||
modalTitle.textContent = tKey('secrets.modal.rotate_title');
|
||||
modalName.value = secret.name;
|
||||
modalName.disabled = true;
|
||||
modalKind.value = secret.kind;
|
||||
modalKind.disabled = true;
|
||||
modalSubmit.textContent = tKey('secrets.modal.rotate_action');
|
||||
modalNote.textContent = tKey('secrets.modal.rotate_note');
|
||||
} else {
|
||||
modalTitle.textContent = tKey('secrets.modal.create_title');
|
||||
modalName.disabled = false;
|
||||
modalKind.disabled = false;
|
||||
modalSubmit.textContent = tKey('secrets.modal.create_action');
|
||||
modalNote.textContent = tKey('secrets.modal.create_note');
|
||||
}
|
||||
|
||||
updateKindFields();
|
||||
modal.classList.add('open');
|
||||
setTimeout(function () {
|
||||
if (mode === 'rotate') {
|
||||
stringField.focus();
|
||||
} else {
|
||||
modalName.focus();
|
||||
}
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove('open');
|
||||
state.modalMode = 'create';
|
||||
state.modalSecretId = null;
|
||||
}
|
||||
|
||||
function updateKindFields() {
|
||||
var kind = modalKind.value;
|
||||
document.querySelectorAll('[data-kind-field="string"]').forEach(function (element) {
|
||||
element.style.display = kind === 'token' || kind === 'header' ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('[data-kind-field="basic"]').forEach(function (element) {
|
||||
element.style.display = kind === 'username_password' ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('[data-kind-field="json"]').forEach(function (element) {
|
||||
element.style.display = kind === 'generic' ? '' : 'none';
|
||||
});
|
||||
|
||||
if (kind === 'token') {
|
||||
stringLabel.textContent = tKey('secrets.modal.value');
|
||||
} else if (kind === 'header') {
|
||||
stringLabel.textContent = tKey('secrets.modal.header_value');
|
||||
} else {
|
||||
stringLabel.textContent = tKey('secrets.modal.value');
|
||||
}
|
||||
kindHint.textContent = tKey('secrets.hint.' + kind);
|
||||
}
|
||||
|
||||
function buildSecretValue() {
|
||||
var kind = modalKind.value;
|
||||
if (kind === 'token' || kind === 'header') {
|
||||
var value = stringField.value.trim();
|
||||
if (!value) {
|
||||
throw new Error(tKey('secrets.validation.value_required'));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (kind === 'username_password') {
|
||||
var username = usernameField.value.trim();
|
||||
var password = passwordField.value;
|
||||
if (!username || !password) {
|
||||
throw new Error(tKey('secrets.validation.username_password_required'));
|
||||
}
|
||||
return {
|
||||
username: username,
|
||||
password: password,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(jsonField.value);
|
||||
} catch (_error) {
|
||||
throw new Error(tKey('secrets.validation.json_invalid'));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSecrets() {
|
||||
var usage = usageBySecret();
|
||||
var query = state.search.toLowerCase();
|
||||
var rows = state.secrets.filter(function (secret) {
|
||||
return !query || secret.name.toLowerCase().includes(query) || String(secret.kind || '').toLowerCase().includes(query);
|
||||
});
|
||||
|
||||
var active = state.secrets.filter(function (secret) { return secret.status === 'active'; }).length;
|
||||
summary.textContent = tfKey('secrets.active.subtitle', {
|
||||
active: active,
|
||||
total: state.secrets.length,
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (state.loading && state.secrets.length === 0) {
|
||||
var loadingRow = document.createElement('tr');
|
||||
var loadingCell = document.createElement('td');
|
||||
loadingCell.colSpan = 7;
|
||||
loadingCell.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
|
||||
loadingCell.textContent = tKey('secrets.loading');
|
||||
loadingRow.appendChild(loadingCell);
|
||||
tbody.appendChild(loadingRow);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
var errorRow = document.createElement('tr');
|
||||
var errorCell = document.createElement('td');
|
||||
errorCell.colSpan = 7;
|
||||
errorCell.style.cssText = 'text-align:center;padding:36px;color:var(--danger,#f85149);';
|
||||
errorCell.textContent = state.error;
|
||||
errorRow.appendChild(errorCell);
|
||||
tbody.appendChild(errorRow);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
var emptyRow = document.createElement('tr');
|
||||
var emptyCell = document.createElement('td');
|
||||
emptyCell.colSpan = 7;
|
||||
emptyCell.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
|
||||
emptyCell.textContent = state.secrets.length ? tKey('secrets.empty.search') : tKey('secrets.empty.none');
|
||||
emptyRow.appendChild(emptyCell);
|
||||
tbody.appendChild(emptyRow);
|
||||
return;
|
||||
}
|
||||
|
||||
rows.forEach(function (secret) {
|
||||
var tr = document.createElement('tr');
|
||||
var references = usage[secret.id] || [];
|
||||
tr.innerHTML = [
|
||||
'<td class="col-name">' + secret.name + '</td>',
|
||||
'<td>' + secretKindLabel(secret.kind) + '</td>',
|
||||
'<td class="col-mono">v' + secret.current_version + '</td>',
|
||||
'<td>' + (secret.last_used_at ? formatDate(secret.last_used_at) : tKey('secrets.last_used.never')) + '</td>',
|
||||
'<td>' + tfKey('secrets.used_by_count', { count: references.length }) + '</td>',
|
||||
'<td><span class="badge ' + (secret.status === 'disabled' ? 'badge-revoked' : 'badge-active') + '">' + tKey('secrets.status.' + secret.status) + '</span></td>',
|
||||
'<td class="col-actions"></td>'
|
||||
].join('');
|
||||
|
||||
var actions = tr.querySelector('.col-actions');
|
||||
var rotateButton = document.createElement('button');
|
||||
rotateButton.className = 'icon-btn';
|
||||
rotateButton.textContent = tKey('secrets.action.rotate');
|
||||
rotateButton.addEventListener('click', function () {
|
||||
openModal('rotate', secret);
|
||||
});
|
||||
actions.appendChild(rotateButton);
|
||||
|
||||
var deleteButton = document.createElement('button');
|
||||
deleteButton.className = 'icon-btn danger';
|
||||
deleteButton.textContent = tKey('secrets.action.delete');
|
||||
deleteButton.addEventListener('click', async function () {
|
||||
await deleteSecret(secret);
|
||||
});
|
||||
actions.appendChild(deleteButton);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfiles() {
|
||||
var usage = usageBySecret();
|
||||
var secretsById = {};
|
||||
state.secrets.forEach(function (secret) {
|
||||
secretsById[secret.id] = secret;
|
||||
});
|
||||
|
||||
profilesSummary.textContent = tfKey('secrets.profiles.subtitle_count', {
|
||||
count: state.profiles.length,
|
||||
});
|
||||
|
||||
if (state.error) {
|
||||
profilesList.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + tKey('secrets.profiles.error_title') + '</div><div class="empty-state-text">' + state.error + '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.loading && state.profiles.length === 0) {
|
||||
profilesList.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + tKey('secrets.profiles.loading_title') + '</div><div class="empty-state-text">' + tKey('secrets.profiles.loading_body') + '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.profiles.length) {
|
||||
profilesList.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + tKey('secrets.profiles.empty_title') + '</div><div class="empty-state-text">' + tKey('secrets.profiles.empty_body') + '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
profilesList.innerHTML = state.profiles.map(function (profile) {
|
||||
var secretIds = secretIdsForProfile(profile);
|
||||
var references = secretIds.map(function (secretId) {
|
||||
var users = usage[secretId] || [];
|
||||
return '<span class="secret-ref-pill"><strong>' + secretName(secretsById, secretId) + '</strong><span>' + tfKey('secrets.profiles.reference_count', { count: users.length }) + '</span></span>';
|
||||
}).join('');
|
||||
return [
|
||||
'<div class="resource-card">',
|
||||
' <div class="resource-card-header">',
|
||||
' <div>',
|
||||
' <div class="resource-card-title">' + profile.name + '</div>',
|
||||
' <div class="resource-card-subtitle">' + profileSummary(profile, secretsById) + '</div>',
|
||||
' <div class="resource-pill-row">',
|
||||
' <span class="resource-status-pill active">' + authKindLabel(profile.kind) + '</span>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' <div class="resource-meta-grid">',
|
||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('secrets.profiles.meta.created') + '</div><div class="resource-meta-value">' + formatDate(profile.created_at) + '</div></div>',
|
||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('secrets.profiles.meta.updated') + '</div><div class="resource-meta-value">' + formatDate(profile.updated_at) + '</div></div>',
|
||||
' </div>',
|
||||
' <div class="resource-detail-block">',
|
||||
' <div class="resource-detail-title">' + tKey('secrets.profiles.references') + '</div>',
|
||||
' <div class="secret-ref-list">' + references + '</div>',
|
||||
' </div>',
|
||||
'</div>'
|
||||
].join('');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
state.workspaceId = currentWorkspaceId();
|
||||
state.loading = true;
|
||||
state.error = '';
|
||||
renderSecrets();
|
||||
renderProfiles();
|
||||
|
||||
if (!state.workspaceId) {
|
||||
state.loading = false;
|
||||
state.error = tKey('secrets.error.workspace');
|
||||
renderSecrets();
|
||||
renderProfiles();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
window.CrankApi.listSecrets(state.workspaceId),
|
||||
window.CrankApi.listAuthProfiles(state.workspaceId),
|
||||
]);
|
||||
state.secrets = (results[0] && results[0].items) || [];
|
||||
state.profiles = (results[1] && results[1].items) || [];
|
||||
} catch (error) {
|
||||
state.error = error.message || tKey('secrets.error.load');
|
||||
state.secrets = [];
|
||||
state.profiles = [];
|
||||
} finally {
|
||||
state.loading = false;
|
||||
renderSecrets();
|
||||
renderProfiles();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSecret(secret) {
|
||||
if (!confirm(tfKey('secrets.confirm.delete', { name: secret.name }))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await window.CrankApi.deleteSecret(state.workspaceId, secret.id);
|
||||
await load();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tfKey('secrets.toast.delete_message', { name: secret.name }),
|
||||
tKey('secrets.toast.delete_title')
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || tKey('secrets.toast.delete_error_message'),
|
||||
tKey('secrets.toast.delete_error_title')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitModal() {
|
||||
var kind = modalKind.value;
|
||||
var value = buildSecretValue();
|
||||
modalSubmit.disabled = true;
|
||||
modalSubmit.textContent = state.modalMode === 'rotate'
|
||||
? tKey('secrets.modal.rotating')
|
||||
: tKey('secrets.modal.creating');
|
||||
|
||||
try {
|
||||
if (state.modalMode === 'rotate') {
|
||||
await window.CrankApi.rotateSecret(state.workspaceId, state.modalSecretId, { value: value });
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tKey('secrets.toast.rotate_message'),
|
||||
tKey('secrets.toast.rotate_title')
|
||||
);
|
||||
}
|
||||
} else {
|
||||
var name = modalName.value.trim();
|
||||
if (!name) {
|
||||
throw new Error(tKey('secrets.validation.name_required'));
|
||||
}
|
||||
await window.CrankApi.createSecret(state.workspaceId, {
|
||||
name: name,
|
||||
kind: kind,
|
||||
value: value,
|
||||
});
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tKey('secrets.toast.create_message'),
|
||||
tKey('secrets.toast.create_title')
|
||||
);
|
||||
}
|
||||
}
|
||||
closeModal();
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || tKey(state.modalMode === 'rotate' ? 'secrets.toast.rotate_error_message' : 'secrets.toast.create_error_message'),
|
||||
tKey(state.modalMode === 'rotate' ? 'secrets.toast.rotate_error_title' : 'secrets.toast.create_error_title')
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
modalSubmit.disabled = false;
|
||||
modalSubmit.textContent = state.modalMode === 'rotate'
|
||||
? tKey('secrets.modal.rotate_action')
|
||||
: tKey('secrets.modal.create_action');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btn-create-secret').addEventListener('click', function () {
|
||||
openModal('create');
|
||||
});
|
||||
document.getElementById('secret-modal-close-btn').addEventListener('click', closeModal);
|
||||
document.getElementById('secret-modal-cancel-btn').addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', function (event) {
|
||||
if (event.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
modalKind.addEventListener('change', updateKindFields);
|
||||
modalSubmit.addEventListener('click', function () {
|
||||
void submitModal();
|
||||
});
|
||||
searchInput.addEventListener('input', function (event) {
|
||||
state.search = event.target.value || '';
|
||||
renderSecrets();
|
||||
});
|
||||
document.addEventListener('workspace:changed', function () {
|
||||
void load();
|
||||
});
|
||||
|
||||
updateKindFields();
|
||||
void load();
|
||||
});
|
||||
@@ -26,6 +26,10 @@ server {
|
||||
try_files /html/api-keys.html =404;
|
||||
}
|
||||
|
||||
location = /secrets {
|
||||
try_files /html/secrets.html =404;
|
||||
}
|
||||
|
||||
location = /logs {
|
||||
try_files /html/logs.html =404;
|
||||
}
|
||||
@@ -75,6 +79,10 @@ server {
|
||||
return 302 /api-keys;
|
||||
}
|
||||
|
||||
location = /html/secrets.html {
|
||||
return 302 /secrets;
|
||||
}
|
||||
|
||||
location = /html/logs.html {
|
||||
return 302 /logs;
|
||||
}
|
||||
|
||||
@@ -220,6 +220,7 @@ DoD:
|
||||
|
||||
- новая страница `Secrets`;
|
||||
- list/create/rotate/delete;
|
||||
- read-only usage references через `AuthProfile`;
|
||||
- no plaintext leaks after creation.
|
||||
|
||||
### Срез 5. Wizard auth UX
|
||||
@@ -247,4 +248,4 @@ DoD:
|
||||
3. `feat/secrets-ui`
|
||||
4. `feat/wizard-auth-selector`
|
||||
|
||||
Только после этого upstream auth можно считать реально реализованным.
|
||||
Сейчас `feat/secrets-ui` уже закрыт: оператор может управлять encrypted secrets и видеть, какие `AuthProfile` на них ссылаются. Следующий шаг - убрать raw `Auth headers` из wizard primary flow и заменить их на нормальный auth selector и quick-create path для `Secret`/`AuthProfile`.
|
||||
|
||||
Reference in New Issue
Block a user