feat: let administrators view and copy encrypted integration credentials

This commit is contained in:
2026-09-27 13:48:26 +08:00
parent 5555a7b342
commit d54bdd9847
38 changed files with 905 additions and 71 deletions

View File

@@ -3,6 +3,7 @@
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE', helpCredential: '' };
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效';
let tokenRequest = 0, tokenTarget = null;
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : error.message; }
async function api(path = '', body) {
const response = await fetch(`/console/applications${path}`, { method: body ? 'POST' : 'GET', credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json', 'X-Orbit-Console': '1' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
@@ -11,11 +12,11 @@
if (!response.ok) { const error = new Error(value.error?.message || `请求失败 (${response.status})`); error.status = response.status; throw error; }
return value.data;
}
function config(token = '<填入已保存的 Token>', queue = $('access-queue').value, namespace = ui.selected) {
function config(token = '••••••••', queue = $('access-queue').value, namespace = ui.selected) {
const url = $('access-url').value.trim() || location.origin;
let parsed; try { parsed = new URL(url); } catch { throw new Error('请输入完整的 HTTP / HTTPS 服务地址'); }
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址');
return `ORBIT_URL=${url.replace(/\/$/, '')}\nORBIT_TOKEN=${token}\nORBIT_NAMESPACE_ID=${namespace}\nORBIT_QUEUE_ID=${queue}\n`;
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\;&|<>(){}]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址');
return `ORBIT_URL=${url.replace(/\/$/, '')}\nORBIT_APPLICATION_ID=${ui.detail?.entry.application_id || ''}\nORBIT_NAMESPACE_ID=${namespace}\nORBIT_QUEUE_ID=${queue}\nORBIT_TOKEN=${token}\n`;
}
async function load() {
if (ui.loading || ui.busy) return;
@@ -24,7 +25,7 @@
const page = await api(ui.after ? `?after=${encodeURIComponent(ui.after)}` : ''); ui.next = page.next || ''; $('access-next').disabled = !ui.next;
$('access-list').innerHTML = page.items.length ? page.items.map(entry => `<button class="access-item ${entry.namespace_id === ui.selected ? 'selected' : ''}" data-access-id="${escape(entry.namespace_id)}"><span class="access-avatar">${escape(entry.application.slice(0, 1).toUpperCase())}</span><span><strong>${escape(entry.application)}</strong><small>${escape(entry.environment)} / ${escape(entry.namespace)} · ${entry.enabled ? '已启用' : '已停用'}</small></span></button>`).join('') : '<p class="panel-description">还没有接入应用,点击右上角新建。</p>';
if (!ui.selected && page.items.length) await select(page.items[0].namespace_id);
else if (ui.selected && !$('access-dialog').open) await select(ui.selected);
else if (ui.selected && !$('access-dialog').open && !$('access-token-dialog').open) await select(ui.selected);
} catch (error) { $('access-error').textContent = message(error); }
finally { ui.loading = false; }
}
@@ -46,12 +47,13 @@
const blocked = !!c.revoked_at || transitioning;
const helpID = `access-rotation-help-${c.id}`;
const reason = c.revoked_at ? '凭据已撤销,不能再次换证。' : '旧 Token 仍在过渡期,暂不能再次换证,以免中断尚未切换的服务。';
const rotationHelp = transitioning ? `<aside class="access-rotation-notice" id="${escape(helpID)}"><strong>旧 Token 仍在过渡期</strong><p>${escape(reason)}</p><p>过渡截止:<time datetime="${escape(c.previous_valid_until)}">${escape(time(c.previous_valid_until))}</time>。到期后,换证按钮随页面刷新恢复可用。</p><p>请用已保存的新 Token 更新服务、Worker 和结果消费者配置。确认全部切换、在途任务收尾后,也可提前点击「结束过渡,停用旧 Token」。</p></aside>` : c.revoked_at ? `<p id="${escape(helpID)}">${escape(reason)}</p>` : c.previous_valid_until ? `<p>旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。</p>` : '';
const rotationHelp = transitioning ? `<aside class="access-rotation-notice" id="${escape(helpID)}"><strong>旧 Token 仍在过渡期</strong><p>${escape(reason)}</p><p>过渡截止:<time datetime="${escape(c.previous_valid_until)}">${escape(time(c.previous_valid_until))}</time>。到期后,换证按钮随页面刷新恢复可用。</p><p>请在上方复制当前接入配置,更新服务、Worker 和结果消费者。确认全部切换、在途任务收尾后,也可提前点击「结束过渡,停用旧 Token」。</p></aside>` : c.revoked_at ? `<p id="${escape(helpID)}">${escape(reason)}</p>` : c.previous_valid_until ? `<p>旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。</p>` : '';
const groups = [['tasks:submit', '任务'], ['workers:consume', 'Worker 执行'], ['events:consume', '结果订阅'], ['schedules:manage', '定时调度'], ['workflows:start', '流程编排'], ['tasks:review', '人工恢复'], ['application:resources:manage', '应用额度']].filter(([action]) => c.permissions.actions.includes(action)).map(([,name]) => name);
return `<section class="access-credential">
<div class="access-credential-header"><b>${escape(c.name)} · ${status}</b><button class="text-button ${blocked ? 'access-rotation-blocked' : ''}" ${blocked ? `data-access-help="${escape(c.id)}" title="${escape(reason)}" aria-describedby="${escape(helpID)}"` : `data-access-rotate="${escape(c.id)}"`} aria-haspopup="dialog">${c.revoked_at ? '已撤销 · 查看原因 ⓘ' : transitioning ? '暂不可换证 · 查看原因 ⓘ' : '平滑换证 ↗'}</button></div>
<p>凭据 ID:${escape(c.id)}<br>有效期:${escape(time(c.expires_at))} · 凭据版本 ${escape(c.revision)}</p>
<div class="access-token-summary"><div><strong>当前 Token</strong><span>原文仅在生成时显示,无法回看</span></div><button type="button" class="text-button" data-access-help="${escape(c.id)}" aria-haspopup="dialog">Token 在哪里查看?</button></div>
<div class="access-token-summary"><div><strong>接入配置 · 当前 Token</strong><span>${c.revoked_at ? '凭据已撤销' : c.token_stored ? '•••••••••••••••• · 已加密保管' : '历史凭据 · 导入当前 Token 后可随时查看'}</span></div></div>
<div class="access-form-actions"><button type="button" class="primary-button compact" data-access-token="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>${c.token_stored ? '查看接入信息' : '导入当前 Token'}</button><button type="button" class="secondary-button compact" data-access-copy-config="${escape(c.id)}" ${c.revoked_at || !c.token_stored ? 'disabled' : ''}>复制接入配置 ↗</button></div>
${rotationHelp}
<div class="access-form-actions"><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div>
<div class="access-permissions">${groups.map(a => `<span>${escape(a)}</span>`).join('')}</div><details class="access-permission-details"><summary>查看具体权限</summary><p>${c.permissions.actions.map(escape).join(' · ')}<br>授权队列:${escape(c.permissions.queues.join(', '))}</p></details>
@@ -62,6 +64,43 @@
} catch (error) { $('access-error').textContent = message(error); }
}
function renderConfig() { try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } }
function closeToken() {
tokenRequest++; tokenTarget = null;
$('access-token-import-submit').disabled = false;
$('access-token-config').value = ''; $('access-token-input').value = '';
$('access-token-dialog').close();
}
async function showToken(credential, copyImmediately = false) {
closeToken();
tokenTarget = { namespace: ui.selected, credential, queue: $('access-queue').value };
// 只导出该凭据已授权的队列,不能拼出无法使用的配置。
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(tokenTarget.queue)) tokenTarget.queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || '';
$('access-token-scope').textContent = `${ui.detail.entry.application} · ${ui.detail.entry.environment} / ${ui.detail.entry.namespace} · 凭据版本 ${credential.revision}`;
$('access-token-error').textContent = '';
$('access-token-import').hidden = credential.token_stored;
$('access-token-output').hidden = true;
$('access-token-dialog').showModal();
if (!ui.detail.token_vault_enabled) { $('access-token-error').textContent = '服务端尚未配置 Token 保管密钥,请联系部署管理员。'; return; }
if (credential.token_stored) await readToken(false, copyImmediately);
else $('access-token-input').focus();
}
async function readToken(importCurrent, copyImmediately = false) {
const target = tokenTarget; if (!target) return;
const request = ++tokenRequest;
$('access-token-error').textContent = '正在读取接入配置…'; $('access-token-import-submit').disabled = true;
try {
if (!target.queue) throw new Error('没有可用的授权队列,请先配置队列权限。');
const value = await api(`/${encodeURIComponent(target.namespace)}/credentials/${encodeURIComponent(target.credential.id)}/token/${importCurrent ? 'import' : 'reveal'}`, { expected_revision: String(target.credential.revision), ...(importCurrent ? { token: $('access-token-input').value.trim() } : {}) });
if (request !== tokenRequest || ui.selected !== target.namespace || !$('access-token-dialog').open) return;
if (value.namespace_id !== target.namespace || value.credential_id !== target.credential.id || String(value.revision) !== String(target.credential.revision)) throw new Error('凭据已更新,请刷新后重试。');
$('access-token-config').value = config(value.token, target.queue, target.namespace);
$('access-token-import').hidden = true; $('access-token-input').value = '';
$('access-token-output').hidden = false; $('access-token-error').textContent = '';
if (importCurrent) target.credential.token_stored = true;
if (copyImmediately) await copy($('access-token-config').value, $('access-token-copy'));
} catch (error) { if (request === tokenRequest) $('access-token-error').textContent = message(error); }
finally { if (request === tokenRequest) $('access-token-import-submit').disabled = false; }
}
function renderCredentialHelp() {
const credential = ui.detail?.credentials.find(c => c.id === ui.helpCredential);
if (!credential) { $('access-help-dialog').close(); return; }
@@ -96,7 +135,7 @@
}
function close() {
if (ui.busy) return;
if ((ui.pending || !ui.saved) && !window.confirm(ui.pending ? '操作结果尚未确认,请先保存待核对操作。仍要关闭吗?' : '请确认已保存完整 SDK 配置。关闭后无法再次查看 Token;如果还在换证过渡期,也不能立即再次换证。仍要关闭吗?')) return;
if (ui.pending && !window.confirm('操作结果尚未确认,请先保存待核对操作。仍要关闭吗?')) return;
$('access-dialog').close(); ui.pending = null; ui.result = null; ui.saved = true; $('access-result-config').value = ''; load();
}
function download(name, content, type = 'text/plain') {
@@ -104,7 +143,7 @@
}
async function copy(value, button) {
try { await navigator.clipboard.writeText(value); const old = button.textContent; button.textContent = '已复制 ✓'; setTimeout(() => { button.textContent = old; }, 2000); return true; }
catch { ($('access-dialog').open ? $('access-clipboard-status') : $('access-error')).textContent = '浏览器未允许复制,请选中配置后手动复制,或下载 .env 文件。'; return false; }
catch { ($('access-token-dialog').open ? $('access-token-error') : $('access-dialog').open ? $('access-clipboard-status') : $('access-error')).textContent = '浏览器未允许复制,请选中配置后手动复制,或下载 .env 文件。'; return false; }
}
async function submit(event) {
event.preventDefault(); if (ui.busy) return; ui.busy = true; $('access-form-error').textContent = '';
@@ -121,7 +160,7 @@
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
const op = ui.pending;
await api('/apply', op);
ui.saved = !op.token;
ui.saved = true;
let queue = op.queue_id || $('access-queue').value;
// 先确认当前凭据版本,旧操作回执不能冒充仍有效的最新 Token。
await select(op.namespace_id);
@@ -146,21 +185,29 @@
}
}
$('access-url').value = location.origin;
$('access-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) select(el.dataset.accessId); });
$('access-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) { closeToken(); select(el.dataset.accessId); } });
$('access-credentials').addEventListener('click', e => {
const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize]'); if (!el || el.disabled) return;
const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize;
const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize],[data-access-token],[data-access-copy-config]'); if (!el || el.disabled) return;
const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize || el.dataset.accessToken || el.dataset.accessCopyConfig;
const credential = ui.detail?.credentials.find(c => c.id === id);
if (credential && (el.dataset.accessToken || el.dataset.accessCopyConfig)) { showToken(credential, !!el.dataset.accessCopyConfig); return; }
if (credential && el.dataset.accessHelp) { showCredentialHelp(credential); return; }
if (credential && !credential.revoked_at) open(credential, el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE');
});
$('access-help-close').addEventListener('click', () => $('access-help-dialog').close());
$('access-help-done').addEventListener('click', () => $('access-help-dialog').close());
$('access-token-close').addEventListener('click', () => { closeToken(); load(); });
$('access-token-dialog').addEventListener('cancel', e => { e.preventDefault(); closeToken(); load(); });
$('access-token-import').addEventListener('submit', e => { e.preventDefault(); readToken(true); });
$('access-token-copy').addEventListener('click', () => copy($('access-token-config').value, $('access-token-copy')));
$('access-token-download').addEventListener('click', () => download('orbit.env', $('access-token-config').value));
window.addEventListener('hashchange', closeToken);
window.addEventListener('pagehide', closeToken);
$('access-create').addEventListener('click', () => open()); $('access-close').addEventListener('click', close);
$('access-dialog').addEventListener('cancel', e => { e.preventDefault(); close(); });
$('access-refresh').addEventListener('click', load); $('access-next').addEventListener('click', () => { ui.after = ui.next; load(); }); $('access-first').addEventListener('click', () => { ui.after = ''; load(); });
$('access-url').addEventListener('input', renderConfig); $('access-queue').addEventListener('change', renderConfig);
$('access-copy-template').addEventListener('click', async () => { try { await copy(config(), $('access-copy-template')); } catch (error) { $('access-error').textContent = error.message; } });
$('access-copy-template').addEventListener('click', () => { const credential = ui.detail?.credentials.find(c => !c.revoked_at); if (credential) showToken(credential); });
$('access-form').addEventListener('submit', submit);
$('access-copy').addEventListener('click', async () => { if (await copy($('access-result-config').value, $('access-copy'))) ui.saved = true; });
$('access-download').addEventListener('click', () => { download('orbit.env', $('access-result-config').value); ui.saved = true; });