feat: support permanent credentials and overlapping token rotation
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true };
|
||||
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE' };
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效';
|
||||
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : error.message; }
|
||||
@@ -42,22 +42,30 @@
|
||||
$('access-credentials').innerHTML = detail.credentials.map(c => {
|
||||
const status = c.revoked_at ? '已撤销' : c.expires_at && new Date(c.expires_at) <= new Date() ? '已到期' : '有效';
|
||||
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" data-access-rotate="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>重新生成 Token ↗</button></div><p>凭据 ID:${escape(c.id)}<br>有效期:${escape(time(c.expires_at))} · 版本 ${escape(c.revision)}<br>Token 原文不保存在数据库;已有服务继续使用原配置。</p><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></section>`;
|
||||
return `<section class="access-credential"><div class="access-credential-header"><b>${escape(c.name)} · ${status}</b><button class="text-button" data-access-rotate="${escape(c.id)}" ${c.revoked_at || c.previous_valid_until && new Date(c.previous_valid_until) > new Date() ? 'disabled' : ''}>平滑换证 ↗</button></div><p>凭据 ID:${escape(c.id)}<br>有效期:${escape(time(c.expires_at))} · 版本 ${escape(c.revision)}<br>Token 原文不保存在数据库;已有服务继续使用原配置。</p>${c.previous_valid_until ? `<p>旧 Token ${new Date(c.previous_valid_until) > new Date() ? '过渡截止' : '已失效于'}:${escape(time(c.previous_valid_until))}</p>` : ''}<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></section>`;
|
||||
}).join('') || '<p class="panel-description">该环境暂无凭据。</p>';
|
||||
document.querySelectorAll('[data-access-id]').forEach(el => el.classList.toggle('selected', el.dataset.accessId === namespace)); renderConfig();
|
||||
} catch (error) { $('access-error').textContent = message(error); }
|
||||
}
|
||||
function renderConfig() { try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } }
|
||||
function open(credential = null) {
|
||||
function open(credential = null, mode = 'ROTATE') {
|
||||
if (ui.busy) return;
|
||||
ui.rotating = credential; ui.pending = null; ui.result = null; ui.saved = true;
|
||||
ui.rotating = credential; ui.mode = credential ? mode : 'CREATE'; ui.pending = null; ui.result = null; ui.saved = true;
|
||||
$('access-form').reset(); $('access-form').querySelectorAll('input,select,button').forEach(el => { el.disabled = false; }); $('access-form').hidden = false; $('access-result').hidden = true; $('access-backup').hidden = true; $('access-form-error').textContent = '';
|
||||
$('access-result-config').value = ''; $('access-clipboard-status').textContent = '';
|
||||
$('access-create-fields').hidden = !!credential;
|
||||
$('access-create-fields').querySelectorAll('input,select').forEach(el => { el.disabled = !!credential; });
|
||||
$('access-form-title').textContent = credential ? '重新生成 Token' : '新建应用';
|
||||
$('access-form-note').textContent = credential ? `正在更新 ${ui.detail.entry.application} 的凭据。确认后旧 Token 立即失效,请同步更新业务服务配置;应用、队列、订阅和历史记录保持原有归属。` : '为应用创建独立的环境、队列与调用凭据。';
|
||||
$('access-submit').textContent = credential ? '确认重新生成 →' : '创建并生成凭据 →';
|
||||
const titles = { CREATE: '新建应用', ROTATE: '平滑换证', SET_EXPIRY: '调整有效期', FINALIZE: '结束过渡' };
|
||||
$('access-form-title').textContent = titles[ui.mode];
|
||||
$('access-valid-field').hidden = ui.mode === 'FINALIZE';
|
||||
$('access-grace-field').hidden = ui.mode !== 'ROTATE';
|
||||
$('access-form-note').textContent = {
|
||||
CREATE: '为应用创建独立的环境、队列与调用凭据,默认长期有效。',
|
||||
ROTATE: '生成新 Token 后,旧 Token 在过渡期内继续可用。逐台更新服务配置并正常收尾在途任务;确认全部切换后,再结束过渡。',
|
||||
SET_EXPIRY: '只调整当前 Token 的有效期,Token 和业务配置保持不变。已有旧 Token 的过渡截止不会延长。',
|
||||
FINALIZE: '此操作立即停用旧 Token,新 Token 继续有效。请先确认全部服务、Worker 和结果消费者已切换,在途执行已收尾。'
|
||||
}[ui.mode];
|
||||
$('access-submit').textContent = ui.mode === 'CREATE' ? '创建并生成凭据 →' : `确认${titles[ui.mode]} →`;
|
||||
$('access-dialog').showModal(); (credential ? $('access-reason') : $('access-name')).focus();
|
||||
}
|
||||
function close() {
|
||||
@@ -77,7 +85,8 @@
|
||||
const form = $('access-form'); const disabled = [...form.elements].map(el => [el, el.disabled]);
|
||||
try {
|
||||
if (!ui.pending) {
|
||||
const input = { kind: ui.rotating ? 'ROTATE' : 'CREATE', valid_days: Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' };
|
||||
const input = { kind: ui.mode, valid_days: ui.mode === 'FINALIZE' ? 0 : Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' };
|
||||
if (ui.mode === 'ROTATE') input.grace_days = Number($('access-grace-days').value);
|
||||
if (ui.rotating) Object.assign(input, { namespace_id: ui.selected, credential_id: ui.rotating.id });
|
||||
else Object.assign(input, { application: $('access-name').value.trim(), namespace: $('access-namespace').value.trim(), environment: $('access-environment').value, queue: $('access-queue-name').value.trim(), capabilities: [...document.querySelectorAll('.access-capabilities input:checked')].map(el => el.value) });
|
||||
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||
@@ -86,7 +95,7 @@
|
||||
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||
const op = ui.pending;
|
||||
await api('/apply', op);
|
||||
ui.saved = false;
|
||||
ui.saved = !op.token;
|
||||
let queue = op.queue_id || $('access-queue').value;
|
||||
// 先确认当前凭据版本,旧操作回执不能冒充仍有效的最新 Token。
|
||||
await select(op.namespace_id);
|
||||
@@ -94,12 +103,13 @@
|
||||
const expected = op.input.kind === 'CREATE' ? '1' : String(BigInt(op.input.expected_revision) + 1n);
|
||||
if (!credential || String(credential.revision) !== expected || credential.revoked_at) throw new Error('操作已提交,但凭据已被更新或暂时无法核对,请刷新应用详情;不要使用旧操作中的 Token。');
|
||||
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(queue)) queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || '';
|
||||
const preparedConfig = config(op.token, queue, op.namespace_id);
|
||||
const preparedConfig = op.token ? config(op.token, queue, op.namespace_id) : '';
|
||||
ui.result = op; ui.pending = null;
|
||||
$('access-form').hidden = true; $('access-result').hidden = false;
|
||||
$('access-result-title').textContent = op.input.kind === 'CREATE' ? '应用已创建,可以接入' : '新 Token 已生效';
|
||||
$('access-result-title').textContent = { CREATE: '应用已创建,可以接入', ROTATE: '新 Token 已生效', SET_EXPIRY: '有效期已更新,原 Token 继续使用', FINALIZE: '过渡已结束,旧 Token 已停用' }[op.input.kind];
|
||||
$('access-result-note').hidden = !op.token; $('access-result-config-field').hidden = !op.token; $('access-result-actions').hidden = !op.token;
|
||||
$('access-result-config').value = preparedConfig;
|
||||
$('access-result-expiry').textContent = `有效期至 ${time(op.expires_at)}。业务服务读取这些配置后交给 SDK。`;
|
||||
$('access-result-expiry').textContent = `当前 Token:${time(credential.expires_at)}。${credential.previous_valid_until ? `旧 Token 截止:${time(credential.previous_valid_until)},请在此之前完成切换。` : '无旧 Token 过渡。'}`;
|
||||
} catch (error) {
|
||||
$('access-form-error').textContent = `${message(error)}${ui.pending ? '。保留当前窗口,点击“重试确认”会使用原操作核对结果。' : ''}`;
|
||||
} finally {
|
||||
@@ -110,7 +120,12 @@
|
||||
}
|
||||
$('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-credentials').addEventListener('click', e => { const el = e.target.closest('[data-access-rotate]'); const credential = ui.detail?.credentials.find(c => c.id === el?.dataset.accessRotate); if (credential && !credential.revoked_at) open(credential); });
|
||||
$('access-credentials').addEventListener('click', e => {
|
||||
const el = e.target.closest('[data-access-rotate],[data-access-expiry],[data-access-finalize]'); if (!el || el.disabled) return;
|
||||
const id = el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize;
|
||||
const credential = ui.detail?.credentials.find(c => c.id === id);
|
||||
if (credential && !credential.revoked_at) open(credential, el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE');
|
||||
});
|
||||
$('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(); });
|
||||
|
||||
Reference in New Issue
Block a user