diff --git a/src/setup/pageTemplate.ts b/src/setup/pageTemplate.ts
index 9077e67..19b5bb1 100644
--- a/src/setup/pageTemplate.ts
+++ b/src/setup/pageTemplate.ts
@@ -367,6 +367,31 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
flex: 1;
}
+ .totp-preview {
+ margin-top: 12px;
+ border: 1px solid #d5dae1;
+ border-radius: 14px;
+ background: #f8fafc;
+ padding: 16px 20px;
+ display: none;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ }
+ .totp-code {
+ font-family: var(--mono);
+ font-size: 42px;
+ font-weight: 700;
+ letter-spacing: 8px;
+ color: #111418;
+ line-height: 1.1;
+ }
+ .totp-expire {
+ font-size: 14px;
+ color: var(--muted);
+ font-weight: 500;
+ }
+
.flow-bottom {
margin-top: 14px;
padding: 0 8px;
@@ -635,18 +660,25 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
-
-
-
-
@@ -788,6 +820,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
s5Enable1: '打开 Cloudflare 控制台 -> Workers 和 Pages -> NodeWarden -> 设置 -> 变量和机密。',
s5Enable2: '新增 Secret:TOTP_SECRET,值填写下方生成的 Base32 密钥。',
s5QrTitle: '扫描二维码',
+ copyCode: '复制验证码',
+ totpExpire: '秒后过期',
s6Title: '最终页面',
s6Desc: '最后一步:查看客户端使用地址,并可选择隐藏初始化页面。',
nameLabel: '昵称',
@@ -883,6 +917,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
s5Enable1: 'Open Cloudflare Dashboard -> Workers & Pages -> your service -> Settings -> Variables and Secrets.',
s5Enable2: 'Add Secret: TOTP_SECRET, using the generated Base32 seed below.',
s5QrTitle: 'Scan QR code',
+ copyCode: 'Copy code',
+ totpExpire: 's left',
s6Title: 'Final step',
s6Desc: 'Last step: check your server URL, then optionally hide this setup page.',
nameLabel: 'Name',
@@ -1007,6 +1043,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
setText('t_s5_qr_title', t('s5QrTitle'));
setText('refreshTotpBtnText', t('refresh'));
setText('copyTotpBtnText', t('copy'));
+ setText('copyTotpCodeBtnText', t('copyCode'));
setText('t_s6_title', t('s6Title'));
setText('t_s6_desc', t('s6Desc'));
setText('hideModalTitle', t('hideModalTitle'));
@@ -1099,7 +1136,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
function renderTotpHelper(seed) {
const seedEl = document.getElementById('totpSeed');
- if (seedEl) seedEl.textContent = seed;
+ if (seedEl) seedEl.value = seed;
const uri = buildTotpUri(seed);
const qr = document.getElementById('totpQr');
@@ -1107,15 +1144,109 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
const qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=170x170&data=' + encodeURIComponent(uri);
qr.src = qrUrl;
}
+
+ const preview = document.getElementById('totpPreview');
+ if (preview) preview.style.display = 'flex';
+ startTotpTick();
}
function refreshTotpSeed() {
renderTotpHelper(randomBase32(32));
}
+ let totpSeedInputTimer = null;
+ function onTotpSeedInput() {
+ clearTimeout(totpSeedInputTimer);
+ totpSeedInputTimer = setTimeout(() => {
+ const el = document.getElementById('totpSeed');
+ const seed = el ? el.value.trim() : '';
+ if (!seed) return;
+ const uri = buildTotpUri(seed);
+ const qr = document.getElementById('totpQr');
+ if (qr) qr.src = 'https://api.qrserver.com/v1/create-qr-code/?size=170x170&data=' + encodeURIComponent(uri);
+ const preview = document.getElementById('totpPreview');
+ if (preview) preview.style.display = 'flex';
+ startTotpTick();
+ }, 400);
+ }
+
async function copyTotpSeed() {
const el = document.getElementById('totpSeed');
if (!el) return;
+ const text = el.value || '';
+ try {
+ await navigator.clipboard.writeText(text);
+ } catch {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ ta.remove();
+ }
+ const btnText = document.getElementById('copyTotpBtnText');
+ if (btnText) {
+ const old = btnText.textContent;
+ btnText.textContent = t('copied');
+ setTimeout(() => { btnText.textContent = old; }, 1000);
+ }
+ }
+
+ function base32ToBuf(base32) {
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+ const s = base32.toUpperCase().replace(/[\s=\-]/g, '');
+ let bits = 0, val = 0;
+ const output = [];
+ for (const c of s) {
+ const idx = alphabet.indexOf(c);
+ if (idx === -1) return null;
+ val = (val << 5) | idx;
+ bits += 5;
+ if (bits >= 8) { bits -= 8; output.push((val >> bits) & 0xff); }
+ }
+ return output.length ? new Uint8Array(output) : null;
+ }
+
+ async function computeTotp(seed) {
+ const secret = base32ToBuf(seed);
+ if (!secret) return null;
+ const counter = Math.floor(Date.now() / 1000 / 30);
+ const cb = new Uint8Array(8);
+ let c = counter;
+ for (let i = 7; i >= 0; i--) { cb[i] = c & 0xff; c = Math.floor(c / 256); }
+ const key = await crypto.subtle.importKey('raw', secret, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
+ const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, cb));
+ const offset = sig[sig.length - 1] & 0x0f;
+ const binary = ((sig[offset] & 0x7f) << 24) | ((sig[offset+1] & 0xff) << 16) | ((sig[offset+2] & 0xff) << 8) | (sig[offset+3] & 0xff);
+ return (binary % 1000000).toString().padStart(6, '0');
+ }
+
+ let totpTickTimer = null;
+
+ async function totpTick() {
+ const seedEl = document.getElementById('totpSeed');
+ const codeEl = document.getElementById('totpCodeDisplay');
+ const expireEl = document.getElementById('totpExpireText');
+ if (!seedEl || !codeEl || !expireEl) return;
+ const seed = seedEl.value.trim();
+ if (!seed) return;
+ const remaining = 30 - (Math.floor(Date.now() / 1000) % 30);
+ const code = await computeTotp(seed);
+ if (code) {
+ codeEl.textContent = code;
+ expireEl.textContent = remaining + t('totpExpire');
+ }
+ }
+
+ function startTotpTick() {
+ if (totpTickTimer) clearInterval(totpTickTimer);
+ totpTick();
+ totpTickTimer = setInterval(totpTick, 1000);
+ }
+
+ async function copyTotpCode() {
+ const el = document.getElementById('totpCodeDisplay');
+ if (!el) return;
const text = el.textContent || '';
try {
await navigator.clipboard.writeText(text);
@@ -1127,7 +1258,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
document.execCommand('copy');
ta.remove();
}
- const btnText = document.getElementById('copyTotpBtnText');
+ const btnText = document.getElementById('copyTotpCodeBtnText');
if (btnText) {
const old = btnText.textContent;
btnText.textContent = t('copied');
@@ -1446,7 +1577,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
if (emailInput) {
emailInput.addEventListener('change', () => {
const seedEl = document.getElementById('totpSeed');
- const seed = seedEl ? (seedEl.textContent || '').trim() : '';
+ const seed = seedEl ? (seedEl.value || '').trim() : '';
if (seed) renderTotpHelper(seed);
});
}