fix: make credential lookup and blocked rotation guidance actionable

This commit is contained in:
2026-09-27 12:45:46 +08:00
parent e3d8b16844
commit 0782b0488b
5 changed files with 49 additions and 12 deletions

View File

@@ -298,6 +298,7 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
### 换证过渡状态提示(2026-09-27)
- 凭据卡片明确区分「过渡中 · 暂不可换证」与「已撤销 · 不可换证」,展示禁用原因、旧 Token 实际截止及服务切换步骤;到期后页面刷新恢复换证入口。
- 凭据卡片明确区分过渡中与已撤销状态,展示旧 Token 实际截止及服务切换步骤;入口可点击「查看原因」打开只读说明,到期后页面刷新恢复换证入口。
- 单独说明当前 Token 的获取位置、凭据 ID 与 Token 的区别、过渡期配置可能仍是旧代,以及未保存时的处理方式;新建/换证前、成功交付与关闭确认均提示原文只显示一次。
- 保留过渡期禁止重复换证的保护;提前结束仍需管理员确认服务全部切换、在途任务收尾,不自动结束过渡或替换现有凭据。
- 回归范围:标准竞态测试、vet/build、JavaScript 语法和文档链接;本地页面核对过渡说明、正常换证入口与结束过渡确认弹窗。说明同步到[应用接入手册](api/console-applications.md)。
- 回归范围:标准竞态测试、vet/build、JavaScript 语法和文档链接;本地页面核对过渡原因可点击、凭据获取说明、正常换证入口与结束过渡确认弹窗。说明同步到[应用接入手册](api/console-applications.md)。

View File

@@ -21,6 +21,8 @@ export ORBIT_CONSOLE_APPLICATION_ADMIN=admin
5. 「平滑换证」保留 application_id、namespace_id、credential_id、队列、任务和订阅;新 Token 立即生效,旧 Token 在配置的过渡期内继续使用(页面默认 7 天,支持 1/7/30 天),便于逐台更新配置。详情展示旧 Token 实际截止,全部服务切换后可点「结束过渡」。
6. 默认「长期有效」,不需要每月人工换证;已有凭据可点「调整有效期」直接设为长期,原 Token 保持不变。
凭据卡片将「凭据 ID」与「当前 Token」分开展示:ID 只是标识,不能填入 ORBIT_TOKEN。点击「Token 在哪里查看?」可查看获取位置和未保存时的处理说明。完整 Token 请从创建或换证时下载的 `orbit.env`、已保存的 SDK 配置或业务服务配置中读取;处于过渡期时,业务配置可能仍是旧 Token,新 Token 要以本次换证保存的配置为准。页面里的「SDK 配置模板」不含完整 Token。
环境与命名空间形成隔离边界。不同应用应分别创建;同一应用的多个命名空间在列表中分别展示。页面支持新建应用及其初始队列;业务列表可通过应用筛选栏切换范围,分别展示各应用数据。未提供应用删除、身份迁移或现有应用新增命名空间的表单;已有 CLI 的 `--application-id` 仍可增加命名空间,队列 SDK 可增加同范围队列。
## 2. 路由与认证
@@ -91,7 +93,7 @@ prepare 请求:
expected_revision 为读取到的字符串版本。只允许上述轮换字段,不混入新应用名称或能力组。准备对象包含原 application_id / namespace_id / credential_id,没有 queue_id。apply 复用原有凭据轮换事务:将原摘要保留到固定过渡截止,替换当前摘要与可选到期时间,版本加一,保存脱敏回执及人类账号审计;权限组不改变。已撤销的凭据禁止变更。ROTATE 的 grace_days 范围 0–30,API 省略/0 保持立即停用旧 Token 的兼容语义,页面显式提交 7;实际截止不超过旧 Token 原到期时间。过渡未结束时再次轮换返回 409。
页面在过渡期将按钮标为「过渡中 · 暂不可换证」,同时显示原因、实际截止和切换步骤。这表示上一轮换证已生效;再次轮换会挤掉仍在使用的旧 Token,所以暂时禁止。将已保存的新 Token 配置到所有服务、Worker 和结果消费者,并收尾在途任务。到期后页面刷新会恢复换证按钮;只有确认全部切换完成,才可提前点「结束过渡,停用旧 Token」。结束过渡会立即停用旧 Token,不能仅为解锁按钮而操作。已撤销凭据单独显示「已撤销 · 不可换证」。
页面在过渡期将入口标为「暂不可换证 · 查看原因」,同时显示实际截止和切换步骤。该入口可以点击,打开只读说明弹窗,不会提交换证或结束过渡。这表示上一轮换证已生效;再次轮换会挤掉仍在使用的旧 Token,所以暂时禁止。将已保存的新 Token 配置到所有服务、Worker 和结果消费者,并收尾在途任务。到期后页面刷新会恢复换证入口;只有确认全部切换完成,才可提前点「结束过渡,停用旧 Token」。结束过渡会立即停用旧 Token,不能仅为解锁按钮而操作。已撤销凭据单独显示「已撤销 · 查看原因」,同样只打开说明。
apply 成功 data:
@@ -132,6 +134,7 @@ SET_EXPIRY 不生成新 Token,只更新当前有效期;valid_days=0 为长
- 页面提交前把整个固定操作保留在当前内存;请求超时或结果未知时,按钮变成「重试确认」,重用同一份内容。不能再次 prepare 来代替核对。
- 页面允许「保存待核对操作」下载私密 JSON;含真实 Token,应放到受限目录。恢复时登录同一管理员后,向 apply 发送该文件正文。不要把 Cookie 或操作文件提交 Git,也不要放到 URL。
- 关闭/刷新页面会丢失内存中的 Token,页面会提醒先保存。若新建已经成功、交付内容未保存,可从应用列表重新生成同一凭据的 Token;不要再次创建同名应用。
- 创建/换证前和成功交付时明确提示「只显示一次」及过渡期限制;未复制/下载时,关闭确认也提示原文不可回看。若换证后的新 Token 未保存,先查下载文件或已更新的部署配置。确实丢失且仍在过渡期时,需要安排维护窗口暂停接收新任务并收尾在途执行,再由管理员结束过渡、重新换证,保存和更新配置后恢复运行;平台不自动解除保护,也不能从摘要还原原文。
- 原操作返回的回执是历史快照,可能已被后续轮换替代。页面会重新读取当前凭据版本后才展示配置。
- 管理审计主体是 `console:<user_id>`;正文只有权限、资源 ID、到期时间等元数据。列表、审计、幂等回执和业务记录都不保存秘密原文。
- 当前管理员退出、会话过期或账号停用后不能重放管理操作。SDK Token 撤销/到期与控制台登录资格沿用既有账号绑定约束。

View File

@@ -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, mode: 'CREATE' };
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 }) : '长期有效';
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : error.message; }
@@ -49,17 +49,30 @@
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 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)}" ${blocked ? `disabled title="${escape(reason)}" aria-describedby="${escape(helpID)}"` : ''}>${c.revoked_at ? '已撤销 · 不可换证' : transitioning ? '过渡中 · 暂不可换证' : '平滑换证 ↗'}</button></div>
<p>凭据 ID:${escape(c.id)}<br>有效期:${escape(time(c.expires_at))} · 版本 ${escape(c.revision)}<br>Token 原文不保存在数据库;请妥善保存接入配置。</p>
<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>
${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>
</section>`;
}).join('') || '<p class="panel-description">该环境暂无凭据。</p>';
if ($('access-help-dialog').open) renderCredentialHelp();
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 renderCredentialHelp() {
const credential = ui.detail?.credentials.find(c => c.id === ui.helpCredential);
if (!credential) { $('access-help-dialog').close(); return; }
const transitioning = !credential.revoked_at && Date.parse(credential.previous_valid_until) > Date.now();
$('access-help-scope').textContent = `${ui.detail.entry.application} · ${ui.detail.entry.environment} / ${ui.detail.entry.namespace} · ${credential.name} · 凭据版本 ${credential.revision}`;
$('access-help-state').textContent = credential.revoked_at ? '凭据已撤销,不能再用于接入或换证。' : transitioning ? `上一轮换证已生效。旧 Token 过渡截止:${time(credential.previous_valid_until)}。期间暂不能再次换证,以免中断还在用旧 Token 的服务。` : '当前没有有效的旧 Token 过渡期,可从凭据卡片发起换证。';
$('access-help-transition').hidden = !transitioning;
}
function showCredentialHelp(credential) {
ui.helpCredential = credential.id; renderCredentialHelp(); $('access-help-dialog').showModal();
}
function open(credential = null, mode = 'ROTATE') {
if (ui.busy) return;
ui.rotating = credential; ui.mode = credential ? mode : 'CREATE'; ui.pending = null; ui.result = null; ui.saved = true;
@@ -77,12 +90,13 @@
SET_EXPIRY: '只调整当前 Token 的有效期,Token 和业务配置保持不变。已有旧 Token 的过渡截止不会延长。',
FINALIZE: '此操作立即停用旧 Token,新 Token 继续有效。请先确认全部服务、Worker 和结果消费者已切换,在途执行已收尾。'
}[ui.mode];
$('access-token-once-note').hidden = !['CREATE', 'ROTATE'].includes(ui.mode);
$('access-submit').textContent = ui.mode === 'CREATE' ? '创建并生成凭据 →' : `确认${titles[ui.mode]} →`;
$('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;
if ((ui.pending || !ui.saved) && !window.confirm(ui.pending ? '操作结果尚未确认,请先保存待核对操作。仍要关闭吗?' : '请确认已保存完整 SDK 配置。关闭后无法再次查看 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') {
@@ -118,6 +132,7 @@
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-token-once-note').hidden = true;
$('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;
@@ -133,11 +148,14 @@
$('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],[data-access-expiry],[data-access-finalize]'); if (!el || el.disabled) return;
const id = 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]'); if (!el || el.disabled) return;
const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize;
const credential = ui.detail?.credentials.find(c => c.id === id);
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-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(); });

View File

@@ -224,7 +224,8 @@
<p class="panel-description">本机默认使用当前页面地址;容器内可填写 http://orbit-test:8080,部署后可换成服务域名。</p>
<div class="access-credentials" id="access-credentials"></div>
<p class="panel-description" id="access-truncated" hidden>队列或凭据超过 100 项,此页只展示前 100 项。</p>
<div class="access-config-heading"><h3>SDK 接入配置</h3><button class="text-button" id="access-copy-template">复制配置模板 ↗</button></div>
<div class="access-config-heading"><h3>SDK 配置模板</h3><button class="text-button" id="access-copy-template">复制配置模板 ↗</button></div>
<p class="panel-description">此模板不含完整 Token。请将 ORBIT_TOKEN 替换为生成凭据时保存的值;上面的凭据 ID 不能作为 Token 使用。</p>
<pre class="access-code" id="access-config"></pre>
<div class="access-help">Token 确定应用和权限,队列决定任务去向。业务服务注册相同名称与版本的 handler 后,即可执行任务。</div>
</div>
@@ -396,6 +397,7 @@
<dialog id="access-dialog" class="access-dialog">
<div class="access-dialog-top"><span class="panel-kicker">ORBIT / CONNECT</span><button type="button" class="icon-button" id="access-close" aria-label="关闭应用接入">×</button></div>
<h2 id="access-form-title">新建应用</h2><p class="panel-description" id="access-form-note">为应用创建独立的环境、队列与调用凭据。</p>
<p class="access-token-once-note" id="access-token-once-note">完整 Token 只在生成成功后显示一次,请当场复制或下载保存。关闭后无法回看;换证过渡期内也不能立即再次换证。</p>
<form id="access-form">
<div id="access-create-fields">
<label>应用名称<input id="access-name" maxlength="128" placeholder="例如 order-service" autocomplete="off" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
@@ -409,7 +411,19 @@
<div class="access-form-actions"><button type="submit" class="primary-button compact" id="access-submit">创建并生成凭据 →</button><button type="button" class="text-button" id="access-backup" hidden>保存待核对操作</button></div>
</form>
<p id="access-clipboard-status" class="panel-description" role="status"></p>
<section id="access-result" hidden><div class="access-success">✓ <span id="access-result-title">应用已创建</span></div><p class="panel-description" id="access-result-note">完整 Token 仅在本次操作中显示。请复制或下载到服务配置中;页面关闭后只能重新生成。</p><label id="access-result-config-field">完整 SDK 配置<textarea id="access-result-config" readonly rows="7" spellcheck="false"></textarea></label><p class="panel-description" id="access-result-expiry"></p><div class="access-form-actions" id="access-result-actions"><button type="button" class="primary-button compact" id="access-copy">复制配置 ↗</button><button type="button" class="secondary-button compact" id="access-download">下载 .env ↓</button></div></section>
<section id="access-result" hidden><div class="access-success">✓ <span id="access-result-title">应用已创建</span></div><p class="access-token-once-note" id="access-result-note">请现在复制或下载下面的完整 SDK 配置。Token 只在本次操作中显示,关闭或刷新后无法回看;旧 Token 仍在过渡期时,不能立即再次换证。</p><label id="access-result-config-field">完整 SDK 配置<textarea id="access-result-config" readonly rows="7" spellcheck="false"></textarea></label><p class="panel-description" id="access-result-expiry"></p><div class="access-form-actions" id="access-result-actions"><button type="button" class="primary-button compact" id="access-copy">复制配置 ↗</button><button type="button" class="secondary-button compact" id="access-download">下载 .env ↓</button></div></section>
</dialog>
<dialog id="access-help-dialog" class="access-dialog access-credential-help" aria-labelledby="access-help-title">
<div class="access-dialog-top"><span class="panel-kicker">ORBIT / CREDENTIAL</span><button type="button" class="icon-button" id="access-help-close" aria-label="关闭凭据说明">×</button></div>
<h2 id="access-help-title">凭据与换证说明</h2>
<p class="panel-description" id="access-help-scope"></p>
<div class="access-rotation-notice"><strong>当前换证状态</strong><p id="access-help-state"></p></div>
<h3>当前 Token 在哪里查看?</h3>
<p>请打开生成凭据时下载的 <code>orbit.env</code>、已保存的 SDK 配置,或业务服务的配置,找到 <code>ORBIT_TOKEN</code>。凭据 ID 只是标识,不能代替 Token。</p>
<p>控制台只能查看凭据状态、有效期、版本和权限。完整 Token 仅在创建或换证成功时显示,关闭后无法从平台还原。</p>
<div id="access-help-transition" hidden><p>过渡期内,业务配置可能仍是旧 Token;新 Token 请以本次换证时保存的配置为准。</p><p>确认所有服务已切换新 Token、在途任务已收尾后,才可以提前结束过渡。该操作会立即停用旧 Token。</p></div>
<details class="access-token-recovery"><summary>如果新 Token 没有保存怎么办?</summary><p>先检查下载文件、已保存的配置或已更新的服务配置。若都没有保存,平台无法找回原文,需要重新生成并更新服务配置。</p><p>仍在过渡期时,不要为了解锁按钮直接结束过渡,这会立即停用旧 Token。确需重置,应先安排维护窗口,暂停接收新任务并收尾在途执行,再由管理员结束过渡、重新换证,保存新配置并更新服务后恢复运行。</p></details>
<div class="access-form-actions"><button type="button" class="primary-button compact" id="access-help-done">我知道了</button></div>
</dialog>
<script src="/console/applications.js"></script>

View File

@@ -18,7 +18,8 @@
@media(max-width:1100px){.access-grid{grid-template-columns:1fr}.access-guide{gap:14px;flex-wrap:wrap}.access-list{display:grid;grid-template-columns:1fr 1fr}.access-heading h1{font-size:28px}}
@media(max-width:600px){.access-heading{align-items:flex-start;flex-direction:column}.access-config-grid,.access-list{grid-template-columns:1fr}.access-dialog{padding:22px}.access-guide{flex-direction:column;align-items:flex-start;gap:10px}.access-guide>i{display:none}.access-heading p{font-size:13px}}
.access-permission-details{margin-top:14px;font-size:11px;color:#8ba5bc}.access-permission-details summary{cursor:pointer}.access-permission-details p{font-size:10px;line-height:2}
.access-credential-header{flex-wrap:wrap}.access-credential button:disabled{color:#93a8b7;cursor:not-allowed}.access-credential-header button:disabled{padding:7px 10px;border:1px solid #344352;border-radius:7px;background:#1b2a3866}.access-rotation-notice{margin-top:16px;padding:14px 16px;border:1px solid #76603966;border-left:3px solid #caa869;border-radius:10px;background:#55411d24}.access-rotation-notice strong{font-size:12px;color:#e2c58e}.access-rotation-notice p{margin:7px 0 0;color:#b5bdc5}.access-rotation-notice time{color:#e2c58e;font-variant-numeric:tabular-nums}
.access-credential-header{flex-wrap:wrap}.access-credential button:disabled{color:#93a8b7;cursor:not-allowed}.access-credential-header .access-rotation-blocked{padding:7px 10px;border:1px solid #76603988;border-radius:7px;background:#55411d24;color:#e2c58e}.access-rotation-blocked:hover,.access-rotation-blocked:focus-visible{border-color:#e2c58e}.access-rotation-notice{margin-top:16px;padding:14px 16px;border:1px solid #76603966;border-left:3px solid #caa869;border-radius:10px;background:#55411d24}.access-rotation-notice strong{font-size:12px;color:#e2c58e}.access-rotation-notice p{margin:7px 0 0;color:#b5bdc5}.access-rotation-notice time{color:#e2c58e;font-variant-numeric:tabular-nums}
.access-token-summary{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;padding:14px;border:1px solid #284050;background:#08152380;border-radius:10px;margin-top:14px}.access-token-summary strong{display:block;font-size:12px;color:#c0d5e7}.access-token-summary span{display:block;font-size:11px;color:#8da4bc;margin-top:7px}.access-token-summary .text-button{color:#77dccc}.access-token-once-note{font-size:12px;line-height:1.9;padding:14px 16px;border:1px solid #76603966;border-radius:10px;background:#55411d24;color:#e2c58e}.access-credential-help h3{font-size:15px;margin:24px 0 10px;color:#d4e2f1}.access-credential-help p{font-size:12px;line-height:1.9;color:#a3b8ca;overflow-wrap:anywhere}.access-credential-help code{color:#83dacd}.access-token-recovery{margin-top:20px;padding-top:16px;border-top:1px solid #283c53}.access-token-recovery summary{cursor:pointer;font-size:12px;color:#e2c58e}.access-credential-help .access-form-actions{justify-content:flex-end}
/* 应用范围在页面导航间保持;列表归属信息使用同一组数据库元数据。 */
.application-scope{display:flex;align-items:center;gap:24px;flex-wrap:wrap;margin:24px 0 8px;padding:20px 24px;background:linear-gradient(110deg,rgba(39,105,112,.16),rgba(13,25,43,.68));border:1px solid var(--line);border-radius:16px}.application-scope[hidden]{display:none}.scope-heading{display:flex;align-items:center;gap:12px;min-width:140px}.scope-symbol{display:grid;place-items:center;width:36px;height:36px;background:rgba(98,230,212,.09);color:var(--cyan);border-radius:10px;font-size:23px}.scope-heading b{font-size:13px}.scope-heading small{display:block;color:var(--muted);font-size:10px;margin-top:6px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.scope-field{flex:1;min-width:170px;max-width:340px}.scope-field>span{display:block;color:var(--muted);font-size:10px;margin-bottom:7px}.scope-field select{width:100%;padding:10px 30px 10px 12px;border:1px solid var(--line);border-radius:9px;background:#0b1829;color:var(--text);font:inherit;font-size:12px;text-overflow:ellipsis}.scope-field select:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}.scope-queue{max-width:240px}.scope-note{margin-left:auto;display:grid;gap:9px;text-align:right}.scope-note small{color:var(--muted);font-size:10px}.scope-note button{justify-self:end;font-size:11px}.scope-error{width:100%;margin:0;color:var(--orange);font-size:12px}.scope-error:empty{display:none}.application-badge{display:inline-flex;align-items:center;gap:7px;max-width:260px;padding:5px 8px;border:1px solid rgba(98,230,212,.13);border-radius:7px;background:rgba(98,230,212,.055);font-size:11px;white-space:nowrap}.application-badge i{width:5px;height:5px;border-radius:50%;background:var(--cyan);flex-shrink:0}.application-badge b{overflow:hidden;text-overflow:ellipsis;font-weight:500;color:#d4eae8;max-width:170px}.application-badge em{font-style:normal;font-size:10px;color:var(--muted);border-left:1px solid var(--line);padding-left:7px}.list-application{margin-left:auto;margin-right:18px}.list-copy{min-width:0}.list-copy b{overflow:hidden;text-overflow:ellipsis}.archive-job>.application-badge{margin:12px 0 0}.detail-scope,.retention-scope{margin-bottom:14px}.scope-field select:disabled{opacity:.7}.task-processor{max-width:260px;overflow-wrap:anywhere}