feat: 更新网页客户端样式和布局,提升用户体验

This commit is contained in:
shuaiplus
2026-02-26 05:04:43 +08:00
committed by Shuai
parent 6bbc7554c1
commit 4a37d742eb
+237 -143
View File
@@ -13,213 +13,298 @@ function renderWebClientHTML(): string {
<title>NodeWarden Web</title> <title>NodeWarden Web</title>
<style> <style>
:root { :root {
--bg: #f4f0e7; --bg: #f8fafc;
--bg2: #e9f1ec; --bg2: #f1f5f9;
--panel: #fffdf8; --panel: #ffffff;
--line: #d7ccbb; --line: #e2e8f0;
--text: #1f1710; --text: #0f172a;
--muted: #6a5f52; --muted: #64748b;
--primary: #a63c2b; --primary: #0f172a;
--primary2: #1f6b5a; --primary-hover: #334155;
--danger: #a53024; --primary2: #3b82f6;
--ok: #0f7a3d; --primary2-hover: #2563eb;
--danger: #ef4444;
--danger-hover: #dc2626;
--danger-bg: #fef2f2;
--ok: #10b981;
--ok-bg: #ecfdf5;
--radius: 16px;
--radius-sm: 10px;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { height: 100%; } html, body { height: 100%; }
body { body {
margin: 0; margin: 0;
color: var(--text); color: var(--text);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; font-family: var(--font-sans);
background: background-color: var(--bg);
radial-gradient(circle at 12% 14%, #f6ddaf 0%, transparent 35%), background-image:
radial-gradient(circle at 84% 20%, #d5efe4 0%, transparent 33%), radial-gradient(at 0% 0%, hsla(253,16%,7%,0.05) 0, transparent 50%),
linear-gradient(155deg, var(--bg) 0%, var(--bg2) 100%); radial-gradient(at 50% 0%, hsla(225,39%,30%,0.05) 0, transparent 50%),
radial-gradient(at 100% 0%, hsla(339,49%,30%,0.05) 0, transparent 50%);
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
} }
#app { min-height: 100%; } #app { min-height: 100%; display: flex; flex-direction: column; }
.shell { min-height: 100%; padding: 10px; } .shell { flex: 1; padding: 20px; display: flex; flex-direction: column; }
.auth { .auth {
min-height: calc(100vh - 20px); width: 100%;
max-width: 900px;
min-height: 600px;
margin: auto;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 18px; border-radius: 24px;
background: var(--panel); background: var(--panel);
box-shadow: 0 20px 46px rgba(26, 18, 12, 0.12); box-shadow: var(--shadow-lg);
display: grid; display: grid;
grid-template-columns: 360px 1fr; grid-template-columns: 360px 1fr;
overflow: hidden; overflow: hidden;
} }
.auth-left { .auth-left {
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
padding: 24px; padding: 40px;
background: #fff8eb; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
display: flex;
flex-direction: column;
justify-content: center;
} }
.brand { .brand {
width: 58px; width: 64px;
height: 58px; height: 64px;
border-radius: 13px; border-radius: 16px;
background: #111; background: linear-gradient(135deg, #0f172a, #334155);
color: #fff; color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: 800; font-weight: 800;
margin-bottom: 14px; font-size: 24px;
margin-bottom: 24px;
user-select: none; user-select: none;
box-shadow: var(--shadow);
} }
.auth-left h1 { margin: 0; font-size: 30px; } .auth-left h1 { margin: 0; font-size: 32px; font-weight: 700; letter-spacing: -0.02em; }
.auth-left p { margin: 10px 0 0 0; color: var(--muted); line-height: 1.7; font-size: 14px; } .auth-left p { margin: 16px 0 0 0; color: var(--muted); line-height: 1.6; font-size: 15px; }
.auth-right { padding: 24px; position: relative; } .auth-right { padding: 40px; position: relative; display: flex; flex-direction: column; justify-content: center; }
.section-title { margin: 0 0 12px 0; font-size: 28px; } .section-title { margin: 0 0 24px 0; font-size: 28px; font-weight: 700; letter-spacing: -0.02em; }
.msg { .msg {
margin-bottom: 12px; margin-bottom: 20px;
border-radius: 10px; border-radius: var(--radius-sm);
border: 1px solid var(--line); border: 1px solid var(--line);
padding: 10px 12px; padding: 12px 16px;
font-size: 13px; font-size: 14px;
background: #fff; background: #fff;
display: flex;
align-items: center;
gap: 8px;
} }
.msg.ok { color: var(--ok); border-color: #9dd2b6; background: #f0fbf4; } .msg.ok { color: var(--ok); border-color: #a7f3d0; background: var(--ok-bg); }
.msg.err { color: var(--danger); border-color: #f2b4a9; background: #fff5f2; } .msg.err { color: var(--danger); border-color: #fecaca; background: var(--danger-bg); }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.field { margin-bottom: 10px; } .field { margin-bottom: 16px; }
.field label { display: block; margin-bottom: 6px; color: var(--muted); font-size: 13px; } .field label { display: block; margin-bottom: 8px; color: var(--text); font-size: 14px; font-weight: 500; }
.field input, .field select, .field textarea { .field input, .field select, .field textarea {
width: 100%; width: 100%;
min-height: 44px; min-height: 48px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: var(--radius-sm);
background: #fffdf8; background: #fff;
color: var(--text); color: var(--text);
padding: 0 12px; padding: 0 16px;
font-size: 14px; font-size: 15px;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
.field input:focus, .field select:focus, .field textarea:focus {
outline: none;
border-color: var(--primary2);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
} }
.field textarea { .field textarea {
min-height: 90px; min-height: 100px;
padding-top: 10px; padding-top: 12px;
padding-bottom: 10px; padding-bottom: 12px;
resize: vertical; resize: vertical;
} }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } .actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
.btn { .btn {
border: 1px solid #b8ad9c; border: 1px solid var(--line);
background: #f4ede3; background: #fff;
color: var(--text); color: var(--text);
border-radius: 10px; border-radius: var(--radius-sm);
min-height: 40px; min-height: 44px;
padding: 0 12px; padding: 0 20px;
font-weight: 700; font-weight: 600;
font-size: 14px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
display: inline-flex;
align-items: center;
justify-content: center;
} }
.btn.primary { border-color: #8f3124; background: var(--primary); color: #fff; } .btn:hover { background: #f8fafc; border-color: #cbd5e1; }
.btn.secondary { border-color: #1b594c; background: var(--primary2); color: #fff; } .btn.primary { border-color: var(--primary); background: var(--primary); color: #fff; }
.btn.danger { border-color: #7f261d; background: var(--danger); color: #fff; } .btn.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
.tiny { font-size: 12px; color: var(--muted); } .btn.secondary { border-color: var(--primary2); background: var(--primary2); color: #fff; }
.btn.secondary:hover { background: var(--primary2-hover); border-color: var(--primary2-hover); }
.btn.danger { border-color: var(--danger); background: var(--danger); color: #fff; }
.btn.danger:hover { background: var(--danger-hover); border-color: var(--danger-hover); }
.tiny { font-size: 13px; color: var(--muted); margin-top: 8px; line-height: 1.5; }
.totp-mask { .totp-mask {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(22, 17, 12, 0.48); background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(4px);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 16px; padding: 20px;
z-index: 50;
border-radius: 24px;
} }
.totp-box { .totp-box {
width: min(460px, 100%); width: min(400px, 100%);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 14px; border-radius: 20px;
background: #fff; background: #fff;
padding: 16px; padding: 32px;
box-shadow: 0 18px 30px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-xl);
} }
.totp-box h3 { margin: 0 0 8px 0; font-size: 20px; } .totp-box h3 { margin: 0 0 12px 0; font-size: 22px; font-weight: 700; letter-spacing: -0.01em; }
.app-layout { .app-layout {
min-height: calc(100vh - 20px); width: 100%;
max-width: 1400px;
height: calc(100vh - 40px);
margin: auto;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 18px; border-radius: 24px;
background: var(--panel); background: var(--panel);
box-shadow: 0 16px 40px rgba(26, 18, 12, 0.12); box-shadow: var(--shadow-lg);
overflow: hidden; overflow: hidden;
display: grid; display: grid;
} }
.app-layout.normal-layout { grid-template-columns: 250px 1fr; } .app-layout.normal-layout { grid-template-columns: 260px 1fr; }
.app-layout.vault-layout { grid-template-columns: 250px 260px 1fr; } .app-layout.vault-layout { grid-template-columns: 260px 300px 1fr; }
.sidebar, .folderbar { .sidebar, .folderbar {
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
padding: 14px; padding: 20px;
background: #fff8eb; background: #f8fafc;
min-width: 0; min-width: 0;
display: flex;
flex-direction: column;
} }
.folderbar { background: #fffaf1; } .folderbar { background: #ffffff; }
.sidebar .brand { width: 50px; height: 50px; margin-bottom: 8px; } .sidebar .brand { width: 48px; height: 48px; margin-bottom: 16px; font-size: 18px; border-radius: 12px; }
.sidebar .mail { font-size: 12px; color: var(--muted); margin-bottom: 10px; word-break: break-all; } .sidebar .mail { font-size: 13px; color: var(--muted); margin-bottom: 24px; word-break: break-all; font-weight: 500; }
.nav-btn, .folder-btn { width: 100%; text-align: left; margin-bottom: 8px; } .nav-btn, .folder-btn {
.nav-btn.active { border-color: #8f3124; background: #fff2ea; color: #7f271c; } width: 100%;
.folder-btn { margin-bottom: 6px; font-size: 13px; } text-align: left;
.folder-btn.active { border-color: #1b594c; background: #e9f6f0; color: #184f43; } margin-bottom: 8px;
.content { padding: 12px; min-width: 0; overflow: auto; } justify-content: flex-start;
border: none;
box-shadow: none;
background: transparent;
color: var(--muted);
font-weight: 500;
}
.nav-btn:hover, .folder-btn:hover { background: #f1f5f9; color: var(--text); }
.nav-btn.active { background: #e2e8f0; color: var(--text); font-weight: 600; }
.folder-btn { margin-bottom: 4px; font-size: 14px; min-height: 36px; padding: 0 12px; }
.folder-btn.active { background: #eff6ff; color: var(--primary2); font-weight: 600; }
.content { padding: 24px; min-width: 0; overflow: auto; background: #ffffff; }
.panel { .panel {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 16px;
background: #fff; background: #fff;
padding: 12px; padding: 24px;
margin-bottom: 12px; margin-bottom: 24px;
box-shadow: var(--shadow-sm);
} }
.panel h3 { margin: 0 0 10px 0; font-size: 18px; } .panel h3 { margin: 0 0 16px 0; font-size: 20px; font-weight: 600; letter-spacing: -0.01em; }
.vault-grid { display: grid; grid-template-columns: 1.1fr 1fr; gap: 12px; } .vault-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; height: calc(100vh - 220px); }
.list { .list {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: 12px;
max-height: calc(100vh - 280px);
overflow: auto; overflow: auto;
background: #fff; background: #fff;
box-shadow: var(--shadow-sm);
} }
.item { .item {
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
padding: 9px 10px; padding: 12px 16px;
display: grid; display: grid;
grid-template-columns: 26px 1fr; grid-template-columns: 24px 1fr;
gap: 8px; gap: 12px;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
transition: background 0.2s ease;
} }
.item:hover { background: #f8fafc; }
.item:last-child { border-bottom: none; } .item:last-child { border-bottom: none; }
.item.active { background: #fff2ea; } .item.active { background: #eff6ff; }
.item input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; accent-color: var(--primary2); }
.kv { .kv {
margin-bottom: 7px; margin-bottom: 12px;
font-size: 13px; font-size: 14px;
line-height: 1.55; line-height: 1.6;
word-break: break-word; word-break: break-word;
display: flex;
flex-direction: column;
} }
.kv b { color: var(--muted); margin-right: 6px; } .kv b { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
.table { .table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: separate;
border-spacing: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: 12px;
overflow: hidden; overflow: hidden;
font-size: 13px; font-size: 14px;
background: #fff; background: #fff;
box-shadow: var(--shadow-sm);
} }
.table th { background: #f8fafc; font-weight: 600; color: var(--muted); text-transform: uppercase; font-size: 12px; letter-spacing: 0.05em; }
.table th, .table td { .table th, .table td {
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
padding: 8px; padding: 12px 16px;
text-align: left; text-align: left;
vertical-align: middle; vertical-align: middle;
} }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.qr-row { display: grid; grid-template-columns: 190px 1fr; gap: 12px; align-items: start; } .badge {
.qr-box { border: 1px solid var(--line); border-radius: 10px; padding: 8px; background: #fff; } display: inline-flex;
.qr-box img { width: 170px; height: 170px; display: block; object-fit: contain; background: #fff; } align-items: center;
.help-box { border: 1px solid var(--line); border-radius: 10px; background: #fff; padding: 12px; margin-bottom: 10px; } padding: 2px 8px;
.help-box h4 { margin: 0 0 8px 0; font-size: 15px; } border-radius: 9999px;
.help-box ul { margin: 0; padding-left: 18px; line-height: 1.65; font-size: 13px; color: #3f352c; } font-size: 12px;
font-weight: 600;
background: #f1f5f9;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge.success { background: #dcfce7; color: #166534; }
.badge.danger { background: #fee2e2; color: #991b1b; }
.qr-row { display: grid; grid-template-columns: 200px 1fr; gap: 24px; align-items: start; }
.qr-box { border: 1px solid var(--line); border-radius: 16px; padding: 16px; background: #fff; box-shadow: var(--shadow-sm); }
.qr-box img { width: 100%; height: auto; display: block; object-fit: contain; border-radius: 8px; }
@media (max-width: 1080px) { @media (max-width: 1080px) {
.auth { grid-template-columns: 1fr; } .auth { grid-template-columns: 1fr; min-height: auto; }
.auth-left { border-right: none; border-bottom: 1px solid var(--line); } .auth-left { border-right: none; border-bottom: 1px solid var(--line); padding: 32px; }
.app-layout { grid-template-columns: 1fr; } .auth-right { padding: 32px; }
.sidebar, .folderbar { border-right: none; border-bottom: 1px solid var(--line); } .app-layout { grid-template-columns: 1fr; height: auto; min-height: calc(100vh - 40px); }
.vault-grid { grid-template-columns: 1fr; } .sidebar, .folderbar { border-right: none; border-bottom: 1px solid var(--line); padding: 16px; }
.vault-grid { grid-template-columns: 1fr; height: auto; }
.list { max-height: 400px; }
.row { grid-template-columns: 1fr; } .row { grid-template-columns: 1fr; }
.qr-row { grid-template-columns: 1fr; } .qr-row { grid-template-columns: 1fr; }
.totp-mask { border-radius: 0; }
} }
</style> </style>
</head> </head>
@@ -403,12 +488,12 @@ function renderWebClientHTML(): string {
+ ' <form id="loginForm">' + ' <form id="loginForm">'
+ ' <div class="field"><label>Email</label><input type="email" name="email" value="'+esc(state.loginEmail)+'" required /></div>' + ' <div class="field"><label>Email</label><input type="email" name="email" value="'+esc(state.loginEmail)+'" required /></div>'
+ ' <div class="field"><label>Master Password</label><input type="password" name="password" value="'+esc(state.loginPassword)+'" required /></div>' + ' <div class="field"><label>Master Password</label><input type="password" name="password" value="'+esc(state.loginPassword)+'" required /></div>'
+ ' <div class="actions"><button class="btn primary" type="submit">Login</button><button class="btn" type="button" data-action="goto-register">Register</button></div>' + ' <div class="actions"><button class="btn primary" type="submit">Login</button><button class="btn secondary" type="button" data-action="goto-register">Register</button></div>'
+ ' </form>' + ' </form>'
+ (state.pendingLogin ? '' + (state.pendingLogin ? ''
+ '<div class="totp-mask"><div class="totp-box"><h3>Two-step verification</h3><div class="tiny">Password is already verified.</div>' + '<div class="totp-mask"><div class="totp-box"><h3>Two-step verification</h3><div class="tiny">Password is already verified.</div>'
+ (state.loginTotpError?'<div class="msg err" style="margin-top:8px;">'+esc(state.loginTotpError)+'</div>':'') + (state.loginTotpError?'<div class="msg err" style="margin-top:8px;">'+esc(state.loginTotpError)+'</div>':'')
+ '<form id="loginTotpForm"><div class="field"><label>TOTP Code</label><input name="totpToken" maxlength="6" value="'+esc(state.loginTotpToken)+'" required /></div><div class="actions"><button class="btn primary" type="submit">Verify</button><button class="btn" type="button" data-action="totp-cancel">Cancel</button></div></form>' + '<form id="loginTotpForm"><div class="field"><label>TOTP Code</label><input name="totpToken" maxlength="6" value="'+esc(state.loginTotpToken)+'" required /></div><div class="actions"><button class="btn primary" type="submit">Verify</button><button class="btn secondary" type="button" data-action="totp-cancel">Cancel</button></div></form>'
+ '</div></div>' + '</div></div>'
: '') : '')
+ ' </main>' + ' </main>'
@@ -426,7 +511,7 @@ function renderWebClientHTML(): string {
+ ' <div class="field"><label>Master Password</label><input type="password" name="password" value="'+esc(state.registerPassword)+'" minlength="12" required /></div>' + ' <div class="field"><label>Master Password</label><input type="password" name="password" value="'+esc(state.registerPassword)+'" minlength="12" required /></div>'
+ ' <div class="field"><label>Confirm Password</label><input type="password" name="password2" value="'+esc(state.registerPassword2)+'" minlength="12" required /></div>' + ' <div class="field"><label>Confirm Password</label><input type="password" name="password2" value="'+esc(state.registerPassword2)+'" minlength="12" required /></div>'
+ ' <div class="field"><label>Invite Code</label><input name="inviteCode" value="'+esc(state.inviteCode)+'" /></div>' + ' <div class="field"><label>Invite Code</label><input name="inviteCode" value="'+esc(state.inviteCode)+'" /></div>'
+ ' <div class="actions"><button class="btn primary" type="submit">Create Account</button><button class="btn" type="button" data-action="goto-login">Back to Login</button></div>' + ' <div class="actions"><button class="btn primary" type="submit">Create Account</button><button class="btn secondary" type="button" data-action="goto-login">Back to Login</button></div>'
+ ' </form>' + ' </form>'
+ ' </main>' + ' </main>'
+ '</div></div>'; + '</div></div>';
@@ -438,12 +523,12 @@ function renderWebClientHTML(): string {
for(var i=0;i<list.length;i++){ for(var i=0;i<list.length;i++){
var c=list[i]; var c=list[i];
var nameText=(c.decName||c.name||c.id); var nameText=(c.decName||c.name||c.id);
rows += '<div class="item '+(c.id===state.selectedCipherId?'active':'')+'" data-action="pick-cipher" data-id="'+esc(c.id)+'"><input type="checkbox" data-action="toggle-select" data-id="'+esc(c.id)+'"'+(state.selectedMap[c.id]?' checked':'')+' /><div><div style="font-weight:700;font-size:14px;">'+esc(nameText)+'</div><div class="tiny">'+esc(c.id)+'</div></div></div>'; rows += '<div class="item '+(c.id===state.selectedCipherId?'active':'')+'" data-action="pick-cipher" data-id="'+esc(c.id)+'"><input type="checkbox" data-action="toggle-select" data-id="'+esc(c.id)+'"'+(state.selectedMap[c.id]?' checked':'')+' /><div><div style="font-weight:600;font-size:14px;color:var(--text-primary);">'+esc(nameText)+'</div><div class="tiny" style="color:var(--text-secondary);">'+esc(c.id)+'</div></div></div>';
} }
if(!rows) rows='<div class="item"><div></div><div class="tiny">No items in this folder.</div></div>'; if(!rows) rows='<div class="item" style="justify-content:center; color:var(--text-secondary);">No items in this folder.</div>';
var c0=selectedCipher(); var c0=selectedCipher();
var detail='<div class="tiny">Select an item to view details.</div>'; var detail='<div class="tiny" style="text-align:center; padding:40px; color:var(--text-secondary);">Select an item to view details.</div>';
if(c0){ if(c0){
var login = c0.login||{}; var login = c0.login||{};
var fields=Array.isArray(c0.fields)?c0.fields:[]; var fields=Array.isArray(c0.fields)?c0.fields:[];
@@ -463,10 +548,11 @@ function renderWebClientHTML(): string {
return '' return ''
+ renderMsg() + renderMsg()
+ '<div class="panel"><h3>Vault</h3>' + '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:16px;">'
+ '<div class="actions"><button class="btn" data-action="vault-refresh">Refresh</button><button class="btn" data-action="bulk-move">Move Selected</button><button class="btn danger" data-action="bulk-delete">Delete Selected ('+selectedCount()+')</button><button class="btn" data-action="select-all">Select all</button><button class="btn" data-action="select-none">Clear</button></div>' + '<h2 style="margin:0; font-size:24px; font-weight:700; letter-spacing:-0.02em;">Vault</h2>'
+ '<div class="vault-grid" style="margin-top:10px;"><div class="list">'+rows+'</div><div class="panel" style="margin:0;">'+detail+'</div></div>' + '<div class="actions" style="margin:0;"><button class="btn secondary" data-action="vault-refresh">Refresh</button><button class="btn secondary" data-action="bulk-move">Move</button><button class="btn danger" data-action="bulk-delete">Delete ('+selectedCount()+')</button><button class="btn secondary" data-action="select-all">All</button><button class="btn secondary" data-action="select-none">Clear</button></div>'
+ '</div>'; + '</div>'
+ '<div class="vault-grid"><div class="list">'+rows+'</div><div class="panel" style="margin:0; overflow:auto;">'+detail+'</div></div>';
} }
function renderSettingsTab(){ function renderSettingsTab(){
@@ -475,52 +561,58 @@ function renderWebClientHTML(): string {
var qr='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(buildTotpUri(secret)); var qr='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(buildTotpUri(secret));
return '' return ''
+ renderMsg() + renderMsg()
+ '<h2 style="margin:0 0 24px 0; font-size:24px; font-weight:700; letter-spacing:-0.02em;">Settings</h2>'
+ '<div class="panel"><h3>Profile</h3><form id="profileForm"><div class="row"><div class="field"><label>Name</label><input name="name" value="'+esc(p.name||'')+'" /></div><div class="field"><label>Email</label><input type="email" name="email" value="'+esc(p.email||'')+'" required /></div></div><div class="actions"><button class="btn primary" type="submit">Save Profile</button></div></form></div>' + '<div class="panel"><h3>Profile</h3><form id="profileForm"><div class="row"><div class="field"><label>Name</label><input name="name" value="'+esc(p.name||'')+'" /></div><div class="field"><label>Email</label><input type="email" name="email" value="'+esc(p.email||'')+'" required /></div></div><div class="actions"><button class="btn primary" type="submit">Save Profile</button></div></form></div>'
+ '<div class="panel"><h3>Master Password</h3><form id="passwordForm"><div class="field"><label>Current Master Password</label><input type="password" name="currentPassword" required /></div><div class="row"><div class="field"><label>New Master Password</label><input type="password" name="newPassword" minlength="12" required /></div><div class="field"><label>Confirm New Password</label><input type="password" name="newPassword2" minlength="12" required /></div></div><div class="actions"><button class="btn danger" type="submit">Change Master Password</button></div><div class="tiny">After success, current sessions are revoked and you must log in again.</div></form></div>' + '<div class="panel"><h3>Master Password</h3><form id="passwordForm"><div class="field"><label>Current Master Password</label><input type="password" name="currentPassword" required /></div><div class="row"><div class="field"><label>New Master Password</label><input type="password" name="newPassword" minlength="12" required /></div><div class="field"><label>Confirm New Password</label><input type="password" name="newPassword2" minlength="12" required /></div></div><div class="actions"><button class="btn danger" type="submit">Change Master Password</button></div><div class="tiny">After success, current sessions are revoked and you must log in again.</div></form></div>'
+ '<div class="panel"><h3>TOTP Setup</h3><div class="qr-row"><div class="qr-box"><img src="'+esc(qr)+'" alt="TOTP QR" /></div><div><form id="totpEnableForm"><div class="field"><label>Secret (Base32)</label><input name="secret" value="'+esc(secret)+'" /></div><div class="field"><label>Verification Code</label><input name="token" maxlength="6" value="'+esc(state.totpSetupToken)+'" /></div><div class="actions"><button class="btn secondary" type="submit">Enable TOTP</button><button class="btn" type="button" data-action="totp-secret-refresh">Regenerate</button><button class="btn" type="button" data-action="totp-secret-copy">Copy Secret</button></div></form></div></div><div class="actions"><button class="btn danger" type="button" data-action="totp-disable">Disable TOTP</button></div><div class="tiny">Disable action prompts for master password.</div></div>'; + '<div class="panel"><h3>TOTP Setup</h3><div class="qr-row"><div class="qr-box"><img src="'+esc(qr)+'" alt="TOTP QR" /></div><div><form id="totpEnableForm"><div class="field"><label>Secret (Base32)</label><input name="secret" value="'+esc(secret)+'" /></div><div class="field"><label>Verification Code</label><input name="token" maxlength="6" value="'+esc(state.totpSetupToken)+'" /></div><div class="actions"><button class="btn primary" type="submit">Enable TOTP</button><button class="btn secondary" type="button" data-action="totp-secret-refresh">Regenerate</button><button class="btn secondary" type="button" data-action="totp-secret-copy">Copy Secret</button></div></form></div></div><div class="actions"><button class="btn danger" type="button" data-action="totp-disable">Disable TOTP</button></div><div class="tiny">Disable action prompts for master password.</div></div>';
} }
function renderTotpDisableModal(){ function renderTotpDisableModal(){
if(!state.totpDisableOpen) return ''; if(!state.totpDisableOpen) return '';
return '' return ''
+ '<div class="totp-mask"><div class="totp-box"><h3>Disable TOTP</h3><div class="tiny">Enter master password to disable two-step verification.</div>' + '<div class="totp-mask"><div class="totp-box"><h3 style="margin:0 0 8px 0; font-size:20px;">Disable TOTP</h3><div class="tiny" style="margin-bottom:16px;">Enter master password to disable two-step verification.</div>'
+ (state.totpDisableError?'<div class="msg err" style="margin-top:8px;">'+esc(state.totpDisableError)+'</div>':'') + (state.totpDisableError?'<div class="msg err" style="margin-bottom:16px;">'+esc(state.totpDisableError)+'</div>':'')
+ '<form id="totpDisableForm"><div class="field"><label>Master Password</label><input type="password" name="masterPassword" value="'+esc(state.totpDisablePassword)+'" required /></div><div class="actions"><button class="btn danger" type="submit">Disable</button><button class="btn" type="button" data-action="totp-disable-cancel">Cancel</button></div></form>' + '<form id="totpDisableForm"><div class="field"><label>Master Password</label><input type="password" name="masterPassword" value="'+esc(state.totpDisablePassword)+'" required /></div><div class="actions"><button class="btn danger" type="submit">Disable</button><button class="btn secondary" type="button" data-action="totp-disable-cancel">Cancel</button></div></form>'
+ '</div></div>'; + '</div></div>';
} }
function renderHelpTab(){ function renderHelpTab(){
return '' return ''
+ '<div class="help-box"><h4>Upstream Sync</h4><ul><li>Track upstream with a fork and scheduled sync workflow (recommended).</li><li>Before merge: compare API routes, migration files, and auth logic changes.</li><li>After merge: run local dev migration tests, then deploy Worker after validation.</li></ul></div>' + '<h2 style="margin:0 0 24px 0; font-size:24px; font-weight:700; letter-spacing:-0.02em;">Help & Support</h2>'
+ '<div class="help-box"><h4>Common Errors</h4><ul><li>401 Unauthorized: token expired or revoked, login again.</li><li>403 Account disabled: admin must unban user in User Management.</li><li>403 Invite invalid: invite expired/used/revoked, create a new invite.</li><li>429 Too many requests: wait retry seconds and avoid burst writes.</li></ul></div>' + '<div class="panel"><h3>Upstream Sync</h3><ul style="margin:0; padding-left:20px; color:var(--text-secondary); line-height:1.6;"><li>Track upstream with a fork and scheduled sync workflow (recommended).</li><li>Before merge: compare API routes, migration files, and auth logic changes.</li><li>After merge: run local dev migration tests, then deploy Worker after validation.</li></ul></div>'
+ '<div class="help-box"><h4>Troubleshooting</h4><ul><li>Login OK but encrypted values shown: verify profile key and KDF settings are consistent.</li><li>TOTP fails repeatedly: sync device time and re-scan QR using latest secret.</li><li>Password change failed: ensure current password is correct and new password has at least 12 chars.</li><li>Sync conflicts: refresh vault and retry one operation at a time.</li></ul></div>'; + '<div class="panel"><h3>Common Errors</h3><ul style="margin:0; padding-left:20px; color:var(--text-secondary); line-height:1.6;"><li>401 Unauthorized: token expired or revoked, login again.</li><li>403 Account disabled: admin must unban user in User Management.</li><li>403 Invite invalid: invite expired/used/revoked, create a new invite.</li><li>429 Too many requests: wait retry seconds and avoid burst writes.</li></ul></div>'
+ '<div class="panel"><h3>Troubleshooting</h3><ul style="margin:0; padding-left:20px; color:var(--text-secondary); line-height:1.6;"><li>Login OK but encrypted values shown: verify profile key and KDF settings are consistent.</li><li>TOTP fails repeatedly: sync device time and re-scan QR using latest secret.</li><li>Password change failed: ensure current password is correct and new password has at least 12 chars.</li><li>Sync conflicts: refresh vault and retry one operation at a time.</li></ul></div>';
} }
function renderAdminTab(){ function renderAdminTab(){
var usersRows=''; var usersRows='';
for(var i=0;i<state.users.length;i++){ for(var i=0;i<state.users.length;i++){
var u=state.users[i]; var canAct=state.profile&&u.id!==state.profile.id; var u=state.users[i]; var canAct=state.profile&&u.id!==state.profile.id;
usersRows += '<tr><td>'+esc(u.email)+'</td><td>'+esc(u.name||'')+'</td><td>'+esc(u.role)+'</td><td>'+esc(u.status)+'</td><td>' usersRows += '<tr><td>'+esc(u.email)+'</td><td>'+esc(u.name||'')+'</td><td><span class="badge">'+esc(u.role)+'</span></td><td><span class="badge '+(u.status==='active'?'success':'danger')+'">'+esc(u.status)+'</span></td><td>'
+ (canAct?'<button class="btn" data-action="user-toggle" data-id="'+esc(u.id)+'" data-status="'+esc(u.status)+'">'+(u.status==='active'?'Ban':'Unban')+'</button>':'') + (canAct?'<button class="btn secondary" data-action="user-toggle" data-id="'+esc(u.id)+'" data-status="'+esc(u.status)+'">'+(u.status==='active'?'Ban':'Unban')+'</button>':'')
+ (canAct?' <button class="btn danger" data-action="user-delete" data-id="'+esc(u.id)+'">Delete</button>':'') + (canAct?' <button class="btn danger" data-action="user-delete" data-id="'+esc(u.id)+'">Delete</button>':'')
+ '</td></tr>'; + '</td></tr>';
} }
if(!usersRows) usersRows='<tr><td colspan="5">No users.</td></tr>'; if(!usersRows) usersRows='<tr><td colspan="5" style="text-align:center; color:var(--text-secondary); padding:24px;">No users found.</td></tr>';
var inviteRows=''; var inviteRows='';
for(var j=0;j<state.invites.length;j++){ for(var j=0;j<state.invites.length;j++){
var inv=state.invites[j]; var inv=state.invites[j];
inviteRows += '<tr><td>'+esc(inv.code)+'</td><td>'+esc(inv.status)+'</td><td>'+esc(inv.expiresAt)+'</td><td>' inviteRows += '<tr><td><code style="background:var(--bg-secondary); padding:4px 8px; border-radius:6px; font-size:13px;">'+esc(inv.code)+'</code></td><td><span class="badge '+(inv.status==='active'?'success':'danger')+'">'+esc(inv.status)+'</span></td><td>'+esc(inv.expiresAt)+'</td><td>'
+ '<button class="btn" data-action="invite-copy" data-link="'+esc(inv.inviteLink||'')+'">Copy link</button>' + '<button class="btn secondary" data-action="invite-copy" data-link="'+esc(inv.inviteLink||'')+'">Copy link</button>'
+ (inv.status==='active'?' <button class="btn danger" data-action="invite-revoke" data-code="'+esc(inv.code)+'">Revoke</button>':'') + (inv.status==='active'?' <button class="btn danger" data-action="invite-revoke" data-code="'+esc(inv.code)+'">Revoke</button>':'')
+ '</td></tr>'; + '</td></tr>';
} }
if(!inviteRows) inviteRows='<tr><td colspan="4">No invites.</td></tr>'; if(!inviteRows) inviteRows='<tr><td colspan="4" style="text-align:center; color:var(--text-secondary); padding:24px;">No invites found.</td></tr>';
return '' return ''
+ renderMsg() + renderMsg()
+ '<div class="panel"><h3>Create Invite</h3><form id="inviteForm"><div class="field"><label>Expires in hours</label><input name="hours" type="number" min="1" max="720" value="168" /></div><div class="actions"><button class="btn primary" type="submit">Create Invite</button><button class="btn" type="button" data-action="admin-refresh">Refresh</button></div></form></div>' + '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;">'
+ '<div class="panel"><h3>Users</h3><table class="table"><thead><tr><th>Email</th><th>Name</th><th>Role</th><th>Status</th><th>Action</th></tr></thead><tbody>'+usersRows+'</tbody></table></div>' + '<h2 style="margin:0; font-size:24px; font-weight:700; letter-spacing:-0.02em;">User Management</h2>'
+ '<div class="panel"><h3>Invites</h3><table class="table"><thead><tr><th>Code</th><th>Status</th><th>Expires At</th><th>Action</th></tr></thead><tbody>'+inviteRows+'</tbody></table></div>'; + '<button class="btn secondary" data-action="admin-refresh">Refresh Data</button>'
+ '</div>'
+ '<div class="panel"><h3>Create Invite</h3><form id="inviteForm"><div class="field"><label>Expires in hours</label><input name="hours" type="number" min="1" max="720" value="168" /></div><div class="actions"><button class="btn primary" type="submit">Create Invite</button></div></form></div>'
+ '<div class="panel"><h3>Users</h3><div style="overflow-x:auto;"><table class="table"><thead><tr><th>Email</th><th>Name</th><th>Role</th><th>Status</th><th>Action</th></tr></thead><tbody>'+usersRows+'</tbody></table></div></div>'
+ '<div class="panel"><h3>Invites</h3><div style="overflow-x:auto;"><table class="table"><thead><tr><th>Code</th><th>Status</th><th>Expires At</th><th>Action</th></tr></thead><tbody>'+inviteRows+'</tbody></table></div></div>';
} }
function renderApp(){ function renderApp(){
@@ -534,18 +626,20 @@ function renderWebClientHTML(): string {
return '' return ''
+ '<div class="shell"><div class="app-layout '+layoutClass+'">' + '<div class="shell"><div class="app-layout '+layoutClass+'">'
+ ' <aside class="sidebar"><div class="brand">NW</div><div class="mail">'+esc(state.profile&&state.profile.email?state.profile.email:'')+'</div>' + ' <aside class="sidebar"><div class="brand">NW</div><div class="mail">'+esc(state.profile&&state.profile.email?state.profile.email:'')+'</div>'
+ ' <button class="btn nav-btn '+(state.tab==='vault'?'active':'')+'" data-action="tab" data-tab="vault">Vault</button>' + ' <div style="flex:1; display:flex; flex-direction:column; gap:4px;">'
+ ' <button class="btn nav-btn '+(state.tab==='settings'?'active':'')+'" data-action="tab" data-tab="settings">Settings</button>' + ' <button class="btn nav-btn '+(state.tab==='vault'?'active':'')+'" data-action="tab" data-tab="vault">Vault</button>'
+ ' <button class="btn nav-btn '+(state.tab==='settings'?'active':'')+'" data-action="tab" data-tab="settings">Settings</button>'
+ (isAdmin?'<button class="btn nav-btn '+(state.tab==='admin'?'active':'')+'" data-action="tab" data-tab="admin">User Management</button>':'') + (isAdmin?'<button class="btn nav-btn '+(state.tab==='admin'?'active':'')+'" data-action="tab" data-tab="admin">User Management</button>':'')
+ ' <button class="btn nav-btn '+(state.tab==='help'?'active':'')+'" data-action="tab" data-tab="help">Help</button>' + ' <button class="btn nav-btn '+(state.tab==='help'?'active':'')+'" data-action="tab" data-tab="help">Help</button>'
+ ' <button class="btn nav-btn" data-action="logout">Logout</button></aside>' + ' </div>'
+ (showFolders?(' <aside class="folderbar"><h3 style="margin:0 0 10px 0;">Folders</h3>'+folders+'</aside>'):'') + ' <button class="btn nav-btn" style="margin-top:auto; color:var(--danger);" data-action="logout">Logout</button></aside>'
+ (showFolders?(' <aside class="folderbar"><h3 style="margin:0 0 16px 0; font-size:14px; text-transform:uppercase; letter-spacing:0.05em; color:var(--text-secondary);">Folders</h3><div style="display:flex; flex-direction:column; gap:4px;">'+folders+'</div></aside>'):'')
+ ' <main class="content">'+content+'</main>' + ' <main class="content">'+content+'</main>'
+ '</div>'+renderTotpDisableModal()+'</div>'; + '</div>'+renderTotpDisableModal()+'</div>';
} }
function render(){ function render(){
if(state.phase==='loading'){ app.innerHTML='<div class="shell"><div class="panel"><h3>Loading...</h3></div></div>'; return; } if(state.phase==='loading'){ app.innerHTML='<div class="shell" style="align-items:center; justify-content:center;"><div style="display:flex; flex-direction:column; align-items:center; gap:16px;"><div class="brand" style="margin:0;">NW</div><div style="color:var(--text-secondary); font-weight:500;">Loading NodeWarden...</div></div></div>'; return; }
if(state.phase==='register'){ app.innerHTML=renderRegisterScreen(); return; } if(state.phase==='register'){ app.innerHTML=renderRegisterScreen(); return; }
if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; } if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; }
app.innerHTML=renderApp(); app.innerHTML=renderApp();