feat: add single-admin application onboarding and token rotation

This commit is contained in:
2026-09-27 10:18:31 +08:00
parent 98ff028228
commit 87aa710013
24 changed files with 1341 additions and 25 deletions

View File

@@ -0,0 +1,125 @@
(() => {
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 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 }) : '长期有效';
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) } : {}) });
const value = await response.json().catch(() => ({}));
if (response.status === 401) location.replace(`/console/login?return=${encodeURIComponent('/console/#/applications')}`);
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) {
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`;
}
async function load() {
if (ui.loading || ui.busy) return;
ui.loading = true; $('access-error').textContent = '';
try {
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);
} catch (error) { $('access-error').textContent = message(error); }
finally { ui.loading = false; }
}
async function select(namespace) {
const requested = namespace; ui.selected = namespace;
try {
const detail = await api(`/${encodeURIComponent(namespace)}`); if (ui.selected !== requested) return; ui.detail = detail;
$('access-empty').hidden = true; $('access-detail').hidden = false;
$('access-title').textContent = detail.entry.application; $('access-status').textContent = detail.entry.enabled ? '已启用' : '已停用';
$('access-app-id').textContent = detail.entry.application_id; $('access-ns-id').textContent = namespace; $('access-scope').textContent = `${detail.entry.environment} / ${detail.entry.namespace}`;
const previous = $('access-queue').value;
$('access-queue').innerHTML = detail.queues.map(q => `<option value="${escape(q.id)}">${escape(q.name)} · ${escape(q.id.slice(0, 8))}</option>`).join('');
if (detail.queues.some(q => q.id === previous)) $('access-queue').value = previous;
$('access-truncated').hidden = !detail.truncated;
$('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>`;
}).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) {
if (ui.busy) return;
ui.rotating = credential; 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 ? '确认重新生成 →' : '创建并生成凭据 →';
$('access-dialog').showModal(); (credential ? $('access-reason') : $('access-name')).focus();
}
function close() {
if (ui.busy) return;
if ((ui.pending || !ui.saved) && !window.confirm(ui.pending ? '操作结果尚未确认,请先保存待核对操作。仍要关闭吗?' : '请确认已保存新 Token。关闭后无法再次查看原文。')) 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') {
const url = URL.createObjectURL(new Blob([content], { type })); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
}
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; }
}
async function submit(event) {
event.preventDefault(); if (ui.busy) return; ui.busy = true; $('access-form-error').textContent = '';
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' };
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; });
ui.pending = await api('/prepare', input);
}
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
const op = ui.pending;
await api('/apply', op);
ui.saved = false;
let queue = op.queue_id || $('access-queue').value;
// 先确认当前凭据版本,旧操作回执不能冒充仍有效的最新 Token。
await select(op.namespace_id);
const credential = ui.detail?.entry.namespace_id === op.namespace_id && ui.detail.credentials.find(c => c.id === op.credential_id);
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);
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-config').value = preparedConfig;
$('access-result-expiry').textContent = `有效期至 ${time(op.expires_at)}。业务服务读取这些配置后交给 SDK。`;
} catch (error) {
$('access-form-error').textContent = `${message(error)}${ui.pending ? '。保留当前窗口,点击“重试确认”会使用原操作核对结果。' : ''}`;
} finally {
ui.busy = false; disabled.forEach(([el, value]) => { el.disabled = value; });
$('access-backup').hidden = !ui.pending;
if (ui.pending) { form.querySelectorAll('input,select').forEach(el => { el.disabled = true; }); $('access-submit').textContent = '重试确认 →'; }
}
}
$('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-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-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; });
$('access-backup').addEventListener('click', () => { if (ui.pending) download(`orbit-operation-${ui.pending.operation_id}.json`, JSON.stringify(ui.pending, null, 2), 'application/json'); });
window.addEventListener('beforeunload', e => { if (ui.pending || !ui.saved) { e.preventDefault(); e.returnValue = ''; } });
window.orbitApplications = { load };
})();