@seasonkoh/webaz 0.1.29 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bond-refund-blockers.js +50 -0
- package/dist/bond-slash.js +100 -0
- package/dist/bond-terms.js +31 -0
- package/dist/direct-pay-bond-rail-clearance.js +10 -5
- package/dist/direct-pay-cancel-refund.js +160 -0
- package/dist/direct-pay-create.js +78 -9
- package/dist/direct-pay-disclosures.js +17 -11
- package/dist/direct-pay-fee-ar.js +2 -2
- package/dist/direct-pay-fee-prepay-request.js +80 -0
- package/dist/direct-pay-returns.js +104 -0
- package/dist/direct-pay-stock.js +9 -0
- package/dist/direct-receive-deferral.js +30 -1
- package/dist/direct-receive-deposits.js +122 -17
- package/dist/free-shipping.js +37 -0
- package/dist/fx-rates.js +7 -0
- package/dist/layer0-foundation/L0-1-database/schema.js +246 -0
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +1 -0
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +73 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +150 -33
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +110 -41
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +133 -31
- package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +16 -4
- package/dist/layer3-trust/L3-1-dispute-engine/mutual-cancel.js +156 -0
- package/dist/platform-receive-accounts.js +94 -0
- package/dist/pwa/arbitration-read-admin.js +37 -0
- package/dist/pwa/arbitrator-lifecycle.js +100 -0
- package/dist/pwa/contract-fingerprint.js +15 -0
- package/dist/pwa/direct-pay-order-redaction.js +20 -2
- package/dist/pwa/human-presence.js +2 -6
- package/dist/pwa/public/app-account.js +9 -9
- package/dist/pwa/public/app-admin-disputes.js +55 -0
- package/dist/pwa/public/app-agent-appeal.js +90 -0
- package/dist/pwa/public/app-agent-approvals.js +93 -0
- package/dist/pwa/public/app-agent-pair.js +127 -0
- package/dist/pwa/public/app-arbitrator-admin.js +87 -0
- package/dist/pwa/public/app-arbitrator-entry.js +9 -0
- package/dist/pwa/public/app-bond-deferral-ui.js +9 -0
- package/dist/pwa/public/app-bond-refund-ui.js +66 -0
- package/dist/pwa/public/app-bond-slash-ui.js +74 -0
- package/dist/pwa/public/app-bond-terms-ui.js +23 -0
- package/dist/pwa/public/app-bond-ui.js +108 -0
- package/dist/pwa/public/app-chat-poll.js +6 -6
- package/dist/pwa/public/app-contribution-hub.js +23 -0
- package/dist/pwa/public/app-direct-pay-accounts.js +1 -1
- package/dist/pwa/public/app-direct-pay-buyer.js +1 -1
- package/dist/pwa/public/app-direct-pay-cancel-refund.js +72 -0
- package/dist/pwa/public/app-direct-pay-copy.js +12 -0
- package/dist/pwa/public/app-direct-pay-fee-history.js +39 -0
- package/dist/pwa/public/app-direct-pay-fee-ops.js +1 -1
- package/dist/pwa/public/app-direct-pay-fee-request.js +81 -0
- package/dist/pwa/public/app-direct-pay-fee-requests-admin.js +70 -0
- package/dist/pwa/public/app-direct-pay-memo.js +14 -0
- package/dist/pwa/public/app-direct-pay-negotiation.js +35 -0
- package/dist/pwa/public/app-direct-pay-pay.js +15 -0
- package/dist/pwa/public/app-direct-pay-paymodal.js +32 -0
- package/dist/pwa/public/app-direct-pay-reconcile.js +19 -0
- package/dist/pwa/public/app-direct-pay-returns.js +56 -0
- package/dist/pwa/public/app-direct-pay-reveal.js +82 -0
- package/dist/pwa/public/app-direct-pay-sales-report.js +70 -0
- package/dist/pwa/public/app-direct-pay.js +36 -37
- package/dist/pwa/public/app-dispute-close-ui.js +38 -0
- package/dist/pwa/public/app-free-shipping-ui.js +29 -0
- package/dist/pwa/public/app-gmv-rail-split.js +12 -0
- package/dist/pwa/public/app-listing-commerce-ui.js +72 -0
- package/dist/pwa/public/app-mutual-cancel.js +54 -0
- package/dist/pwa/public/app-notif-templates-orders.js +43 -0
- package/dist/pwa/public/app-notif-templates.js +22 -0
- package/dist/pwa/public/app-order-accept-ui.js +158 -0
- package/dist/pwa/public/app-order-errors.js +50 -0
- package/dist/pwa/public/app-order-labels.js +19 -0
- package/dist/pwa/public/app-order-rail-filter.js +26 -0
- package/dist/pwa/public/app-platform-receive-accounts.js +140 -0
- package/dist/pwa/public/app-poll-governor.js +22 -0
- package/dist/pwa/public/app-profile.js +4 -4
- package/dist/pwa/public/app-purchase-terms-ui.js +68 -0
- package/dist/pwa/public/app-sale-regions-ui.js +38 -0
- package/dist/pwa/public/app-seller.js +1 -1
- package/dist/pwa/public/app-trade-tax-ui.js +33 -0
- package/dist/pwa/public/app.js +133 -160
- package/dist/pwa/public/i18n.js +675 -5
- package/dist/pwa/public/index.html +41 -0
- package/dist/pwa/public/openapi.json +719 -11
- package/dist/pwa/public/style.css +3 -0
- package/dist/pwa/routes/admin-direct-receive-deposits.js +215 -3
- package/dist/pwa/routes/admin-protocol-params.js +16 -0
- package/dist/pwa/routes/admin-reports.js +31 -5
- package/dist/pwa/routes/agent-governance.js +1 -1
- package/dist/pwa/routes/agent-grants.js +281 -32
- package/dist/pwa/routes/analytics.js +2 -0
- package/dist/pwa/routes/arbitrator.js +67 -14
- package/dist/pwa/routes/bond-seller.js +162 -0
- package/dist/pwa/routes/direct-pay-cancel-refund.js +111 -0
- package/dist/pwa/routes/direct-pay-disclosure-acks.js +9 -4
- package/dist/pwa/routes/direct-pay-pending-accept.js +222 -0
- package/dist/pwa/routes/direct-pay-returns.js +74 -0
- package/dist/pwa/routes/direct-pay-timeouts.js +186 -9
- package/dist/pwa/routes/disputes-read.js +20 -6
- package/dist/pwa/routes/disputes-write.js +91 -33
- package/dist/pwa/routes/external-anchors.js +4 -2
- package/dist/pwa/routes/fee-prepay-requests.js +66 -0
- package/dist/pwa/routes/governance-onboarding.js +46 -8
- package/dist/pwa/routes/logistics.js +4 -4
- package/dist/pwa/routes/me-data.js +2 -2
- package/dist/pwa/routes/mutual-cancel.js +62 -0
- package/dist/pwa/routes/orders-action.js +182 -9
- package/dist/pwa/routes/orders-create.js +18 -7
- package/dist/pwa/routes/orders-read.js +76 -14
- package/dist/pwa/routes/platform-receive-accounts.js +111 -0
- package/dist/pwa/routes/products-create.js +45 -16
- package/dist/pwa/routes/products-update.js +15 -1
- package/dist/pwa/routes/profile-identity.js +4 -2
- package/dist/pwa/routes/returns.js +39 -8
- package/dist/pwa/routes/seller-directpay-report.js +110 -0
- package/dist/pwa/routes/seller-quota.js +5 -1
- package/dist/pwa/routes/shipping-templates.js +219 -0
- package/dist/pwa/routes/snf.js +4 -1
- package/dist/pwa/routes/webauthn.js +3 -3
- package/dist/pwa/server.js +63 -40
- package/dist/runtime/agent-grant-scopes.js +69 -1
- package/dist/runtime/webaz-schema-helpers.js +57 -3
- package/dist/sale-regions.js +116 -0
- package/dist/shipping-templates.js +119 -0
- package/dist/trade-tax.js +99 -0
- package/dist/trade-terms.js +76 -0
- package/dist/version.js +1 -1
- package/package.json +61 -2
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// admin 争议查看/管理 —— 可【区分】(状态 / 直付-托管 / 进度 / 是否已指派 / 如何结案 / 紧急度)+ 可【管理】(状态过滤 + 钻取查看)。
|
|
2
|
+
// admin 只读监督(后端 disputes-read 允许 admin 查详情);裁定/驳回等【动作】仍须 active 白名单仲裁员(COI+指派)。中文 t(),英文 i18n.js。
|
|
3
|
+
window.renderAdminDisputes = async function (app, opts = {}) {
|
|
4
|
+
if (!state.user) { renderLogin(); return }
|
|
5
|
+
if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
|
|
6
|
+
app.innerHTML = shell(loading$(), 'admin')
|
|
7
|
+
const status = opts.status || ''
|
|
8
|
+
const data = await GET('/admin/disputes' + (status ? `?status=${encodeURIComponent(status)}` : ''))
|
|
9
|
+
if (data.error) { app.innerHTML = shell(alert$('error', data.error), 'admin'); return }
|
|
10
|
+
const c = data.counts || {}
|
|
11
|
+
const STATUS_META = { open: ['#fef3c7', '#92400e', t('待响应')], in_review: ['#fde68a', '#b45309', t('仲裁中')], resolved: ['#dcfce7', '#166534', t('已裁定')], dismissed: ['#e5e7eb', '#374151', t('已驳回')] }
|
|
12
|
+
const tabs = [['', t('全部'), data.total || 0], ['open', t('待响应'), c.open || 0], ['in_review', t('仲裁中'), c.in_review || 0], ['resolved', t('已裁定'), c.resolved || 0], ['dismissed', t('已驳回'), c.dismissed || 0]]
|
|
13
|
+
const tabHtml = tabs.map(([val, label, n]) => `<button class="btn btn-sm" style="width:auto;font-size:12px;background:${status === val ? '#4f46e5' : '#fff'};color:${status === val ? '#fff' : '#374151'};border:1px solid ${status === val ? '#4f46e5' : '#e5e7eb'}" onclick="renderAdminDisputes(document.getElementById('app'),{status:'${val}'})">${label} ${n}</button>`).join('')
|
|
14
|
+
const railBadge = (rail) => rail === 'direct_p2p'
|
|
15
|
+
? `<span style="font-size:10px;background:#eff6ff;color:#1e40af;padding:1px 7px;border-radius:99px">${t('直付')}</span>`
|
|
16
|
+
: `<span style="font-size:10px;background:#f0fdf4;color:#166534;padding:1px 7px;border-radius:99px">${t('托管')}</span>`
|
|
17
|
+
const urgencyChip = (d) => {
|
|
18
|
+
if (d.status !== 'open' && d.status !== 'in_review') return ''
|
|
19
|
+
const dl = d.status === 'open' ? d.respond_deadline : d.arbitrate_deadline
|
|
20
|
+
if (!dl) return ''
|
|
21
|
+
const overdue = new Date(dl) < new Date()
|
|
22
|
+
return `<span style="font-size:10px;background:${overdue ? '#fee2e2' : '#fff7ed'};color:${overdue ? '#991b1b' : '#9a3412'};padding:1px 7px;border-radius:99px">${overdue ? '⏰ ' + t('已超时') : '⏳ ' + fmtTime(dl)}</span>`
|
|
23
|
+
}
|
|
24
|
+
const assignChip = (d) => {
|
|
25
|
+
let n = 0; try { n = (JSON.parse(d.assigned_arbitrators || '[]') || []).length } catch {}
|
|
26
|
+
return `<span style="font-size:10px;background:${n ? '#ede9fe' : '#f3f4f6'};color:${n ? '#5b21b6' : '#9ca3af'};padding:1px 7px;border-radius:99px">${n ? '⚖ ' + t('已指派') : t('未指派')}</span>`
|
|
27
|
+
}
|
|
28
|
+
const verdictChip = (d) => (d.status === 'resolved' || d.status === 'dismissed') && d.ruling_type
|
|
29
|
+
? `<span style="font-size:10px;background:#f5f3ff;color:#6d28d9;padding:1px 7px;border-radius:99px">${(window.dpRulingLabel && window.dpRulingLabel(d.ruling_type)) || t(d.ruling_type)}</span>` : ''
|
|
30
|
+
const list = (data.disputes || []).map(d => {
|
|
31
|
+
const [bg, fg, lbl] = STATUS_META[d.status] || ['#e5e7eb', '#374151', d.status]
|
|
32
|
+
return `<div class="card" style="margin-bottom:10px;font-size:13px;cursor:pointer" onclick="navigate('#dispute/${escHtml(d.id)}')">
|
|
33
|
+
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
|
34
|
+
<div style="flex:1;min-width:0">
|
|
35
|
+
<div style="font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(d.product_title || '—')}</div>
|
|
36
|
+
<div style="font-size:12px;color:#6b7280;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml((d.reason || '').slice(0, 60))}</div>
|
|
37
|
+
<div style="font-size:11px;color:#6b7280;margin-top:6px">${t('原告')}: ${escHtml(d.initiator_name || '—')} → ${t('被告')}: ${escHtml(d.defendant_name || '—')}</div>
|
|
38
|
+
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-top:6px">${railBadge(d.payment_rail)}${assignChip(d)}${urgencyChip(d)}${verdictChip(d)}</div>
|
|
39
|
+
<div style="font-size:11px;color:#9ca3af;margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(d.id)} · ${fmtTime(d.created_at)}</div>
|
|
40
|
+
</div>
|
|
41
|
+
<div style="display:flex;flex-direction:column;gap:6px;align-items:flex-end;flex-shrink:0">
|
|
42
|
+
<span style="background:${bg};color:${fg};padding:2px 8px;border-radius:8px;font-size:11px;white-space:nowrap">${lbl}</span>
|
|
43
|
+
${d.total_amount ? `<div style="font-size:12px;color:#374151;white-space:nowrap">${d.total_amount} WAZ</div>` : ''}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>`
|
|
47
|
+
}).join('') || `<div class="empty"><div class="empty-icon">⚖️</div><div class="empty-text">${t('暂无争议')}</div></div>`
|
|
48
|
+
app.innerHTML = shell(`
|
|
49
|
+
<h1 class="page-title">⚖️ ${t('争议查看')}</h1>
|
|
50
|
+
<div style="margin-bottom:12px"><button class="btn btn-outline btn-sm" style="width:auto" onclick="navigate('#admin')">${t('返回概览')}</button></div>
|
|
51
|
+
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px">${tabHtml}</div>
|
|
52
|
+
<div style="font-size:11px;color:#9ca3af;margin-bottom:8px">${t('点卡片查看争议详情(只读监督;裁定须仲裁员)')}</div>
|
|
53
|
+
${list}
|
|
54
|
+
`, 'admin')
|
|
55
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// 被封用户申诉页 + admin strike 审批页(Tina 案补齐 UI)。UI ONLY —— 申诉/裁决边界全在后端;
|
|
2
|
+
// 被封状态下只调两个封禁豁免端点(GET /api/me/agents、POST .../strikes/:id/appeal)。
|
|
3
|
+
;(function () {
|
|
4
|
+
const SEV = () => ({ warning: t('警告'), suspend_7d: t('暂停 7 天'), permanent: t('永久封禁') })
|
|
5
|
+
const APL = () => ({ none: t('未申诉'), pending: t('申诉审核中'), approved: t('申诉通过(已解封)'), denied: t('申诉被驳回') })
|
|
6
|
+
|
|
7
|
+
// ── 被封用户:极简申诉页(登录检测到 AGENT_BLOCKED 时进入;key 已在 state.apiKey)──
|
|
8
|
+
window.renderAgentBlockedAppeal = async (blockMsg) => {
|
|
9
|
+
const app = document.getElementById('app')
|
|
10
|
+
app.innerHTML = `<div style="max-width:560px;margin:40px auto;padding:0 16px">
|
|
11
|
+
<h1 style="font-size:18px;font-weight:700;margin-bottom:8px">🚫 ${t('账号处于暂停期')}</h1>
|
|
12
|
+
<div class="alert alert-error" style="margin-bottom:12px">${escHtml(blockMsg || '')}</div>
|
|
13
|
+
<div id="appeal-box">${loading$()}</div></div>`
|
|
14
|
+
const r = await GET('/me/agents').catch(() => null)
|
|
15
|
+
const box = document.getElementById('appeal-box')
|
|
16
|
+
if (!r || !Array.isArray(r.items)) { box.innerHTML = `<div class="alert alert-error">${t('加载失败,请重试(确认 key 完整)')}</div>`; return }
|
|
17
|
+
const strikes = r.items.flatMap(a => (a.recent_strikes || []))
|
|
18
|
+
const active = strikes.find(s => ['suspend_7d', 'permanent'].includes(s.severity) && s.appeal_status !== 'approved')
|
|
19
|
+
if (!active) { box.innerHTML = `<div class="alert alert-info">${t('未发现生效中的封禁,请重新登录试试')}</div>`; return }
|
|
20
|
+
const S = SEV(); const A = APL()
|
|
21
|
+
box.innerHTML = `
|
|
22
|
+
<div class="card" style="font-size:13px">
|
|
23
|
+
<div><strong>${S[active.severity] || active.severity}</strong> · ${escHtml(active.reason_code)}${active.reason_detail ? `(${escHtml(active.reason_detail)})` : ''}</div>
|
|
24
|
+
<div style="color:#6b7280;margin-top:4px">${t('签发')}:${fmtTime(active.issued_at)}${active.expires_at ? ` · ${t('到期自动解除')}:${fmtTime(active.expires_at)}` : ''}</div>
|
|
25
|
+
<div style="margin-top:4px">${t('申诉状态')}:<strong>${A[active.appeal_status] || active.appeal_status}</strong></div>
|
|
26
|
+
</div>
|
|
27
|
+
${active.appeal_status === 'none' ? `
|
|
28
|
+
<textarea class="form-control" id="appeal-reason" rows="3" maxlength="500" placeholder="${t('申诉理由(≥10 字;说明误触发原因,如页面挂机轮询/误操作)')}" style="margin:10px 0;font-size:13px"></textarea>
|
|
29
|
+
<button class="btn btn-primary" style="width:100%" onclick="agentAppealSubmit(${active.id})">${t('提交申诉(等待管理员审核)')}</button>`
|
|
30
|
+
: active.appeal_status === 'pending' ? `<div class="alert alert-info" style="margin-top:10px">${t('申诉已提交,等待管理员审核;审核通过后重新登录即可。')}</div>`
|
|
31
|
+
: active.appeal_status === 'denied' ? `<div class="alert alert-error" style="margin-top:10px">${t('申诉被驳回;封禁将按期自动解除。')}</div>` : ''}
|
|
32
|
+
<button class="btn btn-outline btn-sm" style="width:100%;margin-top:10px" onclick="state.apiKey=null;location.reload()">${t('退出(换个账号登录)')}</button>`
|
|
33
|
+
}
|
|
34
|
+
window.agentAppealSubmit = async (strikeId) => {
|
|
35
|
+
const reason = (document.getElementById('appeal-reason')?.value || '').trim()
|
|
36
|
+
if (reason.length < 10) return void toast$(t('申诉理由 ≥10 字'), 'error')
|
|
37
|
+
const r = await POST(`/me/agents/strikes/${strikeId}/appeal`, { reason })
|
|
38
|
+
if (r.error) return void toast$(r.error, 'error')
|
|
39
|
+
toast$(t('申诉已提交'), 'success'); window.renderAgentBlockedAppeal('')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── admin:#admin/agent-strikes 待审队列 + 裁决 + 主动 issue ──
|
|
43
|
+
window.renderAdminAgentStrikes = function (app) {
|
|
44
|
+
if (!state.user) { renderLogin(); return }
|
|
45
|
+
if (typeof isAdmin === 'function' && !isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
|
|
46
|
+
if ((state.user.admin_type || 'root') !== 'root') { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限根管理员')}</div>`, 'admin'); return }
|
|
47
|
+
app.innerHTML = shell(`
|
|
48
|
+
<h1 class="page-title">🚦 ${t('Agent 封禁与申诉')}</h1>
|
|
49
|
+
<div style="font-size:12px;color:#6b7280;margin-bottom:10px">${t('申诉批准=立即解封(60 秒缓存内生效);驳回=按期自动解除。真人(绑 Passkey)已豁免速率封禁,此页主要处理 agent 与历史误封。')}</div>
|
|
50
|
+
<div id="strike-adm-box">${loading$()}</div>
|
|
51
|
+
<h2 style="font-size:14px;font-weight:700;margin:16px 0 8px">${t('主动签发 strike(慎用)')}</h2>
|
|
52
|
+
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">
|
|
53
|
+
<input class="form-control" id="stk-key" placeholder="api_key" style="flex:2;min-width:160px;font-size:12px">
|
|
54
|
+
<input class="form-control" id="stk-reason" placeholder="${t('reason_code(如 spam / fake_shipment)')}" style="flex:1;min-width:120px;font-size:12px">
|
|
55
|
+
<select class="form-control" id="stk-sev" style="width:auto;font-size:12px"><option value="warning">warning</option><option value="suspend_7d">suspend_7d</option><option value="permanent">permanent</option></select>
|
|
56
|
+
<button class="btn btn-outline btn-sm" style="width:auto;font-size:11px" onclick="adminStrikeIssue()">${t('签发')}</button>
|
|
57
|
+
</div>`, 'admin')
|
|
58
|
+
window.adminStrikesHydrate()
|
|
59
|
+
}
|
|
60
|
+
window.adminStrikesHydrate = async () => {
|
|
61
|
+
const box = document.getElementById('strike-adm-box'); if (!box) return
|
|
62
|
+
const r = await GET('/admin/agent-strikes/pending').catch(() => null)
|
|
63
|
+
const items = (r && r.items) || []
|
|
64
|
+
const S = SEV()
|
|
65
|
+
box.innerHTML = items.length === 0 ? `<div class="alert alert-info">${t('暂无待审申诉')}</div>` : items.map(i => `
|
|
66
|
+
<div class="card" style="font-size:12px">
|
|
67
|
+
<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap">
|
|
68
|
+
<div><strong>@${escHtml(i.handle || i.user_id)}</strong> · ${S[i.severity] || i.severity} · ${escHtml(i.reason_code)}</div>
|
|
69
|
+
<div style="color:#6b7280">${fmtTime(i.issued_at)}</div>
|
|
70
|
+
</div>
|
|
71
|
+
${i.reason_detail ? `<div style="color:#6b7280;margin-top:4px">${t('签发详情')}:${escHtml(i.reason_detail)}</div>` : ''}
|
|
72
|
+
<div style="margin-top:6px;padding:8px;background:#f9fafb;border-radius:6px">💬 ${t('申诉理由')}:${escHtml(i.appeal_reason || '-')}</div>
|
|
73
|
+
<div style="display:flex;gap:8px;margin-top:8px">
|
|
74
|
+
<button class="btn btn-primary btn-sm" style="width:auto;font-size:11px" onclick="adminStrikeDecide(${i.id},'approved')">${t('批准(立即解封)')}</button>
|
|
75
|
+
<button class="btn btn-outline btn-sm" style="width:auto;font-size:11px;color:#dc2626;border-color:#fecaca" onclick="adminStrikeDecide(${i.id},'denied')">${t('驳回')}</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>`).join('')
|
|
78
|
+
}
|
|
79
|
+
window.adminStrikeDecide = async (id, decision) => {
|
|
80
|
+
const r = await POST(`/admin/agent-strikes/${id}/decide`, { decision })
|
|
81
|
+
if (r.error) return void toast$(r.error, 'error')
|
|
82
|
+
toast$(decision === 'approved' ? t('已批准,60 秒内解封') : t('已驳回'), 'success'); window.adminStrikesHydrate()
|
|
83
|
+
}
|
|
84
|
+
window.adminStrikeIssue = async () => {
|
|
85
|
+
if (typeof confirmModal === 'function' && !(await confirmModal(t('确认签发 strike?suspend_7d/permanent 将立即阻断该 key 的所有访问。'), t('签发'), { danger: true }))) return
|
|
86
|
+
const r = await POST('/admin/agent-strikes/issue', { api_key: (document.getElementById('stk-key')?.value || '').trim(), reason_code: (document.getElementById('stk-reason')?.value || '').trim(), severity: document.getElementById('stk-sev')?.value })
|
|
87
|
+
if (r.error) return void toast$(r.error, 'error')
|
|
88
|
+
toast$(t('已签发'), 'success')
|
|
89
|
+
}
|
|
90
|
+
})()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// RFC-020 — Agent 授权请求审批页(#agent-approvals)。一个已连接的 agent 请求【更多 SAFE 作用域 / 一个能力包】,
|
|
2
|
+
// 真人在这里审核并用 Passkey 批准或拒绝。批准 = 扩展该 agent 已有的委托凭证(仍只 SAFE、可撤销、按时长)。
|
|
3
|
+
// 经典脚本,全局函数;globals(GET/POST/state/shell/t/escHtml/fmtTime/navigate/loading$/toast$/requestPasskeyGate)调用时解析。
|
|
4
|
+
// 商家视角:不需要懂 scope/complete/verify —— 只看【哪个 agent · 要做什么 · 风险 · 多久】。批准需 Passkey(扩权=提权)。
|
|
5
|
+
;(function () {
|
|
6
|
+
const DURATION_LABEL = { once: '一次性', '1h': '1 小时', '24h': '24 小时', '7d': '7 天', '30d': '30 天' }
|
|
7
|
+
const RISK = {
|
|
8
|
+
low: { label: '低风险', color: '#16a34a', bg: '#dcfce7' },
|
|
9
|
+
medium: { label: '中风险', color: '#b45309', bg: '#fef3c7' },
|
|
10
|
+
high: { label: '高风险', color: '#dc2626', bg: '#fee2e2' },
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Lightweight badge: fill #aa-pending-badge with a pending count (called from the account/settings page).
|
|
14
|
+
async function hydrateAgentApprovalsBadge() {
|
|
15
|
+
const el = document.getElementById('aa-pending-badge'); if (!el || !state.user) return
|
|
16
|
+
const r = await GET('/agent-grants/permission-requests').catch(() => null)
|
|
17
|
+
const n = (r && Array.isArray(r.requests)) ? r.requests.length : 0
|
|
18
|
+
el.innerHTML = n > 0 ? `<span style="display:inline-block;min-width:16px;height:16px;line-height:16px;text-align:center;font-size:10px;color:#fff;background:#dc2626;border-radius:999px;padding:0 5px;margin-left:4px">${n}</span>` : ''
|
|
19
|
+
}
|
|
20
|
+
window.hydrateAgentApprovalsBadge = hydrateAgentApprovalsBadge
|
|
21
|
+
|
|
22
|
+
function renderAgentApprovals(app) {
|
|
23
|
+
if (!state.user) { app.innerHTML = shell(`<div class="empty">${t('请先登录以审核 agent 授权请求')}</div>`, 'me'); return }
|
|
24
|
+
app.innerHTML = shell(`
|
|
25
|
+
<div class="page-header"><h2>${t('🔔 Agent 授权请求')}</h2></div>
|
|
26
|
+
<div style="font-size:12px;color:#6b7280;padding:0 4px 12px;line-height:1.6">${t('一个已连接的 AI agent 请求更多【安全只读/草稿】权限。批准只会扩展它已有的委托凭证 —— 仍然作用域受限、可随时撤销,永远动不了资金、投票、仲裁或改密钥。')}</div>
|
|
27
|
+
<div id="aa-body">${loading$()}</div>
|
|
28
|
+
`, 'me')
|
|
29
|
+
setTimeout(aaHydrate, 30)
|
|
30
|
+
}
|
|
31
|
+
window.renderAgentApprovals = renderAgentApprovals
|
|
32
|
+
|
|
33
|
+
async function aaHydrate() {
|
|
34
|
+
const box = document.getElementById('aa-body'); if (!box) return
|
|
35
|
+
const r = await GET('/agent-grants/permission-requests').catch(() => null)
|
|
36
|
+
if (!r || r.error) { box.innerHTML = `<div class="card" style="padding:16px;color:#991b1b">${escHtml((r && r.error) || t('无法读取授权请求,请重试。'))}</div>`; return }
|
|
37
|
+
const reqs = Array.isArray(r.requests) ? r.requests : []
|
|
38
|
+
if (reqs.length === 0) {
|
|
39
|
+
box.innerHTML = `<div class="empty" style="padding:40px 16px;text-align:center">
|
|
40
|
+
<div style="font-size:32px;margin-bottom:8px">✅</div>
|
|
41
|
+
<div style="font-weight:600;margin-bottom:4px">${t('暂无待处理的授权请求')}</div>
|
|
42
|
+
<div style="color:#9ca3af;font-size:12px">${t('当你的 agent 请求更多权限时,会出现在这里等你批准。')}</div>
|
|
43
|
+
</div>`
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
box.innerHTML = reqs.map(aaCard).join('')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function aaCard(r) {
|
|
50
|
+
const risk = RISK[r.risk_level] || RISK.low
|
|
51
|
+
const scopes = Array.isArray(r.requested_scopes) ? r.requested_scopes : []
|
|
52
|
+
// Prefer the human-readable bundle summary; otherwise show the individual safe-scope chips.
|
|
53
|
+
const what = r.human_summary
|
|
54
|
+
? `<div style="font-size:13px;color:#374151;line-height:1.7">${escHtml(r.human_summary)}</div>`
|
|
55
|
+
: `<div style="margin-top:2px">${scopes.map(s => `<span style="display:inline-block;font-size:11px;color:#4f46e5;background:#eef2ff;padding:2px 8px;border-radius:4px;margin:2px 4px 2px 0">${escHtml(String(s))}</span>`).join('') || `<span style="font-size:12px;color:#9ca3af">${t('(无 —— 仅基础只读)')}</span>`}</div>`
|
|
56
|
+
const dur = DURATION_LABEL[r.duration] || r.duration || '—'
|
|
57
|
+
return `<div class="card" style="margin-bottom:12px;padding:16px;border:1px solid #e5e7eb" data-aa-id="${escHtml(String(r.id))}">
|
|
58
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
59
|
+
<div style="font-weight:600">${escHtml(r.agent_label || t('未命名 Agent'))} <span style="font-size:10px;color:#9ca3af">(${t('未验证')})</span></div>
|
|
60
|
+
<span style="font-size:11px;color:${risk.color};background:${risk.bg};padding:2px 8px;border-radius:999px">${t(risk.label)}</span>
|
|
61
|
+
</div>
|
|
62
|
+
<div style="font-size:11px;color:#6b7280;margin-bottom:2px">${t('它想做什么')}:</div>
|
|
63
|
+
${what}
|
|
64
|
+
${r.reason ? `<div style="font-size:12px;color:#6b7280;margin-top:8px">${t('用途(agent 自述)')}: ${escHtml(r.reason)} <span style="font-size:10px;color:#9ca3af">(${t('未验证')})</span></div>` : ''}
|
|
65
|
+
<div style="font-size:11px;color:#9ca3af;margin-top:8px">${t('授权时长')}: <strong style="color:#374151">${t(dur)}</strong> · ${t('请求于')} ${r.created_at ? fmtTime(r.created_at) : '—'}</div>
|
|
66
|
+
<div style="display:flex;gap:8px;margin-top:12px">
|
|
67
|
+
<button class="btn btn-outline" style="flex:1;color:#dc2626;border-color:#fecaca" onclick="aaReject('${escHtml(String(r.id))}')">${t('拒绝')}</button>
|
|
68
|
+
<button class="btn btn-primary" style="flex:2" onclick="aaApprove('${escHtml(String(r.id))}')">🔑 ${t('用 Passkey 批准')}</button>
|
|
69
|
+
</div>
|
|
70
|
+
</div>`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
window.aaApprove = async (id) => {
|
|
74
|
+
const card = document.querySelector(`[data-aa-id="${(window.CSS && CSS.escape) ? CSS.escape(id) : id}"]`)
|
|
75
|
+
const btn = card ? card.querySelector('.btn-primary') : null; if (btn) btn.disabled = true
|
|
76
|
+
let token
|
|
77
|
+
// Passkey bound to THIS request (a token minted for request A can't approve B — server re-validates).
|
|
78
|
+
try { token = await requestPasskeyGate('agent_permission_approve', { request_id: id }) }
|
|
79
|
+
catch (e) { if (window.dpPromptRegisterPasskey && e && e.code === 'NO_PASSKEY_REGISTERED') { await window.dpPromptRegisterPasskey(e) } else { toast$((e && e.message) || t('Passkey 验证已取消'), 'error') } if (btn) btn.disabled = false; return }
|
|
80
|
+
const r = await POST('/agent-grants/permission-requests/' + encodeURIComponent(id) + '/approve', { webauthn_token: token }).catch(() => null)
|
|
81
|
+
if (!r || r.error) { toast$((r && r.error) || t('批准失败,请重试'), 'error'); if (btn) btn.disabled = false; return }
|
|
82
|
+
toast$(t('已批准 —— 该 agent 的权限已扩展'))
|
|
83
|
+
aaHydrate(); hydrateAgentApprovalsBadge()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
window.aaReject = async (id) => {
|
|
87
|
+
if (!confirm(t('确认拒绝这个授权请求?'))) return
|
|
88
|
+
const r = await POST('/agent-grants/permission-requests/' + encodeURIComponent(id) + '/reject', {}).catch(() => null)
|
|
89
|
+
if (!r || r.error) { toast$((r && r.error) || t('操作失败'), 'error'); return }
|
|
90
|
+
toast$(t('已拒绝该授权请求'))
|
|
91
|
+
aaHydrate(); hydrateAgentApprovalsBadge()
|
|
92
|
+
}
|
|
93
|
+
})()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// RFC-020 — Agent 配对授权页(#pair)。真人登录后审核 agent 的委托请求,Passkey 批准 / 拒绝。
|
|
2
|
+
// 经典脚本,全局函数;globals(GET/POST/state/shell/t/escHtml/fmtTime/navigate/loading$/toast$/requestPasskeyGate)调用时解析。
|
|
3
|
+
// 安全姿态:配对只授 SAFE scope(只读/草稿,可撤销,短期,永不碰钱/投票/仲裁/密钥)。
|
|
4
|
+
// 反钓鱼:口令(user_code)是【必须核对】的安全值 —— 大号显示 + 强制"与 agent 屏幕一致"确认才解锁批准;label/reason 是 agent 自称,标注未验证。
|
|
5
|
+
// 未登录由 app.js 路由守卫拦截并存 webaz_intended_hash(含 ?code=),登录后跳回本页 —— 不丢 code。
|
|
6
|
+
;(function () {
|
|
7
|
+
let _code = null
|
|
8
|
+
|
|
9
|
+
function renderAgentPair(app) {
|
|
10
|
+
if (!state.user) { app.innerHTML = shell(`<div class="empty">${t('请先登录以审核 agent 授权请求')}</div>`, 'me'); return }
|
|
11
|
+
_code = (state._urlQuery && typeof state._urlQuery.code === 'string') ? state._urlQuery.code.trim().toUpperCase().slice(0, 32) : ''
|
|
12
|
+
app.innerHTML = shell(`
|
|
13
|
+
<div class="page-header"><h2>${t('🔗 授权 AI Agent')}</h2></div>
|
|
14
|
+
<div style="font-size:12px;color:#6b7280;padding:0 4px 12px;line-height:1.6">${t('一个 AI agent 请求代表你执行【安全只读/草稿】操作。它拿到的是作用域受限、短期、可随时撤销的委托凭证 —— 不是你的账号或密钥,永远动不了资金、投票、仲裁或改密钥。')}</div>
|
|
15
|
+
<div id="pair-body">${loading$()}</div>
|
|
16
|
+
`, 'me')
|
|
17
|
+
setTimeout(dpairHydrate, 30)
|
|
18
|
+
}
|
|
19
|
+
window.renderAgentPair = renderAgentPair
|
|
20
|
+
|
|
21
|
+
async function dpairHydrate() {
|
|
22
|
+
const box = document.getElementById('pair-body'); if (!box) return
|
|
23
|
+
if (!_code || _code.length < 4) { // 无 code / 手动输入路径(抗钓鱼正路:人对着 agent 屏幕输口令)
|
|
24
|
+
box.innerHTML = `<div class="card" style="padding:16px">
|
|
25
|
+
<div style="font-size:13px;margin-bottom:8px">${t('请输入你的 agent 屏幕上显示的配对口令:')}</div>
|
|
26
|
+
<input id="pair-code-input" maxlength="32" placeholder="${t('配对口令')}" style="width:100%;font-size:16px;letter-spacing:2px;text-transform:uppercase;padding:10px;border:1px solid #d1d5db;border-radius:8px;text-align:center;font-family:monospace">
|
|
27
|
+
<button class="btn btn-primary" style="width:100%;margin-top:10px" onclick="dpairSubmitCode()">${t('查看授权请求')}</button>
|
|
28
|
+
<div style="font-size:11px;color:#9ca3af;margin-top:8px;line-height:1.6">${t('只输入你自己的 agent 显示给你的口令。不要输入别人发来的口令。')}</div>
|
|
29
|
+
</div>`
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
const r = await GET('/agent-grants/pair/' + encodeURIComponent(_code)).catch(() => null)
|
|
33
|
+
if (!r || r.error) return void dpairShowError(r && (r.error_code || r.error))
|
|
34
|
+
dpairRenderConsent(r.consent || {})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
window.dpairSubmitCode = () => {
|
|
38
|
+
const v = (document.getElementById('pair-code-input')?.value || '').trim().toUpperCase()
|
|
39
|
+
if (v.length < 4) { toast$(t('请输入有效口令'), 'error'); return }
|
|
40
|
+
_code = v; dpairHydrate()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function dpairShowError(codeOrMsg) {
|
|
44
|
+
const box = document.getElementById('pair-body'); if (!box) return
|
|
45
|
+
const map = {
|
|
46
|
+
pairing_not_found: t('配对口令无效或不存在。请核对你的 agent 显示的口令。'),
|
|
47
|
+
pairing_not_pending_or_expired: t('该配对请求已过期、已被处理或已失效。请让你的 agent 重新发起配对。'),
|
|
48
|
+
pairing_not_pending: t('该配对请求已被处理或已失效。'),
|
|
49
|
+
}
|
|
50
|
+
const msg = map[codeOrMsg] || t('无法读取该配对请求,请重试。')
|
|
51
|
+
box.innerHTML = `<div class="card" style="padding:16px;border:1px solid #fecaca;background:#fef2f2">
|
|
52
|
+
<div style="font-size:14px;font-weight:600;color:#991b1b;margin-bottom:6px">⚠️ ${t('无法授权')}</div>
|
|
53
|
+
<div style="font-size:13px;color:#7f1d1d;line-height:1.6">${escHtml(msg)}</div>
|
|
54
|
+
<button class="btn btn-sm" style="margin-top:10px" onclick="navigate('#pair')">${t('手动输入口令')}</button>
|
|
55
|
+
</div>`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function dpairRenderConsent(c) {
|
|
59
|
+
const box = document.getElementById('pair-body'); if (!box) return
|
|
60
|
+
const caps = Array.isArray(c.capabilities) ? c.capabilities : []
|
|
61
|
+
const capHtml = caps.length
|
|
62
|
+
? caps.map(cap => `<span style="display:inline-block;font-size:11px;color:#4f46e5;background:#eef2ff;padding:2px 8px;border-radius:4px;margin:2px 4px 2px 0">${escHtml(String(cap.capability || cap))}</span>`).join('')
|
|
63
|
+
: `<span style="font-size:12px;color:#9ca3af">${t('(无 —— 仅基础只读)')}</span>`
|
|
64
|
+
box.innerHTML = `
|
|
65
|
+
<div class="card" style="padding:16px;border:2px solid #c7d2fe;margin-bottom:12px">
|
|
66
|
+
<div style="text-align:center;margin-bottom:12px">
|
|
67
|
+
<div style="font-size:11px;color:#6b7280;margin-bottom:4px">${t('配对口令 —— 请核对与你的 agent 屏幕一致')}</div>
|
|
68
|
+
<div style="font-size:28px;font-weight:800;letter-spacing:4px;font-family:monospace;color:#4338ca">${escHtml(String(_code))}</div>
|
|
69
|
+
</div>
|
|
70
|
+
<div style="font-size:12px;color:#991b1b;background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:10px;line-height:1.6;margin-bottom:12px">
|
|
71
|
+
⚠️ <strong>${t('只在这个口令与你的 agent 屏幕显示的一致、且是你主动发起时才批准。')}</strong> ${t('如果这是别人发给你的链接,或你并没有在配对 agent —— 请拒绝。')}
|
|
72
|
+
</div>
|
|
73
|
+
<div style="font-size:13px;line-height:1.9">
|
|
74
|
+
<div><span style="color:#6b7280">${t('Agent 自称')}:</span> <strong>${escHtml(c.agent_label || t('未命名'))}</strong> <span style="font-size:10px;color:#9ca3af">(${t('未验证')})</span></div>
|
|
75
|
+
${c.reason ? `<div><span style="color:#6b7280">${t('用途(agent 自述)')}:</span> ${escHtml(c.reason)} <span style="font-size:10px;color:#9ca3af">(${t('未验证')})</span></div>` : ''}
|
|
76
|
+
<div style="margin-top:6px"><span style="color:#6b7280">${t('请求的权限')}:</span><div style="margin-top:4px">${capHtml}</div></div>
|
|
77
|
+
<div style="font-size:11px;color:#9ca3af;margin-top:6px">${t('有效期至')} ${c.expires_at ? fmtTime(c.expires_at) : '—'}</div>
|
|
78
|
+
</div>
|
|
79
|
+
<label style="display:flex;align-items:flex-start;gap:8px;margin:14px 0 4px;font-size:12px;color:#374151;cursor:pointer">
|
|
80
|
+
<input type="checkbox" id="pair-confirm" onchange="document.getElementById('pair-approve-btn').disabled=!this.checked" style="margin-top:2px">
|
|
81
|
+
<span>${t('我确认这个口令与我的 agent 显示一致,且这是我本人主动发起的配对。')}</span>
|
|
82
|
+
</label>
|
|
83
|
+
<div style="display:flex;gap:8px;margin-top:12px">
|
|
84
|
+
<button class="btn btn-outline" style="flex:1;color:#dc2626;border-color:#fecaca" onclick="dpairReject()">${t('拒绝')}</button>
|
|
85
|
+
<button class="btn btn-primary" id="pair-approve-btn" disabled style="flex:2" onclick="dpairApprove()">🔑 ${t('用 Passkey 批准')}</button>
|
|
86
|
+
</div>
|
|
87
|
+
</div>`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
window.dpairApprove = async () => {
|
|
91
|
+
const btn = document.getElementById('pair-approve-btn'); if (btn) btn.disabled = true
|
|
92
|
+
let token
|
|
93
|
+
try { token = await requestPasskeyGate('agent_pair_approve', { user_code: _code }) }
|
|
94
|
+
catch (e) { if (window.dpPromptRegisterPasskey && e && e.code === 'NO_PASSKEY_REGISTERED') { await window.dpPromptRegisterPasskey(e) } else { toast$((e && e.message) || t('Passkey 验证已取消'), 'error') } if (btn) btn.disabled = false; return }
|
|
95
|
+
const r = await POST('/agent-grants/pair/' + encodeURIComponent(_code) + '/approve', { webauthn_token: token }).catch(() => null)
|
|
96
|
+
if (!r || r.error) { toast$((r && r.error) || t('批准失败,请重试'), 'error'); if (btn) btn.disabled = false; return }
|
|
97
|
+
dpairShowResult('approved', r)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
window.dpairReject = async () => {
|
|
101
|
+
if (!confirm(t('确认拒绝这个 agent 配对请求?'))) return
|
|
102
|
+
const r = await POST('/agent-grants/pair/' + encodeURIComponent(_code) + '/reject', {}).catch(() => null)
|
|
103
|
+
if (!r || r.error) { toast$((r && r.error) || t('操作失败'), 'error'); return }
|
|
104
|
+
dpairShowResult('rejected', r)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dpairShowResult(kind, r) {
|
|
108
|
+
const box = document.getElementById('pair-body'); if (!box) return
|
|
109
|
+
if (kind === 'approved') {
|
|
110
|
+
const caps = Array.isArray(r.capabilities) ? r.capabilities : []
|
|
111
|
+
box.innerHTML = `<div class="card" style="padding:20px;text-align:center;border:1px solid #bbf7d0;background:#f0fdf4">
|
|
112
|
+
<div style="font-size:36px;margin-bottom:8px">✅</div>
|
|
113
|
+
<div style="font-size:16px;font-weight:700;color:#166534;margin-bottom:6px">${t('已授权')}</div>
|
|
114
|
+
<div style="font-size:12px;color:#374151;margin-bottom:10px">${t('该 agent 现在可用你批准的作用域了(短期、可随时撤销)。回到你的 agent 完成配对即可。')}</div>
|
|
115
|
+
<div style="margin-bottom:12px">${caps.map(cap => `<span style="display:inline-block;font-size:11px;color:#4f46e5;background:#eef2ff;padding:2px 8px;border-radius:4px;margin:2px">${escHtml(String(cap.capability || cap))}</span>`).join('') || `<span style="font-size:11px;color:#9ca3af">${t('基础只读')}</span>`}</div>
|
|
116
|
+
<button class="btn btn-primary" onclick="navigate('#agents')">${t('查看 / 撤销已连接的 Agent')}</button>
|
|
117
|
+
</div>`
|
|
118
|
+
} else {
|
|
119
|
+
box.innerHTML = `<div class="card" style="padding:20px;text-align:center;border:1px solid #e5e7eb">
|
|
120
|
+
<div style="font-size:36px;margin-bottom:8px">🚫</div>
|
|
121
|
+
<div style="font-size:16px;font-weight:700;color:#374151;margin-bottom:6px">${t('已拒绝')}</div>
|
|
122
|
+
<div style="font-size:12px;color:#6b7280;margin-bottom:12px">${t('该配对请求已被拒绝,不会签发任何凭证。')}</div>
|
|
123
|
+
<button class="btn btn-outline btn-sm" onclick="navigate('#agents')">${t('已连接的 Agent')}</button>
|
|
124
|
+
</div>`
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
})()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// PR-F:仲裁员管理 admin UI(最小)。唯一授权源 = active arbitrator_whitelist;所有变更 = ROOT + 现场真人 Passkey + 后端审计。
|
|
2
|
+
// grant:输入 user_id/@handle → /admin/users/lookup 解析 → requestPasskeyGate('arbitrator_grant',{user_id}) → POST。
|
|
3
|
+
// 目标须真人 + 已注册 Passkey + 非当事人 + 非 agent/system(后端 grantArbitrator 校验,前端只带输入)。中文 t(),英文 i18n.js。
|
|
4
|
+
window.renderAdminArbitrators = async function (app) {
|
|
5
|
+
if (!state.user) { renderLogin(); return }
|
|
6
|
+
if ((typeof isAdmin === 'function' && !isAdmin()) || (state.user.admin_type || 'root') !== 'root') { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限根管理员')}</div>`, 'admin'); return } // PR-F:先短路判 isAdmin(),否则无 admin_type 用户被 `||'root'` 默认当 root 放行
|
|
7
|
+
app.innerHTML = shell(`
|
|
8
|
+
<h1 class="page-title">⚖ ${t('仲裁员管理')}</h1>
|
|
9
|
+
<div style="margin-bottom:8px"><button class="btn btn-outline btn-sm" style="width:auto" onclick="navigate('#admin')">${t('← 返回')}</button></div>
|
|
10
|
+
<div style="font-size:12px;color:#6b7280;line-height:1.6;margin-bottom:10px">${t('授权真人仲裁员(唯一授权源=active 白名单)。目标须已注册 Passkey、非本案当事人、非 agent/系统账号。授权/暂停/撤销均需你现场 Passkey,后端留痕。')}</div>
|
|
11
|
+
<div class="card" style="margin-bottom:12px">
|
|
12
|
+
<div style="font-size:13px;font-weight:700;margin-bottom:6px">${t('授权新仲裁员')}</div>
|
|
13
|
+
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
|
14
|
+
<input class="form-control" id="arb-grant-q" placeholder="${t('user_id 或 @handle')}" style="flex:1;min-width:200px">
|
|
15
|
+
<button class="btn btn-primary btn-sm" style="width:auto" onclick="arbAdminGrant()">${t('授权(真人 Passkey)')}</button>
|
|
16
|
+
</div>
|
|
17
|
+
<div id="arb-grant-msg" style="margin-top:8px"></div>
|
|
18
|
+
</div>
|
|
19
|
+
<div id="arb-roster">${loading$()}</div>
|
|
20
|
+
`, 'admin')
|
|
21
|
+
window.arbAdminHydrate()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
window.arbAdminHydrate = async () => {
|
|
25
|
+
const box = document.getElementById('arb-roster'); if (!box) return
|
|
26
|
+
const r = await GET('/admin/arbitrators')
|
|
27
|
+
if (r.error) { box.innerHTML = alert$('error', r.error || t('加载失败')); return }
|
|
28
|
+
const rows = r.arbitrators || []
|
|
29
|
+
box.innerHTML = `<div style="font-size:13px;font-weight:700;margin-bottom:6px">${t('仲裁员名册')}(${rows.length})</div>` +
|
|
30
|
+
(rows.length ? rows.map(x => window.arbAdminCard(x)).join('') : `<div style="font-size:12px;color:#9ca3af">${t('暂无仲裁员')}</div>`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
window.arbAdminCard = (r) => {
|
|
34
|
+
const badge = r.status === 'active' ? 'background:#dcfce7;color:#166534' : r.status === 'suspended' ? 'background:#fef9c3;color:#854d0e' : 'background:#fee2e2;color:#991b1b'
|
|
35
|
+
const label = r.status === 'active' ? t('在岗') : r.status === 'suspended' ? t('已暂停') : t('已撤销')
|
|
36
|
+
return `<div style="border:1px solid #e5e7eb;border-radius:10px;padding:10px 12px;margin-bottom:8px" data-arb="${escHtml(r.user_id)}">
|
|
37
|
+
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:8px">
|
|
38
|
+
<code style="font-size:12px">${escHtml(r.user_id)}</code>
|
|
39
|
+
<span style="font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;white-space:nowrap;${badge}">${label}</span>
|
|
40
|
+
</div>
|
|
41
|
+
<div style="font-size:11px;color:#9ca3af;margin:2px 0 6px">${r.is_system ? t('内部账号(is_system)') + ' · ' : ''}${escHtml(r.note || '')}${r.suspended_at ? ' · ' + t('暂停于') + ' ' + escHtml(r.suspended_at) : ''}${r.revoked_at ? ' · ' + t('撤销于') + ' ' + escHtml(r.revoked_at) : ''}</div>
|
|
42
|
+
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
|
43
|
+
${r.status === 'active' ? `<button class="btn btn-outline btn-sm" style="width:auto" onclick="arbAdminMutate('${escHtml(r.user_id)}','suspend')">${t('暂停')}</button>` : ''}
|
|
44
|
+
${r.status === 'suspended' ? `<button class="btn btn-primary btn-sm" style="width:auto" onclick="arbAdminMutate('${escHtml(r.user_id)}','reinstate')">${t('恢复')}</button>` : ''}
|
|
45
|
+
${r.status !== 'revoked' ? `<button class="btn btn-outline btn-sm" style="width:auto;color:#dc2626;border-color:#dc2626" onclick="arbAdminMutate('${escHtml(r.user_id)}','revoke')">${t('撤销(终态)')}</button>` : `<span style="font-size:11px;color:#9ca3af">${t('已永久撤销,不可再授权')}</span>`}
|
|
46
|
+
</div>
|
|
47
|
+
</div>`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
window.arbAdminGrant = async () => {
|
|
51
|
+
const q = (document.getElementById('arb-grant-q')?.value || '').trim()
|
|
52
|
+
const msg = document.getElementById('arb-grant-msg')
|
|
53
|
+
const show = (type, m) => { if (msg) msg.innerHTML = alert$(type, m) }
|
|
54
|
+
if (!q) return show('error', t('请输入 user_id 或 @handle'))
|
|
55
|
+
const look = await GET('/admin/users/lookup?q=' + encodeURIComponent(q))
|
|
56
|
+
if (look.error || !look.user) return show('error', look.error || t('用户不存在'))
|
|
57
|
+
const userId = look.user.id
|
|
58
|
+
let token
|
|
59
|
+
try { token = await requestPasskeyGate('arbitrator_grant', { user_id: userId }) }
|
|
60
|
+
catch (e) { return show('error', (e && e.message ? e.message + ' — ' : '') + t('需先注册 Passkey')) }
|
|
61
|
+
const res = await POST('/admin/arbitrators/grant', { user_id: userId, webauthn_token: token })
|
|
62
|
+
if (res.error) return show('error', res.error) // 后端已给清晰中文(NOT_HUMAN / PASSKEY_REQUIRED / REVOKED_TERMINAL 等)
|
|
63
|
+
show('success', t('已授权') + ':' + escHtml(look.user.name || userId))
|
|
64
|
+
window.arbAdminHydrate()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// PR-F:whitelist-only 仲裁员(role≠arbitrator,如被授权的买家)在个人页看不到"仲裁台"入口 —— 补一张跟随
|
|
68
|
+
// can_arbitrate 的入口卡(纯 UI;#disputes 本就可达,后端 can_arbitrate/COI/assign 才是边界)。
|
|
69
|
+
window.arbTaishCard = () => `
|
|
70
|
+
<div style="font-size:12px;color:#6b7280;font-weight:600;margin:14px 0 6px">⚖ ${t('仲裁工作')}</div>
|
|
71
|
+
<div style="margin-bottom:10px">
|
|
72
|
+
<div class="card" onclick="location.hash='#disputes'" style="padding:14px;cursor:pointer;display:flex;align-items:center;gap:10px;min-height:64px">
|
|
73
|
+
<div style="font-size:22px">⚖</div>
|
|
74
|
+
<div><div style="font-weight:600;font-size:14px">${t('仲裁台')}</div><div style="font-size:12px;color:#6b7280">${t('待响应 / 仲裁中 / 已结')}</div></div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>`
|
|
77
|
+
|
|
78
|
+
window.arbAdminMutate = async (userId, action) => {
|
|
79
|
+
if (action === 'revoke' && !(await confirmModal(t('撤销是终态,不可再授权该用户。确定撤销?'), t('撤销'), { danger: true }))) return
|
|
80
|
+
let token
|
|
81
|
+
try { token = await requestPasskeyGate('arbitrator_' + action, { user_id: userId }) }
|
|
82
|
+
catch (e) { if (typeof toast$ === 'function') toast$((e && e.message ? e.message + ' — ' : '') + t('需先注册 Passkey'), 'error'); return }
|
|
83
|
+
const res = await POST('/admin/arbitrators/' + userId + '/' + action, { webauthn_token: token })
|
|
84
|
+
if (res.error) { if (typeof toast$ === 'function') toast$(res.error, 'error'); return }
|
|
85
|
+
if (typeof toast$ === 'function') toast$(t('操作成功'), 'success')
|
|
86
|
+
window.arbAdminHydrate()
|
|
87
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// PR-E:仲裁能力入口/按钮跟随后端 can_arbitrate(= active arbitrator_whitelist),不再看 user.role。
|
|
2
|
+
// 前端仅 UX 跟随;真正的安全边界在后端(isEligibleArbitrator + assigned/claim + COI)。boot 时拉一次,
|
|
3
|
+
// suspended/revoked/role-only → can_arbitrate=false → 不显示入口/按钮;whitelist-only(role=buyer)→ true。
|
|
4
|
+
window.arbEntryHydrate = async () => {
|
|
5
|
+
const before = state.canArbitrate // PR-F fix:入口不 await(不阻塞 boot),状态回来若首次变 true → 重渲染当前页,让首屏也出现入口
|
|
6
|
+
try { const s = await GET('/arbitrator/status'); state.canArbitrate = !!(s && s.can_arbitrate); state.arbitratorStatus = (s && s.arbitrator_status) || 'none' }
|
|
7
|
+
catch { state.canArbitrate = false; state.arbitratorStatus = 'none' } // active / suspended / revoked / none
|
|
8
|
+
if (state.canArbitrate && !before && typeof route === 'function') route() // 仅"变为可仲裁"时重渲染;非仲裁员(恒 false)不触发
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// 缓交收口通知模板(B4)。UI ONLY —— 提醒/到期/转正式全在后端 cron/路由。
|
|
2
|
+
;(function () {
|
|
3
|
+
const S = window._notifSub
|
|
4
|
+
const P = (emoji, titleZh, bodyZh) => (p) => ({ title: emoji + ' ' + t(titleZh), body: S(t(bodyZh), p) })
|
|
5
|
+
Object.assign(window.NOTIF_TEMPLATES, {
|
|
6
|
+
deferral_expiring_soon: P('⏰', '保证金缓交即将到期', '你的缓交资格将于 {expires} 到期。请在到期前缴纳履约保证金转正式(设置页-直付履约保证金),否则宽限期后直付资格将关闭。'),
|
|
7
|
+
deferral_expired: P('🚫', '保证金缓交已到期', '缓交资格已到期。若未缴纳保证金,直付资格已关闭(缴纳并经运营确认后可重新开通);在途订单不受影响,请正常履约完成。'),
|
|
8
|
+
})
|
|
9
|
+
})()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// 保证金退出退还 UI(B2)。UI ONLY —— blockers/冷静期/凭据/复核全在后端。
|
|
2
|
+
// 经 app-bond-ui.js 的两个 hook 注入:卖家卡尾 bondRefundBlock(s) / admin 行尾 bondAdmRefundActions(d)。
|
|
3
|
+
;(function () {
|
|
4
|
+
const S = window._notifSub
|
|
5
|
+
const P = (emoji, titleZh, bodyZh) => (p) => ({ title: emoji + ' ' + t(titleZh), body: S(t(bodyZh), p) })
|
|
6
|
+
Object.assign(window.NOTIF_TEMPLATES, {
|
|
7
|
+
bond_refund_requested: P('↩️', '保证金退出申请待处理', '卖家 {seller} 申请退还履约保证金(冷静期 {days} 天,期间其直付资格已暂停)。冷静期满且复核无未了结责任后,场外退还并在 admin 后台记录执行。'),
|
|
8
|
+
bond_refund_executed: P('✅', '履约保证金已退还', '你的保证金已在协议外退还并记录(凭据:{evidence})。直付资格随保证金退出关闭;重新缴纳后可再次开通。'),
|
|
9
|
+
})
|
|
10
|
+
const BLOCKER_LABEL = () => ({
|
|
11
|
+
OPEN_DIRECT_PAY_ORDERS: t('有在途直付订单'), OPEN_CANCEL_REFUND_HANDSHAKE: t('取消退款握手进行中'),
|
|
12
|
+
OPEN_RETURN_FLOW: t('退货/售后流进行中'), UNPAID_PLATFORM_FEES: t('平台服务费欠费未结清'),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// 卖家卡尾:locked → 申请退出(或 blockers 列表);refunding → 冷静期说明 + 撤销
|
|
16
|
+
window.bondRefundBlock = (s) => {
|
|
17
|
+
const d = s.deposit
|
|
18
|
+
if (!d) return ''
|
|
19
|
+
if (d.status === 'locked' && s.refund) {
|
|
20
|
+
if (s.refund.can_request) {
|
|
21
|
+
return `<div style="margin-top:8px;border-top:1px dashed #e5e7eb;padding-top:8px">
|
|
22
|
+
<button class="btn btn-outline btn-sm" style="width:auto;font-size:11px" onclick="bondRefundRequest()">↩️ ${t('申请退出并退还保证金')}</button>
|
|
23
|
+
<div style="font-size:11px;color:#9ca3af;margin-top:4px">${t('申请后进入')} ${s.refund.cooling_days} ${t('天冷静期,期间直付资格暂停;可随时撤销。')}</div></div>`
|
|
24
|
+
}
|
|
25
|
+
const L = BLOCKER_LABEL()
|
|
26
|
+
return `<div style="margin-top:8px;border-top:1px dashed #e5e7eb;padding-top:8px;font-size:11px;color:#92400e">
|
|
27
|
+
${t('暂不能申请退还 —— 有未了结的直付责任')}:${(s.refund.blockers || []).map(b => `${L[b.code] || b.code}${b.count ? `(${b.count})` : ''}`).join('、')}</div>`
|
|
28
|
+
}
|
|
29
|
+
if (d.status === 'refunding') {
|
|
30
|
+
return `<div style="margin-top:8px;border-top:1px dashed #e5e7eb;padding-top:8px;font-size:12px;color:#92400e">
|
|
31
|
+
⏳ ${t('退出申请处理中')}:${t('冷静期')} ${s.refund ? s.refund.cooling_days : 14} ${t('天')}(${t('自申请时起')});${t('期间直付资格暂停。冷静期满、复核无未了结责任后平台场外退还并记录。')}
|
|
32
|
+
<div style="margin-top:6px"><button class="btn btn-outline btn-sm" style="width:auto;font-size:11px" onclick="bondRefundCancel()">${t('撤销退出申请(恢复资格)')}</button></div></div>`
|
|
33
|
+
}
|
|
34
|
+
if (d.status === 'refunded') {
|
|
35
|
+
return `<div style="margin-top:8px;font-size:12px;color:#16a34a">✅ ${t('保证金已退还')}${d.refund_evidence_ref ? `(${t('凭据')} ${escHtml(d.refund_evidence_ref)})` : ''};${t('直付资格已关闭,重新缴纳后可再次开通。')}</div>`
|
|
36
|
+
}
|
|
37
|
+
return ''
|
|
38
|
+
}
|
|
39
|
+
window.bondRefundRequest = async () => {
|
|
40
|
+
if (typeof confirmModal === 'function' && !(await confirmModal(t('确认申请退出并退还保证金?冷静期内你的直付资格将暂停(不可接新直付单),可随时撤销。'), t('申请退出'), { danger: true }))) return
|
|
41
|
+
const r = await POST('/direct-receive/bond-refund-request', {})
|
|
42
|
+
if (r.error) { toast$(r.error_code === 'REFUND_BLOCKED' ? t('有未了结的直付责任,暂不能申请退还') : r.error, 'error'); window.bondHydrateSeller(); return }
|
|
43
|
+
toast$(t('退出申请已提交,进入冷静期'), 'success'); window.bondHydrateSeller()
|
|
44
|
+
}
|
|
45
|
+
window.bondRefundCancel = async () => {
|
|
46
|
+
const r = await POST('/direct-receive/bond-refund-request/cancel', {})
|
|
47
|
+
if (r.error) return void toast$(r.error, 'error')
|
|
48
|
+
toast$(t('已撤销,直付资格已恢复'), 'success'); window.bondHydrateSeller()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// admin 行尾:refunding 行给"记录场外退还"按钮(Passkey;冷静期未满/复核有责任会被后端拒,明示)
|
|
52
|
+
window.bondAdmRefundActions = (d) => {
|
|
53
|
+
if (d.status !== 'refunding') return ''
|
|
54
|
+
return `<div style="margin-top:8px"><button class="btn btn-primary btn-sm" style="width:auto;font-size:11px" onclick="bondAdmExecuteRefund('${d.id}')">↩️ ${t('已场外退还,记录执行(Passkey)')}</button>
|
|
55
|
+
<span style="font-size:11px;color:#9ca3af;margin-left:6px">${t('冷静期未满或复核有未了结责任会被拒,属预期')}</span></div>`
|
|
56
|
+
}
|
|
57
|
+
window.bondAdmExecuteRefund = async (id) => {
|
|
58
|
+
const evidence = prompt(t('场外退还凭据(转账单号/链上 tx,必填)'), ''); if (!evidence || !evidence.trim()) return
|
|
59
|
+
let token
|
|
60
|
+
try { token = await requestPasskeyGate('direct_receive_bond_refund', { deposit_id: id, evidence_ref: evidence.trim() }) }
|
|
61
|
+
catch (e) { if (window.dpPromptRegisterPasskey) await window.dpPromptRegisterPasskey(e); return }
|
|
62
|
+
const r = await POST(`/admin/direct-receive/deposits/${id}/execute-refund`, { evidence_ref: evidence.trim(), webauthn_token: token })
|
|
63
|
+
if (r.error) return void toast$(r.error, 'error')
|
|
64
|
+
toast$(t('退还已记录,卖家已通知'), 'success'); window.bondAdmHydrate()
|
|
65
|
+
}
|
|
66
|
+
})()
|