185 lines
9.4 KiB
HTML
185 lines
9.4 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.ico" sizes="any" />
|
||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||
<link rel="apple-touch-icon" href="/favicon-180.png" />
|
||
<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; }
|
||
/* 2026-09-27 · 官方账号登录入口:分隔线 + 同宽次级按钮(⛔ 不引外链、⛔ 不加第三方脚本) */
|
||
.auth-or { display: flex; align-items: center; gap: 10px; margin: 16px 0 14px;
|
||
color: var(--a-dim, #6b7280); font-size: 13px; }
|
||
.auth-or::before, .auth-or::after { content: ""; flex: 1; height: 1px; background: var(--a-border, #e3e6ea); }
|
||
.btn.dsh-login { width: 100%; border: 1px solid var(--a-border, #e3e6ea); background: #fff;
|
||
color: var(--a-text, #24292f); text-decoration: none; }
|
||
.btn.dsh-login:hover { background: #eaf1fe; border-color: var(--a-primary, #2f6fed); }
|
||
/* 档案 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>
|
||
|
||
<!-- 2026-09-27 · 官方账号登录(DSH 账号):走官方 OAuth(PKCE,S256),
|
||
回来之后与密码登录**同一套** `sid` 会话 ⇒ 两条路都能去批准扫码配对。
|
||
⚠️ `next` 由下面的脚本补上(站内绝对路径,判据与密码登录那条逐条同源)。 -->
|
||
<div class="auth-or" data-i18n="login.or">or</div>
|
||
<a class="btn dsh-login" id="dshLogin" href="/oauth/dsh/start"
|
||
data-i18n="login.dshAccount">Sign in with DSH account</a>
|
||
|
||
<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')
|
||
// 扫码配对(2026-09-27):确认页在**未登录**时会把人送到这里,并用 `?next=` 记住要去哪。
|
||
// ⚠️ 只接受**站内绝对路径**(`/` 开头且不是 `//`,排除协议相对跳转)—— 否则就是开放重定向。
|
||
const nextUrl = (() => {
|
||
const raw = new URLSearchParams(location.search).get('next') || ''
|
||
return raw.startsWith('/') && !raw.startsWith('//') ? raw : null
|
||
})()
|
||
// 2026-09-27 · 官方账号登录:与上面**同一个 `next` 口径** —— 两条路登完都回原来那一页
|
||
// (手机扫码那条路即 `/pair.html?code=…`)。
|
||
const dshLink = document.getElementById('dshLogin')
|
||
if (dshLink && nextUrl !== null) dshLink.href = '/oauth/dsh/start?next=' + encodeURIComponent(nextUrl)
|
||
// 官方入口失败:服务端 302 回 `?dsh_error=start` ⇒ 就地给一句提示。
|
||
// ⛔ 不显示官方返回的任何内容(本页拿不到也不需要拿)。
|
||
if (new URLSearchParams(location.search).get('dsh_error')) {
|
||
msg.className = 'msg err'
|
||
msg.textContent = I18N.t('login.errOauth')
|
||
}
|
||
// 档案 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
|
||
// 扫码配对(2026-09-27):带着 `?next=` 进来 ⇒ 登完**回那一页**(例如"确认绑定这台电脑")。
|
||
// ⛔ 不改成"所有登录都跳过 /api/dsh/enter" —— 那条默认路径逐字不变。
|
||
if (nextUrl !== null) {
|
||
setBusy(true, I18N.t('login.entering'))
|
||
location.href = nextUrl
|
||
return
|
||
}
|
||
// 所有用户(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>
|