mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add TOTP code generation and display functionality with UI enhancements
This commit is contained in:
+139
-8
@@ -367,6 +367,31 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
flex: 1;
|
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 {
|
.flow-bottom {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
@@ -635,18 +660,25 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="qr-side">
|
<div class="qr-side">
|
||||||
<div class="server" id="totpSeed" style="margin-top:0;"></div>
|
<div style="display:flex; gap:8px; align-items:stretch;">
|
||||||
<div style="height:10px"></div>
|
<input class="server" id="totpSeed" type="text" spellcheck="false" autocomplete="off" autocapitalize="off" style="margin-top:0; flex:1; min-width:0; height:auto; cursor:text;" oninput="onTotpSeedInput()">
|
||||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
<button class="btn primary" type="button" id="refreshTotpBtn" onclick="refreshTotpSeed()" style="flex-shrink:0;">
|
||||||
<button class="btn primary" type="button" id="refreshTotpBtn" onclick="refreshTotpSeed()">
|
|
||||||
<svg class="icon-inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#fff" aria-hidden="true"><path d="M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z"/></svg>
|
<svg class="icon-inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#fff" aria-hidden="true"><path d="M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z"/></svg>
|
||||||
<span id="refreshTotpBtnText">Refresh</span>
|
<span id="refreshTotpBtnText">Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" type="button" id="copyTotpBtn" onclick="copyTotpSeed()">
|
<button class="btn" type="button" id="copyTotpBtn" onclick="copyTotpSeed()" style="flex-shrink:0;">
|
||||||
<svg class="icon-inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#1f1f1f" aria-hidden="true"><path d="M360-240q-33 0-56.5-23.5T280-320v-480q0-33 23.5-56.5T360-880h360q33 0 56.5 23.5T800-800v480q0 33-23.5 56.5T720-240H360Zm0-80h360v-480H360v480ZM200-80q-33 0-56.5-23.5T120-160v-560h80v560h440v80H200Zm160-240v-480 480Z"/></svg>
|
<svg class="icon-inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#1f1f1f" aria-hidden="true"><path d="M360-240q-33 0-56.5-23.5T280-320v-480q0-33 23.5-56.5T360-880h360q33 0 56.5 23.5T800-800v480q0 33-23.5 56.5T720-240H360Zm0-80h360v-480H360v480ZM200-80q-33 0-56.5-23.5T120-160v-560h80v560h440v80H200Zm160-240v-480 480Z"/></svg>
|
||||||
<span id="copyTotpBtnText">Copy</span>
|
<span id="copyTotpBtnText">Copy</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="totp-preview" id="totpPreview">
|
||||||
|
<div class="totp-code" id="totpCodeDisplay">------</div>
|
||||||
|
<div class="totp-expire" id="totpExpireText"></div>
|
||||||
|
<button class="btn" type="button" id="copyTotpCodeBtn" onclick="copyTotpCode()">
|
||||||
|
<svg class="icon-inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#1f1f1f" aria-hidden="true"><path d="M360-240q-33 0-56.5-23.5T280-320v-480q0-33 23.5-56.5T360-880h360q33 0 56.5 23.5T800-800v480q0 33-23.5 56.5T720-240H360Zm0-80h360v-480H360v480ZM200-80q-33 0-56.5-23.5T120-160v-560h80v560h440v80H200Zm160-240v-480 480Z"/></svg>
|
||||||
|
<span id="copyTotpCodeBtnText">Copy code</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -788,6 +820,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
s5Enable1: '打开 Cloudflare 控制台 -> Workers 和 Pages -> NodeWarden -> 设置 -> 变量和机密。',
|
s5Enable1: '打开 Cloudflare 控制台 -> Workers 和 Pages -> NodeWarden -> 设置 -> 变量和机密。',
|
||||||
s5Enable2: '新增 Secret:TOTP_SECRET,值填写下方生成的 Base32 密钥。',
|
s5Enable2: '新增 Secret:TOTP_SECRET,值填写下方生成的 Base32 密钥。',
|
||||||
s5QrTitle: '扫描二维码',
|
s5QrTitle: '扫描二维码',
|
||||||
|
copyCode: '复制验证码',
|
||||||
|
totpExpire: '秒后过期',
|
||||||
s6Title: '最终页面',
|
s6Title: '最终页面',
|
||||||
s6Desc: '最后一步:查看客户端使用地址,并可选择隐藏初始化页面。',
|
s6Desc: '最后一步:查看客户端使用地址,并可选择隐藏初始化页面。',
|
||||||
nameLabel: '昵称',
|
nameLabel: '昵称',
|
||||||
@@ -883,6 +917,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
s5Enable1: 'Open Cloudflare Dashboard -> Workers & Pages -> your service -> Settings -> Variables and Secrets.',
|
s5Enable1: 'Open Cloudflare Dashboard -> Workers & Pages -> your service -> Settings -> Variables and Secrets.',
|
||||||
s5Enable2: 'Add Secret: TOTP_SECRET, using the generated Base32 seed below.',
|
s5Enable2: 'Add Secret: TOTP_SECRET, using the generated Base32 seed below.',
|
||||||
s5QrTitle: 'Scan QR code',
|
s5QrTitle: 'Scan QR code',
|
||||||
|
copyCode: 'Copy code',
|
||||||
|
totpExpire: 's left',
|
||||||
s6Title: 'Final step',
|
s6Title: 'Final step',
|
||||||
s6Desc: 'Last step: check your server URL, then optionally hide this setup page.',
|
s6Desc: 'Last step: check your server URL, then optionally hide this setup page.',
|
||||||
nameLabel: 'Name',
|
nameLabel: 'Name',
|
||||||
@@ -1007,6 +1043,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
setText('t_s5_qr_title', t('s5QrTitle'));
|
setText('t_s5_qr_title', t('s5QrTitle'));
|
||||||
setText('refreshTotpBtnText', t('refresh'));
|
setText('refreshTotpBtnText', t('refresh'));
|
||||||
setText('copyTotpBtnText', t('copy'));
|
setText('copyTotpBtnText', t('copy'));
|
||||||
|
setText('copyTotpCodeBtnText', t('copyCode'));
|
||||||
setText('t_s6_title', t('s6Title'));
|
setText('t_s6_title', t('s6Title'));
|
||||||
setText('t_s6_desc', t('s6Desc'));
|
setText('t_s6_desc', t('s6Desc'));
|
||||||
setText('hideModalTitle', t('hideModalTitle'));
|
setText('hideModalTitle', t('hideModalTitle'));
|
||||||
@@ -1099,7 +1136,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
|
|
||||||
function renderTotpHelper(seed) {
|
function renderTotpHelper(seed) {
|
||||||
const seedEl = document.getElementById('totpSeed');
|
const seedEl = document.getElementById('totpSeed');
|
||||||
if (seedEl) seedEl.textContent = seed;
|
if (seedEl) seedEl.value = seed;
|
||||||
|
|
||||||
const uri = buildTotpUri(seed);
|
const uri = buildTotpUri(seed);
|
||||||
const qr = document.getElementById('totpQr');
|
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);
|
const qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=170x170&data=' + encodeURIComponent(uri);
|
||||||
qr.src = qrUrl;
|
qr.src = qrUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preview = document.getElementById('totpPreview');
|
||||||
|
if (preview) preview.style.display = 'flex';
|
||||||
|
startTotpTick();
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshTotpSeed() {
|
function refreshTotpSeed() {
|
||||||
renderTotpHelper(randomBase32(32));
|
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() {
|
async function copyTotpSeed() {
|
||||||
const el = document.getElementById('totpSeed');
|
const el = document.getElementById('totpSeed');
|
||||||
if (!el) return;
|
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 || '';
|
const text = el.textContent || '';
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
@@ -1127,7 +1258,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
ta.remove();
|
ta.remove();
|
||||||
}
|
}
|
||||||
const btnText = document.getElementById('copyTotpBtnText');
|
const btnText = document.getElementById('copyTotpCodeBtnText');
|
||||||
if (btnText) {
|
if (btnText) {
|
||||||
const old = btnText.textContent;
|
const old = btnText.textContent;
|
||||||
btnText.textContent = t('copied');
|
btnText.textContent = t('copied');
|
||||||
@@ -1446,7 +1577,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
|||||||
if (emailInput) {
|
if (emailInput) {
|
||||||
emailInput.addEventListener('change', () => {
|
emailInput.addEventListener('change', () => {
|
||||||
const seedEl = document.getElementById('totpSeed');
|
const seedEl = document.getElementById('totpSeed');
|
||||||
const seed = seedEl ? (seedEl.textContent || '').trim() : '';
|
const seed = seedEl ? (seedEl.value || '').trim() : '';
|
||||||
if (seed) renderTotpHelper(seed);
|
if (seed) renderTotpHelper(seed);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user