diff --git a/TASKS.md b/TASKS.md index 905abda..3d77439 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/apps/ui/html/agents.html b/apps/ui/html/agents.html index ef7e6c0..1cefcc1 100644 --- a/apps/ui/html/agents.html +++ b/apps/ui/html/agents.html @@ -51,6 +51,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -90,6 +91,7 @@ Operations Agents API Keys + Secrets Logs Usage diff --git a/apps/ui/html/api-keys.html b/apps/ui/html/api-keys.html index c211252..bd07007 100644 --- a/apps/ui/html/api-keys.html +++ b/apps/ui/html/api-keys.html @@ -63,6 +63,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -106,6 +107,7 @@ Operations Agents API Keys + Secrets Logs Usage diff --git a/apps/ui/html/async-jobs.html b/apps/ui/html/async-jobs.html index 401ab4a..2aa5872 100644 --- a/apps/ui/html/async-jobs.html +++ b/apps/ui/html/async-jobs.html @@ -44,6 +44,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -68,6 +69,7 @@ Operations Agents API Keys + Secrets Logs Usage diff --git a/apps/ui/html/logs.html b/apps/ui/html/logs.html index 515ef97..889db3f 100644 --- a/apps/ui/html/logs.html +++ b/apps/ui/html/logs.html @@ -49,6 +49,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -87,6 +88,7 @@ Operations Agents API Keys + Secrets Logs Usage diff --git a/apps/ui/html/secrets.html b/apps/ui/html/secrets.html new file mode 100644 index 0000000..2b08fac --- /dev/null +++ b/apps/ui/html/secrets.html @@ -0,0 +1,242 @@ + + + + + + + Crank — Secrets + + + + + + + + + + + + + + + + +
+ Operations + Agents + API Keys + Secrets + Logs + Usage +
+ +
+ + +
+ + + + +
+ Secret plaintext is write-only. + 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. +
+
+ +
+
+
+
Workspace secrets
+
+
+ +
+
+ + + + + + + + + + + + + +
NameKindVersionLast usedUsed byStatus
+
+
+ +
+
+
+
Auth profile references
+
Profiles resolve secrets at runtime before upstream execution.
+
+
+
+
+
+
+
+ + + + + + + diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html index b327b30..3100b6a 100644 --- a/apps/ui/html/settings.html +++ b/apps/ui/html/settings.html @@ -49,6 +49,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -87,6 +88,7 @@ Operations Agents API Keys + Secrets Logs Usage diff --git a/apps/ui/html/stream-sessions.html b/apps/ui/html/stream-sessions.html index 223fb44..3890dbb 100644 --- a/apps/ui/html/stream-sessions.html +++ b/apps/ui/html/stream-sessions.html @@ -44,6 +44,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -68,6 +69,7 @@ Operations Agents API Keys + Secrets Logs Usage diff --git a/apps/ui/html/usage.html b/apps/ui/html/usage.html index 9db8a9c..888f3ea 100644 --- a/apps/ui/html/usage.html +++ b/apps/ui/html/usage.html @@ -49,6 +49,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -87,6 +88,7 @@ Operations Agents API Keys + Secrets Logs Usage diff --git a/apps/ui/html/wizard/index.html b/apps/ui/html/wizard/index.html index 38e864c..416a8d2 100644 --- a/apps/ui/html/wizard/index.html +++ b/apps/ui/html/wizard/index.html @@ -50,6 +50,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -93,6 +94,7 @@ Operations Agents API Keys + Secrets Logs Usage diff --git a/apps/ui/index.html b/apps/ui/index.html index fd7b99f..fcb97c8 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -49,6 +49,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -98,6 +99,7 @@ Operations Agents API Keys + Secrets Logs Usage diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index c5907f9..760439c 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -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)); }, diff --git a/apps/ui/js/config.js b/apps/ui/js/config.js index 1467fff..98b80bc 100644 --- a/apps/ui/js/config.js +++ b/apps/ui/js/config.js @@ -6,6 +6,7 @@ login: '/login', agents: '/agents', apiKeys: '/api-keys', + secrets: '/secrets', logs: '/logs', usage: '/usage', streamSessions: '/stream-sessions', diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js index 613d177..79b1452 100644 --- a/apps/ui/js/i18n.js +++ b/apps/ui/js/i18n.js @@ -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': 'Живой журнал вызовов по всем операциям этого воркспейса.', diff --git a/apps/ui/js/secrets.js b/apps/ui/js/secrets.js new file mode 100644 index 0000000..8b6de32 --- /dev/null +++ b/apps/ui/js/secrets.js @@ -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 = [ + '' + secret.name + '', + '' + secretKindLabel(secret.kind) + '', + 'v' + secret.current_version + '', + '' + (secret.last_used_at ? formatDate(secret.last_used_at) : tKey('secrets.last_used.never')) + '', + '' + tfKey('secrets.used_by_count', { count: references.length }) + '', + '' + tKey('secrets.status.' + secret.status) + '', + '' + ].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 = '
' + tKey('secrets.profiles.error_title') + '
' + state.error + '
'; + return; + } + + if (state.loading && state.profiles.length === 0) { + profilesList.innerHTML = '
' + tKey('secrets.profiles.loading_title') + '
' + tKey('secrets.profiles.loading_body') + '
'; + return; + } + + if (!state.profiles.length) { + profilesList.innerHTML = '
' + tKey('secrets.profiles.empty_title') + '
' + tKey('secrets.profiles.empty_body') + '
'; + return; + } + + profilesList.innerHTML = state.profiles.map(function (profile) { + var secretIds = secretIdsForProfile(profile); + var references = secretIds.map(function (secretId) { + var users = usage[secretId] || []; + return '' + secretName(secretsById, secretId) + '' + tfKey('secrets.profiles.reference_count', { count: users.length }) + ''; + }).join(''); + return [ + '
', + '
', + '
', + '
' + profile.name + '
', + '
' + profileSummary(profile, secretsById) + '
', + '
', + ' ' + authKindLabel(profile.kind) + '', + '
', + '
', + '
', + '
', + '
' + tKey('secrets.profiles.meta.created') + '
' + formatDate(profile.created_at) + '
', + '
' + tKey('secrets.profiles.meta.updated') + '
' + formatDate(profile.updated_at) + '
', + '
', + '
', + '
' + tKey('secrets.profiles.references') + '
', + '
' + references + '
', + '
', + '
' + ].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(); +}); diff --git a/apps/ui/nginx.conf b/apps/ui/nginx.conf index 744d223..51dc179 100644 --- a/apps/ui/nginx.conf +++ b/apps/ui/nginx.conf @@ -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; } diff --git a/docs/secrets-auth-plan.md b/docs/secrets-auth-plan.md index 75e7ce8..4827322 100644 --- a/docs/secrets-auth-plan.md +++ b/docs/secrets-auth-plan.md @@ -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`.