三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。 ⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。 【档案 134 · 注册页人机验证 + 邮箱验证码】 - DB 迁移 v10:users.email(唯一索引 LOWER(email))+ email_codes 事件表(2 索引) - 新增模块 src/web/{register-guard,mail,turnstile,email-code}.ts - routes/auth.ts:新增 GET /api/auth/register/config、POST /api/auth/register/email-code; 注册接口加人机验证与验证码校验;config.ts 新增 12 项配置(默认空 ⇒ 不配 = 老行为) - 邮件走**可插拔驱动**(brevo/http/log),发件人 [email protected](Brevo 域名已认证 + DKIM + SPF) - 防爆破:三层配额(邮箱 6/h、8/天;IP 20/h;全局 200/h)+ 递增冷却阶梯 (60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用 + 与用户名绑定 - Turnstile 服务端校 **success + action + hostname 三项**:sitekey 是公开的, 只校 success 时"拿我们的 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的 - 新增 test/register-guard.test.mjs(19 用例) 【档案 137 · 品牌标识改造 — 去 DeepSeek 图形】 - login/register/admin 页头:删 DeepSeek 鲸鱼图标 + 「DeepSeek」文字图形 → 平台标识(中文「能力枢纽」/英语及其他语言「CapabilityNet」,走 i18n 词条 brand.name) - portal 顶栏换图标(页面名「管理门户」保留) - 新建 web/favicon.svg(平台自有 hub 图标,避开 DeepSeek 蓝)+ 四页 favicon 指向它 - 新增 test/i18n-brand.test.mjs(node:vm 跑真实 i18n.js,六条语言路径断言渲染结果) - scripts/verify-static.mjs 新增 SVG 段:XML 注释不得含 ASCII 双连字符(否则整份 SVG 解析失败、图标静默不显示 —— 实际踩到过) - 🔴 会话页面(实例内官方 dsh 界面)的标识**按用户要求未动**(也受 R2 约束) 【档案 135/136 · 域名迁移线(另一会话产出)】 - 域名收敛为 ai1net.com;旧域 alotbuy.com 降级为 301 过渡装置 - src/net/relay/{addr-override,directory,rendezvous,switcher}.ts 种子与候选链更新; src/web/server.ts、src/worker/relay-tunnel.ts、scripts/verify-cluster-domain.mjs - 档案 136 = 控制面按两台中继取并集(**已立项、未落地**) 验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped| verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、 发码 delivered、四页 deepseek 命中 0、favicon 200。
146 lines
6.6 KiB
HTML
146 lines
6.6 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title data-i18n="login.title">Sign in</title>
|
||
<link rel="icon" href="/favicon.svg" />
|
||
<link rel="stylesheet" href="/design.css" />
|
||
<link rel="stylesheet" href="/auth-ui.css" />
|
||
<!-- 档案 81 · R5:平台多语言运行时(词条表 + data-i18n 就地替换;默认英语) -->
|
||
<script src="/i18n.js"></script>
|
||
<style>
|
||
/* 档案 81 · R2-UI:提交忙碌态(点击后立刻可见;对齐 06-工作台UI规范 的按钮/圆角/主色) */
|
||
.btn[disabled] { opacity: .72; cursor: not-allowed; }
|
||
.btn.is-busy { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
|
||
.btn .sp { width: 14px; height: 14px; flex: 0 0 14px; border: 2px solid rgba(255,255,255,.45);
|
||
border-top-color: #fff; border-radius: 50%; animation: btnsp .7s linear infinite; }
|
||
@keyframes btnsp { to { transform: rotate(360deg); } }
|
||
.msg { min-height: 20px; }
|
||
/* 档案 81 · R5:语言切换(页脚式下拉,不新增页面;中性样式,不引外链) */
|
||
.auth-lang { margin-top: 14px; display: flex; align-items: center; justify-content: center; gap: 8px; }
|
||
.auth-lang .i18n-lang-label { font-size: 13px; opacity: .72; }
|
||
.auth-lang .i18n-lang { font-size: 13px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
|
||
border: 1px solid rgba(0,0,0,.16); background: transparent; color: inherit; }
|
||
@media (prefers-reduced-motion: reduce) { .btn .sp { animation: none; } }
|
||
</style>
|
||
</head>
|
||
<body class="auth-bg">
|
||
<div class="auth-card">
|
||
<div class="auth-brand">
|
||
<span class="wordmark" data-i18n="brand.name">CapabilityNet</span>
|
||
</div>
|
||
<p class="auth-sub" data-i18n="login.sub">Welcome to the AI world</p>
|
||
|
||
<form id="login">
|
||
<div class="field">
|
||
<label for="username" data-i18n="login.username">Username</label>
|
||
<input id="username" name="username" autocomplete="username" required />
|
||
</div>
|
||
<div class="field">
|
||
<label for="password" data-i18n="login.password">Password</label>
|
||
<div class="pw-wrap">
|
||
<input id="password" name="password" type="password" autocomplete="current-password" required />
|
||
<button class="pw-toggle" type="button" id="pwToggle" data-i18n="common.show"
|
||
data-i18n-attr="aria-label:common.showPw" aria-label="Show password" aria-pressed="false">Show</button>
|
||
</div>
|
||
</div>
|
||
<button class="btn primary" type="submit" data-i18n="login.submit">Sign in</button>
|
||
|
||
<p class="msg" id="msg"></p>
|
||
</form>
|
||
|
||
<p class="auth-alt"><span data-i18n="login.noAccount">Don't have an account?</span>
|
||
<a href="/register.html" data-i18n="login.register">Sign up</a></p>
|
||
|
||
<!-- 档案 81 · R5:语言切换(默认英语;切换写 cookie dsh_lang 后重载) -->
|
||
<div class="auth-lang"><div data-i18n-switch="label"></div></div>
|
||
</div>
|
||
|
||
<script>
|
||
|
||
// 档案 83 · 显示/隐藏密码(对齐 06-工作台UI规范 的 .btn-icon 交互;键盘可达 = 原生 button)
|
||
const pwInput = document.getElementById('password')
|
||
const pwBtn = document.getElementById('pwToggle')
|
||
if (pwInput && pwBtn) {
|
||
pwBtn.addEventListener('click', () => {
|
||
const show = pwInput.type === 'password'
|
||
pwInput.type = show ? 'text' : 'password'
|
||
pwBtn.textContent = I18N.t(show ? 'common.hide' : 'common.show')
|
||
pwBtn.setAttribute('aria-label', I18N.t(show ? 'common.hidePw' : 'common.showPw'))
|
||
pwBtn.setAttribute('aria-pressed', String(show))
|
||
pwInput.focus()
|
||
})
|
||
}
|
||
|
||
const form = document.getElementById('login')
|
||
const msg = document.getElementById('msg')
|
||
// 档案 81 · R2-UI:任何提交都要有"看得见"的忙碌态(否则几秒内用户会以为没反应)
|
||
function setBusy(busy, label) {
|
||
const btn = form.querySelector('button[type=submit]')
|
||
if (!btn) return
|
||
if (busy) {
|
||
if (!btn.dataset.idle) btn.dataset.idle = btn.innerHTML
|
||
btn.disabled = true
|
||
btn.classList.add('is-busy')
|
||
btn.innerHTML = '<span class="sp"></span><span>' + (label || I18N.t('common.busy')) + '</span>'
|
||
} else {
|
||
btn.disabled = false
|
||
btn.classList.remove('is-busy')
|
||
if (btn.dataset.idle) btn.innerHTML = btn.dataset.idle
|
||
}
|
||
}
|
||
form.addEventListener('submit', async (event) => {
|
||
event.preventDefault()
|
||
msg.textContent = ''
|
||
msg.className = 'msg err'
|
||
setBusy(true, I18N.t('login.signingIn'))
|
||
const res = await fetch('/api/auth/login', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ username: form.username.value, password: form.password.value }),
|
||
})
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}))
|
||
msg.textContent =
|
||
body.error === 'pending_review' ? I18N.t('login.errPending')
|
||
: body.error === 'disabled' ? I18N.t('login.errDisabled')
|
||
: I18N.t('login.errBadCreds')
|
||
setBusy(false)
|
||
return
|
||
}
|
||
const body = await res.json()
|
||
const role = body && body.user ? body.user.role : null
|
||
// 所有用户(admin/active)登录后统一直达 DSH 会话窗口
|
||
setBusy(true, I18N.t('login.entering'))
|
||
try {
|
||
const r = await fetch('/api/dsh/enter', { method: 'POST' })
|
||
const b = await r.json().catch(() => ({}))
|
||
if (r.ok && b.kind === 'session' && b.url) {
|
||
location.href = b.url
|
||
return
|
||
}
|
||
// 失败兜底:admin 可手动进管理台 /portal.html
|
||
const errText =
|
||
b.error === 'instance_starting' ? I18N.t('login.errStarting')
|
||
: (b.error ?? 'HTTP ' + r.status)
|
||
// 档案 81 · R5:不再用 innerHTML 拼文案(安全);管理台入口用 DOM 组装
|
||
msg.textContent = I18N.t('login.errEnter') + errText
|
||
if (role === 'admin') {
|
||
const link = document.createElement('a')
|
||
link.href = '/portal.html'
|
||
link.textContent = I18N.t('login.gotoAdmin')
|
||
msg.appendChild(document.createTextNode('('))
|
||
msg.appendChild(link)
|
||
msg.appendChild(document.createTextNode(')'))
|
||
}
|
||
setBusy(false)
|
||
} catch {
|
||
msg.textContent = I18N.t('login.errNetwork')
|
||
setBusy(false)
|
||
}
|
||
})
|
||
</script>
|
||
</body>
|
||
</html>
|