From dfc53f1a5fd8736ffa2fe73fb9b03669be348f90 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 14 Jul 2026 20:41:40 +0800 Subject: [PATCH] feat: add global secrets view --- plugins/secrets/frontend/src/index.js | 132 +++++++++++++++++++++++--- plugins/secrets/locales/en.json | 7 ++ plugins/secrets/locales/ru.json | 7 ++ plugins/secrets/plugin.json | 18 ++++ scripts/smoke-secrets-plugin.js | 56 +++++++++++ 5 files changed, 209 insertions(+), 11 deletions(-) diff --git a/plugins/secrets/frontend/src/index.js b/plugins/secrets/frontend/src/index.js index 91cc3f4..fe226e3 100644 --- a/plugins/secrets/frontend/src/index.js +++ b/plugins/secrets/frontend/src/index.js @@ -23,6 +23,7 @@ '.secrets-panel{min-height:0;overflow:auto;border-right:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629)}', '.secrets-main{min-width:0;min-height:0;overflow:auto;padding:1rem;background:var(--vt-color-background,#101020)}', '.secrets-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.65rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46)}', + '.secrets-filters{display:grid;grid-template-columns:minmax(0,1fr) minmax(8rem,.8fr);gap:.4rem;padding:.5rem .55rem;border-bottom:1px solid var(--vt-color-border,#202b46)}', '.secrets-title{font-weight:600;font-size:.88rem}', '.secrets-count{color:var(--vt-color-text-muted,#7f8aa3);font-size:.76rem}', '.secrets-spacer{flex:1}', @@ -44,10 +45,11 @@ '.secrets-form{display:grid;gap:.65rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);padding:.9rem;background:var(--vt-color-surface,#15152c)}', '.secrets-row{display:grid;grid-template-columns:8rem minmax(0,1fr);gap:.65rem;align-items:center}', '.secrets-label{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3)}', - '.secrets-input,.secrets-textarea,.secrets-select{width:100%;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.84rem;padding:.45rem .55rem;outline:none}', + '.secrets-input,.secrets-textarea,.secrets-select,.secrets-search{width:100%;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.84rem;padding:.45rem .55rem;outline:none}', + '.secrets-search{font-size:.76rem}', '.secrets-select{appearance:none;background-color:#0d1117;background-image:linear-gradient(45deg,transparent 50%,#8b949e 50%),linear-gradient(135deg,#8b949e 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 11px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:2rem}.secrets-select option{background:#0d1117;color:var(--vt-color-text-primary,#f4f7fb)}', '.secrets-textarea{min-height:6rem;resize:vertical;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}', - '.secrets-input:focus,.secrets-textarea:focus,.secrets-select:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}', + '.secrets-input:focus,.secrets-textarea:focus,.secrets-select:focus,.secrets-search:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}', '.secrets-actions{display:flex;gap:.5rem;flex-wrap:wrap}', '.secrets-status{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3);min-height:1rem}', '.secrets-status.error{color:#ffc6ce}', @@ -58,7 +60,7 @@ '.secrets-table th{width:9rem;color:var(--vt-color-text-muted,#7f8aa3);font-weight:500;background:var(--vt-color-surface-muted,#111629)}', '.secrets-table td{color:var(--vt-color-text-primary,#f4f7fb);overflow-wrap:anywhere}', '.secrets-table tr:last-child th,.secrets-table tr:last-child td{border-bottom:0}', - '@media(max-width:780px){.secrets-root{grid-template-columns:1fr}.secrets-panel{border-right:0;border-bottom:1px solid #252b36;max-height:45vh}.secrets-row{grid-template-columns:1fr}}' + '@media(max-width:780px){.secrets-root{grid-template-columns:1fr}.secrets-panel{border-right:0;border-bottom:1px solid #252b36;max-height:45vh}.secrets-row,.secrets-filters{grid-template-columns:1fr}}' ].join('\n'); function el(tag, attrs, children) { @@ -97,7 +99,7 @@ function scopeLabel(record) { var scope = record && record.scope || {}; - if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || 'Workspace'; + if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || 'Deal'; return 'Global'; } @@ -145,6 +147,9 @@ var workspaceRoot = workspaceFromProps(props || {}); var selectedID = selectedIDFromProps(props || {}); var records = []; + var workspaceOptions = []; + var scopeFilter = 'all'; + var searchQuery = ''; var selectedRecord = null; var selectedValue = ''; var initialized = false; @@ -162,6 +167,59 @@ render(); } + function recordWorkspaceRoot(record) { + var scope = record && record.scope || {}; + return scope.kind === ScopeWorkspace ? cleanWorkspace(scope.workspaceRootPath) : ''; + } + + function workspaceRoots() { + var seen = {}; + var values = []; + workspaceOptions.concat(records.map(recordWorkspaceRoot)).forEach(function (value) { + value = cleanWorkspace(value); + if (!value || seen[value]) return; + seen[value] = true; + values.push(value); + }); + return values.sort(function (a, b) { return a.localeCompare(b); }); + } + + function filteredRecords() { + var query = text(searchQuery).trim().toLowerCase(); + return records.filter(function (record) { + var recordWorkspace = recordWorkspaceRoot(record); + if (scopeFilter === ScopeGlobal && recordWorkspace) return false; + if (scopeFilter.indexOf(ScopeWorkspace + ':') === 0 && recordWorkspace !== scopeFilter.slice((ScopeWorkspace + ':').length)) return false; + if (!query) return true; + return [record.title, record.id, record.username, recordWorkspace].some(function (value) { + return text(value).toLowerCase().indexOf(query) !== -1; + }); + }); + } + + function clearHiddenSelection() { + if (!selectedRecord) return; + var visible = filteredRecords(); + if (visible.some(function (record) { return record.id === selectedRecord.id; })) return; + selectedRecord = null; + selectedValue = ''; + } + + function loadWorkspaceOptions() { + if (!api || !api.files || typeof api.files.list !== 'function') return Promise.resolve(); + return api.files.list('').then(function (items) { + workspaceOptions = (Array.isArray(items) ? items : []).filter(function (item) { + return text(item && item.type).toLowerCase() === 'folder'; + }).map(function (item) { + return cleanWorkspace(item.relativePath || item.name); + }).filter(function (value) { + return value && value.indexOf('/') === -1; + }); + }).catch(function () { + workspaceOptions = []; + }); + } + function renderLocked() { var passwordInput = el('input', { className: 'secrets-input', @@ -227,19 +285,48 @@ } function renderList() { + var visibleRecords = filteredRecords(); + var scopeSelect = el('select', { + className: 'secrets-select', + 'data-secret-scope-filter': '', + onChange: function (event) { + scopeFilter = text(event.target.value) || 'all'; + clearHiddenSelection(); + render(); + } + }, [ + el('option', { value: 'all' }, [tr('ui.scopeAll', null, 'All scopes')]), + el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')]) + ].concat(workspaceRoots().map(function (workspace) { + return el('option', { value: ScopeWorkspace + ':' + workspace }, [workspace]); + }))); + scopeSelect.value = scopeFilter; + var searchInput = el('input', { + className: 'secrets-search', + type: 'search', + 'data-secret-search': '', + placeholder: tr('ui.search', null, 'Search secrets'), + value: searchQuery, + onInput: function (event) { + searchQuery = text(event.target.value); + clearHiddenSelection(); + render(); + } + }); var children = [ el('div', { className: 'secrets-toolbar' }, [ el('span', { className: 'secrets-title' }, [tr('ui.title', null, 'Secrets')]), - el('span', { className: 'secrets-count' }, [String(records.length)]), + el('span', { className: 'secrets-count' }, [String(visibleRecords.length)]), el('span', { className: 'secrets-spacer' }), el('button', { className: 'secrets-btn', type: 'button', onClick: showNewSecret }, [tr('ui.new', null, 'New')]) - ]) + ]), + el('div', { className: 'secrets-filters' }, [searchInput, scopeSelect]) ]; - if (!records.length) { + if (!visibleRecords.length) { children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')])); return children; } - groupRecords(records).forEach(function (group) { + groupRecords(visibleRecords).forEach(function (group) { children.push(el('div', { className: 'secrets-group' }, [group.label])); children.push(el('div', { className: 'secrets-list' }, group.records.map(function (record) { var active = selectedRecord && selectedRecord.id === record.id; @@ -318,9 +405,26 @@ value.value = isEdit ? selectedValue : ''; var scope = el('select', { className: 'secrets-select' }, [ el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')]), - el('option', { value: ScopeWorkspace }, [workspaceRoot || tr('ui.workspace', null, 'Workspace')]) + el('option', { value: ScopeWorkspace }, [tr('ui.deal', null, 'Deal')]) ]); + scope.setAttribute('data-secret-scope', ''); + var existingWorkspace = existing ? recordWorkspaceRoot(existing) : ''; + var defaultWorkspace = existingWorkspace || workspaceRoot || workspaceRoots()[0] || ''; + var workspace = el('select', { className: 'secrets-select', 'data-secret-workspace': '' }, [ + el('option', { value: '' }, [tr('ui.chooseWorkspace', null, 'Choose a Deal')]) + ].concat(workspaceRoots().map(function (item) { + return el('option', { value: item }, [item]); + }))); + workspace.value = defaultWorkspace; + var workspaceRow = el('div', { className: 'secrets-row' }, [ + el('label', { className: 'secrets-label' }, [tr('ui.deal', null, 'Deal')]), workspace + ]); + function updateWorkspaceVisibility() { + workspaceRow.hidden = scope.value !== ScopeWorkspace; + } + scope.addEventListener('change', updateWorkspaceVisibility); scope.value = existing && existing.scope && existing.scope.kind ? existing.scope.kind : (workspaceRoot ? ScopeWorkspace : ScopeGlobal); + updateWorkspaceVisibility(); return el('div', { className: 'secrets-card' }, [ el('h2', {}, [isEdit ? tr('ui.editSecret', null, 'Edit secret') : tr('ui.newSecret', null, 'New secret')]), el('div', { className: 'secrets-form' }, [ @@ -328,6 +432,7 @@ el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['ID']), id]), el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.username', null, 'Username')]), username]), el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.scope', null, 'Scope')]), scope]), + workspaceRow, el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.value', null, 'Value')]), value]), el('div', { className: 'secrets-actions' }, [ el('button', { @@ -336,12 +441,17 @@ 'data-secret-save': '', onClick: function () { var nextID = text(id.value).trim() || text(title.value).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '.').replace(/^\.+|\.+$/g, ''); + var targetWorkspace = cleanWorkspace(workspace.value); + if (scope.value === ScopeWorkspace && !targetWorkspace) { + setStatus(tr('ui.workspaceRequired', null, 'Choose a Deal for this secret.'), true); + return; + } api.secrets.write({ id: nextID, title: text(title.value).trim() || nextID, username: text(username.value).trim(), value: text(value.value), - scope: scope.value === ScopeWorkspace ? { kind: ScopeWorkspace, workspaceRootPath: workspaceRoot } : { kind: ScopeGlobal } + scope: scope.value === ScopeWorkspace ? { kind: ScopeWorkspace, workspaceRootPath: targetWorkspace } : { kind: ScopeGlobal } }).then(function (record) { selectedID = record.id; selectedRecord = record; @@ -456,7 +566,7 @@ api.secrets.status().then(function (status) { initialized = !!(status && status.initialized); unlocked = !!(status && status.unlocked); - if (unlocked) return loadRecords(); + if (unlocked) return loadWorkspaceOptions().then(loadRecords); render(); }).catch(function (err) { statusText = (err && err.message) ? err.message : String(err); diff --git a/plugins/secrets/locales/en.json b/plugins/secrets/locales/en.json index 5b2f3ff..31dd6ef 100644 --- a/plugins/secrets/locales/en.json +++ b/plugins/secrets/locales/en.json @@ -1,6 +1,8 @@ { "manifest.name": "Secrets", "manifest.description": "Encrypted global and workspace-scoped secret manager.", + "contributions.views.verstak.secrets.view.title": "Secrets", + "contributions.sidebarItems.verstak.secrets.sidebar.title": "Secrets", "contributions.openProviders.verstak.secrets.secret.title": "Secrets", "contributions.settingsPanels.verstak.secrets.settings.title": "Secrets", "contributions.workspaceItems.verstak.secrets.workspace.title": "Secrets", @@ -31,6 +33,11 @@ "ui.editSecret": "Edit secret", "ui.newSecret": "New secret", "ui.scope": "Scope", + "ui.deal": "Deal", + "ui.scopeAll": "All scopes", + "ui.search": "Search secrets", + "ui.chooseWorkspace": "Choose a Deal", + "ui.workspaceRequired": "Choose a Deal for this secret.", "ui.value": "Value", "ui.save": "Save", "ui.cancel": "Cancel", diff --git a/plugins/secrets/locales/ru.json b/plugins/secrets/locales/ru.json index bdb4415..043c678 100644 --- a/plugins/secrets/locales/ru.json +++ b/plugins/secrets/locales/ru.json @@ -1,6 +1,8 @@ { "manifest.name": "Секреты", "manifest.description": "Зашифрованное хранилище общих секретов и секретов рабочих пространств.", + "contributions.views.verstak.secrets.view.title": "Секреты", + "contributions.sidebarItems.verstak.secrets.sidebar.title": "Секреты", "contributions.openProviders.verstak.secrets.secret.title": "Секреты", "contributions.settingsPanels.verstak.secrets.settings.title": "Секреты", "contributions.workspaceItems.verstak.secrets.workspace.title": "Секреты", @@ -31,6 +33,11 @@ "ui.editSecret": "Изменить секрет", "ui.newSecret": "Новый секрет", "ui.scope": "Область", + "ui.deal": "Дело", + "ui.scopeAll": "Все области", + "ui.search": "Поиск секретов", + "ui.chooseWorkspace": "Выберите дело", + "ui.workspaceRequired": "Выберите дело для этого секрета.", "ui.value": "Значение", "ui.save": "Сохранить", "ui.cancel": "Отмена", diff --git a/plugins/secrets/plugin.json b/plugins/secrets/plugin.json index c07c632..5470867 100644 --- a/plugins/secrets/plugin.json +++ b/plugins/secrets/plugin.json @@ -14,6 +14,7 @@ "secrets.write-ui" ], "permissions": [ + "files.read", "secrets.read", "secrets.write", "ui.register" @@ -22,6 +23,23 @@ "entry": "frontend/src/index.js" }, "contributes": { + "views": [ + { + "id": "verstak.secrets.view", + "title": "Secrets", + "icon": "key-round", + "component": "SecretsView" + } + ], + "sidebarItems": [ + { + "id": "verstak.secrets.sidebar", + "title": "Secrets", + "icon": "key-round", + "view": "verstak.secrets.view", + "position": 45 + } + ], "openProviders": [ { "id": "verstak.secrets.secret", diff --git a/scripts/smoke-secrets-plugin.js b/scripts/smoke-secrets-plugin.js index 11f3189..3a18c07 100755 --- a/scripts/smoke-secrets-plugin.js +++ b/scripts/smoke-secrets-plugin.js @@ -129,8 +129,11 @@ async function flush() { if (!manifest.provides.includes('secrets.write-ui')) throw new Error('secrets manifest must provide secrets.write-ui'); if (!manifest.permissions.includes('secrets.read')) throw new Error('secrets manifest must request secrets.read'); if (!manifest.permissions.includes('secrets.write')) throw new Error('secrets manifest must request secrets.write'); + if (!manifest.permissions.includes('files.read')) throw new Error('secrets manifest must request files.read for the Deal selector'); if (!manifest.permissions.includes('ui.register')) throw new Error('secrets manifest must request ui.register'); if (!(manifest.contributes.openProviders || []).some((item) => (item.supports || []).some((support) => support.kind === 'secret'))) throw new Error('secrets secret open provider missing'); + if (!(manifest.contributes.views || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets global view missing'); + if (!(manifest.contributes.sidebarItems || []).some((item) => item.view === 'verstak.secrets.view')) throw new Error('secrets global sidebar item missing'); if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets workspace item missing'); if (!(manifest.contributes.settingsPanels || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets settings panel missing'); @@ -177,6 +180,12 @@ async function flush() { clipboard: { writeText: async (text) => copied.push(text), }, + files: { + list: async () => [ + { type: 'folder', relativePath: 'ClientA' }, + { type: 'folder', relativePath: 'ClientB' }, + ], + }, }; const container = document.createElement('div'); @@ -228,6 +237,53 @@ async function flush() { await flush(); if (!deleted.includes('client-a.db')) throw new Error('secret delete was not called'); + records.push({ id: 'client-a.global-view', title: 'Client A Global View', username: 'app', scope: { kind: 'workspace', workspaceRootPath: 'ClientA' }, updatedAt: '2026-06-29T00:00:00Z' }); + const globalContainer = document.createElement('div'); + component.mount(globalContainer, {}, api); + await flush(); + + if (!globalContainer.textContent.includes('Global Server') || !globalContainer.textContent.includes('Client A Global View')) { + throw new Error('global secrets view did not show global and Deal-scoped records'); + } + const scopeFilter = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-scope-filter') === ''); + if (!scopeFilter) throw new Error('global secrets scope filter missing'); + scopeFilter.value = 'workspace:ClientA'; + scopeFilter.dispatchEvent('change'); + await flush(); + if (!globalContainer.textContent.includes('Client A Global View') || globalContainer.textContent.includes('Global Server')) { + throw new Error('global secrets scope filter did not isolate the selected Deal'); + } + const searchInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-search') === ''); + if (!searchInput) throw new Error('global secrets search missing'); + searchInput.value = 'no-match'; + searchInput.dispatchEvent('input'); + await flush(); + if (!globalContainer.textContent.includes('No secrets')) throw new Error('global secrets search did not filter records'); + + scopeFilter.value = 'all'; + scopeFilter.dispatchEvent('change'); + await flush(); + const newButton = walk(globalContainer, (node) => node.tagName === 'BUTTON' && node.textContent === 'New'); + if (!newButton) throw new Error('global secrets new button missing'); + newButton.click(); + await flush(); + const globalTitleInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-title') === ''); + const globalValueInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-value') === ''); + const scopeInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-scope') === ''); + const workspaceInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-workspace') === ''); + const globalSaveButton = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-save') === ''); + if (!globalTitleInput || !globalValueInput || !scopeInput || !workspaceInput || !globalSaveButton) throw new Error('global Deal-scoped secret form controls missing'); + globalTitleInput.value = 'Client B API'; + globalValueInput.value = 'client-b-api-value'; + scopeInput.value = 'workspace'; + scopeInput.dispatchEvent('change'); + workspaceInput.value = 'ClientB'; + globalSaveButton.click(); + await flush(); + if (!records.some((record) => record.title === 'Client B API' && record.scope && record.scope.workspaceRootPath === 'ClientB')) { + throw new Error('global secrets form did not create a Deal-scoped secret'); + } + console.log('secrets plugin smoke passed'); })().catch((err) => { console.error(err);