From 2934ebd36d16c031e4f3855665cd93a12e8e66b5 Mon Sep 17 00:00:00 2001
From: shuaiplus <2327005759@qq.com>
Date: Fri, 20 Feb 2026 20:28:08 +0800
Subject: [PATCH] feat: enhance registration page with TOTP support and UI
improvements
---
src/setup/pageTemplate.ts | 214 ++++++++++++++++++++++++++++++--------
1 file changed, 171 insertions(+), 43 deletions(-)
diff --git a/src/setup/pageTemplate.ts b/src/setup/pageTemplate.ts
index 95ba026..9077e67 100644
--- a/src/setup/pageTemplate.ts
+++ b/src/setup/pageTemplate.ts
@@ -116,7 +116,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
.step-container {
position: relative;
- height: 442px;
+ height: 447px;
overflow: hidden;
}
.step {
@@ -139,7 +139,6 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
display: none;
border-radius: 12px;
padding: 14px;
- margin: 0 0 12px 0;
font-size: 15px;
line-height: 1.45;
border: 1px solid var(--border);
@@ -304,6 +303,13 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
font-size: 14px;
}
.mode-tab.active { background: #111418; color: #fff; }
+ .icon-inline {
+ width: 18px;
+ height: 18px;
+ display: inline-block;
+ vertical-align: -3px;
+ margin-right: 6px;
+ }
.mode-panel { display: none; margin-top: 12px; }
.mode-panel.active { display: block; }
@@ -319,6 +325,47 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
word-break: break-all;
color: #111418;
}
+ .qr-wrap {
+ margin-top: 10px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px;
+ align-items: flex-start;
+ }
+ .qr-card {
+ border: 1px solid #d5dae1;
+ border-radius: 12px;
+ background: #f8fafc;
+ padding: 10px;
+ flex: 0 0 auto;
+ }
+ .qr-title {
+ text-align: center;
+ font-weight: 700;
+ font-size: 18px;
+ line-height: 1.2;
+ color: #111418;
+ margin-bottom: 8px;
+ }
+ .qr-box {
+ width: 170px;
+ height: 170px;
+ background: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ flex: 0 0 auto;
+ }
+ .qr-box img {
+ width: 170px;
+ height: 170px;
+ display: block;
+ }
+ .qr-side {
+ min-width: 240px;
+ flex: 1;
+ }
.flow-bottom {
margin-top: 14px;
@@ -476,15 +523,22 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
Fix steps
-
+
Random JWT_SECRET
-
-
+
+
+
@@ -565,24 +619,37 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
Optional: login TOTP (2FA)
-
Enable on server (Cloudflare Workers)
-
-
-
-
-
-
Use in Bitwarden client
-
-
-
-
+
+
+
+
+
+
![TOTP QR code]()
+
+
+
+
+
+
+
+
+
+
+
+
@@ -696,6 +763,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
s2GenTitle: '随机密钥生成器',
refresh: '刷新',
copy: '复制',
+ copySeed: '复制密钥',
copied: '已复制',
s3Title: '更新策略(可跳过)',
@@ -716,15 +784,10 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
s4Title: '创建账号',
s4Desc: '填写信息并创建你的唯一账号。创建成功后会进入登录 TOTP 教程。',
s5Title: '开启登录 TOTP(2FA,可跳过)',
- s5Desc: '这一页可跳过。如果你想开启登录二次验证,按下面步骤设置。',
s5EnableTitle: '服务端开启(Cloudflare Workers)',
s5Enable1: '打开 Cloudflare 控制台 -> Workers 和 Pages -> NodeWarden -> 设置 -> 变量和机密。',
- s5Enable2: '新增 Secret:TOTP_SECRET,值填写 Base32 密钥 。',
- s5Enable3: '保存并等待重新部署。删除 TOTP_SECRET 即关闭登录 2FA。',
- s5ClientTitle: '客户端使用',
- s5Client1: '下次登录时先输入主密码。',
- s5Client2: '客户端会弹出 2FA 验证码输入框,输入验证器里的 6 位码。',
- s5Client3: '如需可勾选记住此设备(30 天)。',
+ s5Enable2: '新增 Secret:TOTP_SECRET,值填写下方生成的 Base32 密钥。',
+ s5QrTitle: '扫描二维码',
s6Title: '最终页面',
s6Desc: '最后一步:查看客户端使用地址,并可选择隐藏初始化页面。',
nameLabel: '昵称',
@@ -795,6 +858,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
s2GenTitle: 'Random secret generator',
refresh: 'Refresh',
copy: 'Copy',
+ copySeed: 'Copy seed',
copied: 'Copied',
s3Title: 'Sync strategy (optional)',
@@ -815,15 +879,10 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
s4Title: 'Create account',
s4Desc: 'Create your single user account. After success, you will see the optional login TOTP guide.',
s5Title: 'Optional: login TOTP (2FA)',
- s5Desc: 'You can skip this step. If you want login 2FA, follow the steps below.',
s5EnableTitle: 'Enable on server (Cloudflare Workers)',
s5Enable1: 'Open Cloudflare Dashboard -> Workers & Pages -> your service -> Settings -> Variables and Secrets.',
- s5Enable2: 'Add Secret: TOTP_SECRET, value is a Base32 secret (example: JBSWY3DPEHPK3PXP).',
- s5Enable3: 'Save and wait for redeploy. Remove TOTP_SECRET to disable login 2FA.',
- s5ClientTitle: 'Use in Bitwarden client',
- s5Client1: 'On next login, enter your master password first.',
- s5Client2: 'Client will prompt 2FA code. Enter the 6-digit code from your authenticator app.',
- s5Client3: 'Optionally choose remember this device (30 days).',
+ s5Enable2: 'Add Secret: TOTP_SECRET, using the generated Base32 seed below.',
+ s5QrTitle: 'Scan QR code',
s6Title: 'Final step',
s6Desc: 'Last step: check your server URL, then optionally hide this setup page.',
nameLabel: 'Name',
@@ -910,8 +969,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
setText('t_s2_fix_title', t('s2FixTitle'));
renderJwtFixSteps();
setText('t_s2_gen_title', t('s2GenTitle'));
- setText('refreshSecretBtn', t('refresh'));
- setText('copySecretBtn', t('copy'));
+ setText('refreshSecretBtnText', t('refresh'));
+ setText('copySecretBtnText', t('copy'));
setText('t_s3_title', t('s3Title'));
setText('t_s3_common_title', t('s3CommonTitle'));
@@ -942,15 +1001,12 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
setText('t_hide_desc', t('hideDesc'));
setText('hideBtn', t('hideBtn'));
setText('t_s5_title', t('s5Title'));
- setText('t_s5_desc', t('s5Desc'));
setText('t_s5_enable_title', t('s5EnableTitle'));
setText('t_s5_enable_1', t('s5Enable1'));
setText('t_s5_enable_2', t('s5Enable2'));
- setText('t_s5_enable_3', t('s5Enable3'));
- setText('t_s5_client_title', t('s5ClientTitle'));
- setText('t_s5_client_1', t('s5Client1'));
- setText('t_s5_client_2', t('s5Client2'));
- setText('t_s5_client_3', t('s5Client3'));
+ setText('t_s5_qr_title', t('s5QrTitle'));
+ setText('refreshTotpBtnText', t('refresh'));
+ setText('copyTotpBtnText', t('copy'));
setText('t_s6_title', t('s6Title'));
setText('t_s6_desc', t('s6Desc'));
setText('hideModalTitle', t('hideModalTitle'));
@@ -1009,11 +1065,73 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
document.execCommand('copy');
ta.remove();
}
- const btn = document.getElementById('copySecretBtn');
- if (btn) {
- const old = btn.textContent;
- btn.textContent = t('copied');
- setTimeout(() => { btn.textContent = old; }, 1000);
+ const btnText = document.getElementById('copySecretBtnText');
+ if (btnText) {
+ const old = btnText.textContent;
+ btnText.textContent = t('copied');
+ setTimeout(() => { btnText.textContent = old; }, 1000);
+ }
+ }
+
+ function randomBase32(length) {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+ const bytes = new Uint8Array(length);
+ crypto.getRandomValues(bytes);
+ let out = '';
+ for (let i = 0; i < length; i++) out += chars[bytes[i] % chars.length];
+ return out;
+ }
+
+ function getTotpAccountLabel() {
+ const emailInput = document.getElementById('email');
+ const value = emailInput && typeof emailInput.value === 'string' ? emailInput.value.trim() : '';
+ return value || 'nodewarden@local';
+ }
+
+ function buildTotpUri(seed) {
+ const issuer = 'NodeWarden';
+ const account = getTotpAccountLabel();
+ return 'otpauth://totp/' + encodeURIComponent(issuer + ':' + account)
+ + '?secret=' + encodeURIComponent(seed)
+ + '&issuer=' + encodeURIComponent(issuer)
+ + '&algorithm=SHA1&digits=6&period=30';
+ }
+
+ function renderTotpHelper(seed) {
+ const seedEl = document.getElementById('totpSeed');
+ if (seedEl) seedEl.textContent = seed;
+
+ const uri = buildTotpUri(seed);
+ const qr = document.getElementById('totpQr');
+ if (qr) {
+ const qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=170x170&data=' + encodeURIComponent(uri);
+ qr.src = qrUrl;
+ }
+ }
+
+ function refreshTotpSeed() {
+ renderTotpHelper(randomBase32(32));
+ }
+
+ async function copyTotpSeed() {
+ const el = document.getElementById('totpSeed');
+ if (!el) return;
+ const text = el.textContent || '';
+ 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);
}
}
@@ -1289,6 +1407,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
function init() {
applyI18n();
refreshSecret();
+ refreshTotpSeed();
setSyncMode('manual');
goToStep(1);
checkStatus();
@@ -1323,6 +1442,15 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
});
}
+ const emailInput = document.getElementById('email');
+ if (emailInput) {
+ emailInput.addEventListener('change', () => {
+ const seedEl = document.getElementById('totpSeed');
+ const seed = seedEl ? (seedEl.textContent || '').trim() : '';
+ if (seed) renderTotpHelper(seed);
+ });
+ }
+
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeHideConfirmModal();
});