feat: add toast notifications and dialog components for improved user interaction

This commit is contained in:
shuaiplus
2026-02-28 00:02:47 +08:00
committed by Shuai
parent 59566f88e3
commit 3494471cad
3 changed files with 424 additions and 54 deletions
+1 -2
View File
@@ -21,7 +21,7 @@ export const I18N = {
allItems: 'All Items',
noFolder: 'No Folder',
searchVault: 'Search vault',
filter: 'Filter',
filter: 'Search',
typeAll: 'All items',
typeLogin: 'Logins',
typeCard: 'Cards',
@@ -214,4 +214,3 @@ export const I18N = {
langSwitch: 'English',
},
};
+196 -51
View File
@@ -15,6 +15,8 @@ export function startNodewardenApp(runtimeConfig) {
registerEmail: '',
registerPassword: '',
registerPassword2: '',
registerShowPassword: false,
registerShowPassword2: false,
session: null,
profile: null,
tab: 'vault',
@@ -41,6 +43,7 @@ export function startNodewardenApp(runtimeConfig) {
invites: [],
loginEmail: '',
loginPassword: '',
loginShowPassword: false,
loginTotpToken: '',
loginTotpError: '',
pendingLogin: null,
@@ -51,10 +54,14 @@ export function startNodewardenApp(runtimeConfig) {
totpDisableError: '',
unlockPassword: '',
unlockError: '',
unlockShowPassword: false,
lockTimeoutMinutes: 15,
lockLastActiveTs: Date.now(),
lockCheckTimer: 0,
lockChannel: null
lockChannel: null,
toasts: [],
toastSeq: 0,
dialog: null
};
var NO_FOLDER_FILTER = '__none__';
var i18n = I18N;
@@ -66,9 +73,84 @@ export function startNodewardenApp(runtimeConfig) {
}
function sessionKey() { return 'nodewarden.web.session.v2'; }
function lockSettingsKey() { return 'nodewarden.web.lock.v1'; }
function setMsg(t, ty) { state.msg = t || ''; state.msgType = ty || 'ok'; render(); }
function clearMsg() { state.msg = ''; }
function renderMsg() { return state.msg ? '<div class="alert alert-' + (state.msgType === 'err' ? 'danger' : 'success') + '">' + esc(state.msg) + '</div>' : ''; }
function dismissToast(id) {
var next = [];
for (var i = 0; i < state.toasts.length; i++) if (state.toasts[i].id !== id) next.push(state.toasts[i]);
if (next.length === state.toasts.length) return;
state.toasts = next;
render();
}
function setMsg(t, ty) {
var text = String(t || '').trim();
if (!text) return;
var id = 'toast-' + (++state.toastSeq);
var level = ty === 'err' ? 'error' : (ty === 'warn' ? 'warning' : 'success');
state.toasts.push({ id: id, text: text, level: level });
if (state.toasts.length > 4) state.toasts = state.toasts.slice(state.toasts.length - 4);
render();
setTimeout(function () { dismissToast(id); }, 4500);
}
function clearMsg() {}
function renderMsg() { return ''; }
function renderToasts() {
if (!state.toasts || state.toasts.length === 0) return '';
var items = '';
for (var i = 0; i < state.toasts.length; i++) {
var x = state.toasts[i];
items += '<li class="toast-item ' + esc(x.level) + '">'
+ '<div class="toast-text">' + esc(x.text) + '</div>'
+ '<button class="toast-close" type="button" data-action="toast-close" data-id="' + esc(x.id) + '">✕</button>'
+ '<div class="toast-bar"></div>'
+ '</li>';
}
return '<ul class="toast-stack">' + items + '</ul>';
}
function askConfirm(opts) {
return new Promise(function (resolve) {
state.dialog = {
type: 'confirm',
title: String(opts && opts.title || 'Confirm'),
message: String(opts && opts.message || ''),
okText: String(opts && opts.okText || 'Yes'),
cancelText: String(opts && opts.cancelText || 'No'),
danger: !!(opts && opts.danger),
resolve: resolve
};
render();
});
}
function askMoveFolder() {
return new Promise(function (resolve) {
state.dialog = {
type: 'move',
title: 'Move selected items',
message: 'Choose destination folder.',
selectedFolderId: '__none__',
resolve: resolve
};
render();
});
}
function closeDialog(result) {
var d = state.dialog;
state.dialog = null;
render();
if (d && typeof d.resolve === 'function') d.resolve(result);
}
function renderDialog() {
var d = state.dialog;
if (!d) return '';
if (d.type === 'move') {
var options = '<option value="__none__">' + t('noFolder') + '</option>';
for (var i = 0; i < state.folders.length; i++) {
var f = state.folders[i];
var id = String(f.id || '');
options += '<option value="' + esc(id) + '"' + (d.selectedFolderId === id ? ' selected' : '') + '>' + esc(f.decName || f.name || id) + '</option>';
}
return '<div class="dialog-mask"><div class="dialog-card"><div class="dialog-icon">⚠</div><h3 class="dialog-title">' + esc(d.title) + '</h3><div class="dialog-msg">' + esc(d.message) + '</div><div class="form-group" style="margin: 12px 0 16px 0;"><select class="form-input" data-action="dialog-move-folder">' + options + '</select></div><button class="btn btn-primary dialog-btn" type="button" data-action="dialog-confirm">Move</button><button class="btn btn-secondary dialog-btn" type="button" data-action="dialog-cancel">Cancel</button></div></div>';
}
return '<div class="dialog-mask"><div class="dialog-card"><div class="dialog-icon">⚠</div><h3 class="dialog-title">' + esc(d.title) + '</h3><div class="dialog-msg">' + esc(d.message) + '</div><button class="btn ' + (d.danger ? 'btn-danger' : 'btn-primary') + ' dialog-btn" type="button" data-action="dialog-confirm">' + esc(d.okText) + '</button><button class="btn btn-secondary dialog-btn" type="button" data-action="dialog-cancel">' + esc(d.cancelText) + '</button></div></div>';
}
function saveSession() {
if (!state.session) { localStorage.removeItem(sessionKey()); return; }
var persisted = {
@@ -197,6 +279,7 @@ export function startNodewardenApp(runtimeConfig) {
state.loginTotpError = '';
state.unlockPassword = '';
state.unlockError = '';
state.unlockShowPassword = false;
state.phase = 'locked';
if (broadcast !== false && state.lockChannel) {
try { state.lockChannel.postMessage({ type: 'lock', at: Date.now() }); } catch (_) {}
@@ -227,6 +310,7 @@ export function startNodewardenApp(runtimeConfig) {
state.session.symMacKey = bytesToBase64(symKeyBytes.slice(32, 64));
state.unlockPassword = '';
state.unlockError = '';
state.unlockShowPassword = false;
await loadVault();
await loadAdminData();
state.phase = 'app';
@@ -241,7 +325,7 @@ export function startNodewardenApp(runtimeConfig) {
}
function logout(){
state.session=null; state.profile=null; state.ciphers=[]; state.folders=[]; state.users=[]; state.invites=[]; state.folderFilterId=''; state.selectedCipherId=''; state.selectedMap={}; state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; state.unlockPassword=''; state.unlockError=''; state.phase='login'; saveSession(); clearMsg(); render();
state.session=null; state.profile=null; state.ciphers=[]; state.folders=[]; state.users=[]; state.invites=[]; state.folderFilterId=''; state.selectedCipherId=''; state.selectedMap={}; state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; state.unlockPassword=''; state.unlockError=''; state.unlockShowPassword=false; state.phase='login'; saveSession(); clearMsg(); render();
}
async function authFetch(path, options){
@@ -600,7 +684,8 @@ export function startNodewardenApp(runtimeConfig) {
}
async function deleteSelectedCipher(){
var c=selectedCipher(); if(!c) return;
if(!window.confirm('Delete this item? This operation cannot be undone.')) return;
var ok = await askConfirm({ title: 'Delete item', message: 'Are you sure you want to delete this item?', okText: 'Yes', cancelText: 'No', danger: true });
if(!ok) return;
var r=await authFetch('/api/ciphers/'+encodeURIComponent(c.id),{method:'DELETE'});
var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Delete failed.', 'err');
closeDetailEdit();
@@ -838,20 +923,20 @@ export function startNodewardenApp(runtimeConfig) {
return ''
+ '<div class="auth-page">'
+ ' <div class="lang-switch" data-action="toggle-lang">'+t('langSwitch')+'</div>'
+ ' <div class="auth-card">'
+ ' <div class="auth-header">'
+ ' <div class="auth-logo"></div>'
+ ' <div class="auth-title">'+t('brand')+'</div>'
+ ' <div class="auth-subtitle">'+t('subtitle')+'</div>'
+ ' <div class="auth-card unlock-card">'
+ ' <div class="auth-header" style="margin-bottom:20px;">'
+ ' <div class="auth-title" style="margin-bottom:4px;">'+t('login')+'</div>'
+ ' <div class="auth-subtitle">'+t('brand')+'</div>'
+ ' </div>'
+ renderMsg()
+ ' <form id="loginForm">'
+ ' <div class="form-group"><label class="form-label">'+t('email')+'</label><input class="form-input" type="email" name="email" value="'+esc(state.loginEmail)+'" required autofocus /></div>'
+ ' <div class="form-group"><label class="form-label">'+t('masterPwd')+'</label><input class="form-input" type="password" name="password" value="'+esc(state.loginPassword)+'" required /></div>'
+ ' <button class="btn btn-primary" type="submit" style="width:100%; margin-top:16px;">'+t('loginBtn')+'</button>'
+ ' <div class="form-group unlock-pwd-wrap"><label class="form-label">'+t('masterPwd')+'</label><input class="form-input unlock-pwd-input" type="'+(state.loginShowPassword?'text':'password')+'" name="password" value="'+esc(state.loginPassword)+'" required /><button class="unlock-eye-btn" type="button" data-action="login-toggle-password" aria-label="Toggle password visibility">&#128065;</button></div>'
+ ' <button class="btn btn-primary unlock-main-btn" type="submit">'+t('loginBtn')+'</button>'
+ ' </form>'
+ ' <div class="auth-footer">'
+ ' <a href="#" data-action="goto-register">'+t('registerBtn')+'</a>'
+ ' <div class="unlock-or">or</div>'
+ ' <div style="display:flex; gap:8px;">'
+ ' <button class="btn btn-secondary unlock-secondary-btn" style="flex:1;" data-action="goto-register">'+t('registerBtn')+'</button>'
+ ' </div>'
+ (state.pendingLogin ? ''
+ '<div class="totp-mask"><div class="totp-box"><h3 style="margin-top:0;">'+t('totpVerify')+'</h3><div class="tiny" style="margin-bottom:16px;">'+t('totpVerifySub')+'</div>'
@@ -867,23 +952,23 @@ export function startNodewardenApp(runtimeConfig) {
return ''
+ '<div class="auth-page">'
+ ' <div class="lang-switch" data-action="toggle-lang">'+t('langSwitch')+'</div>'
+ ' <div class="auth-card">'
+ ' <div class="auth-header">'
+ ' <div class="auth-logo"></div>'
+ ' <div class="auth-title">'+t('register')+'</div>'
+ ' <div class="auth-card unlock-card">'
+ ' <div class="auth-header" style="margin-bottom:20px;">'
+ ' <div class="auth-title" style="margin-bottom:4px;">'+t('register')+'</div>'
+ ' <div class="auth-subtitle">'+t('brand')+'</div>'
+ ' </div>'
+ renderMsg()
+ ' <form id="registerForm">'
+ ' <div class="form-group"><label class="form-label">'+t('name')+'</label><input class="form-input" name="name" value="'+esc(state.registerName)+'" required autofocus /></div>'
+ ' <div class="form-group"><label class="form-label">'+t('email')+'</label><input class="form-input" type="email" name="email" value="'+esc(state.registerEmail)+'" required /></div>'
+ ' <div class="form-group"><label class="form-label">'+t('masterPwd')+'</label><input class="form-input" type="password" name="password" value="'+esc(state.registerPassword)+'" minlength="12" required /></div>'
+ ' <div class="form-group"><label class="form-label">'+t('confirmPwd')+'</label><input class="form-input" type="password" name="password2" value="'+esc(state.registerPassword2)+'" minlength="12" required /></div>'
+ ' <div class="form-group unlock-pwd-wrap"><label class="form-label">'+t('masterPwd')+'</label><input class="form-input unlock-pwd-input" type="'+(state.registerShowPassword?'text':'password')+'" name="password" value="'+esc(state.registerPassword)+'" minlength="12" required /><button class="unlock-eye-btn" type="button" data-action="register-toggle-password" aria-label="Toggle password visibility">&#128065;</button></div>'
+ ' <div class="form-group unlock-pwd-wrap"><label class="form-label">'+t('confirmPwd')+'</label><input class="form-input unlock-pwd-input" type="'+(state.registerShowPassword2?'text':'password')+'" name="password2" value="'+esc(state.registerPassword2)+'" minlength="12" required /><button class="unlock-eye-btn" type="button" data-action="register2-toggle-password" aria-label="Toggle password visibility">&#128065;</button></div>'
+ ' <div class="form-group"><label class="form-label">'+t('inviteCode')+'</label><input class="form-input" name="inviteCode" value="'+esc(state.inviteCode)+'" /></div>'
+ ' <button class="btn btn-primary" type="submit" style="width:100%; margin-top:16px;">'+t('registerBtn')+'</button>'
+ ' <button class="btn btn-primary unlock-main-btn" type="submit">'+t('registerBtn')+'</button>'
+ ' </form>'
+ ' <div class="auth-footer">'
+ ' <a href="#" data-action="goto-login">'+t('backToLogin')+'</a>'
+ ' <div class="unlock-or">or</div>'
+ ' <div style="display:flex; gap:8px;">'
+ ' <button class="btn btn-secondary unlock-secondary-btn" style="flex:1;" data-action="goto-login">'+t('backToLogin')+'</button>'
+ ' </div>'
+ ' </div>'
+ '</div>';
@@ -894,20 +979,20 @@ export function startNodewardenApp(runtimeConfig) {
return ''
+ '<div class="auth-page">'
+ ' <div class="lang-switch" data-action="toggle-lang">'+t('langSwitch')+'</div>'
+ ' <div class="auth-card">'
+ ' <div class="auth-header">'
+ ' <div class="auth-logo"></div>'
+ ' <div class="auth-title">Unlock Vault</div>'
+ ' <div class="auth-card unlock-card">'
+ ' <div class="auth-header" style="margin-bottom:20px;">'
+ ' <div class="auth-title" style="margin-bottom:4px;">Unlock Vault</div>'
+ ' <div class="auth-subtitle">'+esc(email)+'</div>'
+ ' </div>'
+ renderMsg()
+ (state.unlockError?('<div class="alert alert-danger">'+esc(state.unlockError)+'</div>'):'')
+ ' <form id="unlockForm">'
+ ' <div class="form-group"><label class="form-label">'+t('masterPwd')+'</label><input class="form-input" type="password" name="password" value="'+esc(state.unlockPassword)+'" required autofocus /></div>'
+ ' <button class="btn btn-primary" type="submit" style="width:100%; margin-top:16px;">Unlock</button>'
+ ' <div class="form-group unlock-pwd-wrap"><label class="form-label">'+t('masterPwd')+'</label><input class="form-input unlock-pwd-input" type="'+(state.unlockShowPassword?'text':'password')+'" name="password" value="'+esc(state.unlockPassword)+'" required autofocus /><button class="unlock-eye-btn" type="button" data-action="unlock-toggle-password" aria-label="Toggle password visibility">&#128065;</button></div>'
+ ' <button class="btn btn-primary unlock-main-btn" type="submit">Unlock</button>'
+ ' </form>'
+ ' <div style="display:flex; gap:8px; margin-top:16px;">'
+ ' <button class="btn btn-secondary" style="flex:1;" data-action="logout">Log Out</button>'
+ ' <div class="unlock-or">or</div>'
+ ' <div style="display:flex; gap:8px;">'
+ ' <button class="btn btn-secondary unlock-secondary-btn" style="flex:1;" data-action="logout">Log Out</button>'
+ ' </div>'
+ ' </div>'
+ '</div>';
@@ -991,9 +1076,9 @@ export function startNodewardenApp(runtimeConfig) {
+ '<div style="margin-top:10px;"><label><input type="checkbox" data-action="draft-change" data-field="reprompt" '+(de.reprompt?'checked':'')+' /> Master password reprompt</label></div>'
+ '</div>'
+ '<div class="card"><div class="card-title">Fields</div>'+efsHtml+'<button class="link-btn" data-action="draft-field-open">'+t('addField')+'</button></div>'
+ '<div class="detail-actions"><div><button class="btn btn-primary" data-action="detail-save">Confirm</button><button class="btn btn-secondary" data-action="detail-cancel">Cancel</button></div><button class="btn btn-danger" data-action="detail-delete">🗑</button></div>';
+ '<div class="detail-actions"><div><button class="btn btn-primary" data-action="detail-save">Confirm</button><button class="btn btn-secondary" data-action="detail-cancel">Cancel</button></div><button class="btn btn-danger btn-danger-icon" data-action="detail-delete" aria-label="Delete item">🗑</button></div>';
} else detail=renderReadOnlyTypeDetails(c0, folderLabel, created, updated)
+ '<div class="detail-actions"><div><button class="btn btn-secondary" data-action="detail-edit">Edit</button></div><button class="btn btn-danger" data-action="detail-delete">🗑</button></div>';
+ '<div class="detail-actions"><div><button class="btn btn-secondary" data-action="detail-edit">Edit</button></div><button class="btn btn-danger btn-danger-icon" data-action="detail-delete" aria-label="Delete item">🗑</button></div>';
}
return ''
@@ -1016,9 +1101,9 @@ export function startNodewardenApp(runtimeConfig) {
function renderTotpDisableModal(){
if(!state.totpDisableOpen) return '';
return ''
+ '<div class="totp-mask"><div class="totp-box"><h3 style="margin-top:0;">'+t('disableTotp')+'</h3><div class="tiny" style="margin-bottom:16px;">'+t('totpDisableSub')+'</div>'
+ (state.totpDisableError?'<div class="alert alert-danger" style="margin-bottom:16px;">'+esc(state.totpDisableError)+'</div>':'')
+ '<form id="totpDisableForm"><div class="form-group"><label class="form-label">'+t('masterPwd')+'</label><input class="form-input" type="password" name="masterPassword" value="'+esc(state.totpDisablePassword)+'" required autofocus /></div><div style="display:flex; gap:8px; margin-top:16px;"><button class="btn btn-danger" type="submit" style="flex:1;">'+t('disableTotp')+'</button><button class="btn btn-secondary" type="button" data-action="totp-disable-cancel" style="flex:1;">'+t('cancel')+'</button></div></form>'
+ '<div class="dialog-mask"><div class="dialog-card form-dialog"><div class="dialog-icon">⚠</div><h3 class="dialog-title">'+t('disableTotp')+'</h3><div class="dialog-msg">'+t('totpDisableSub')+'</div>'
+ (state.totpDisableError?'<div class="dialog-error">'+esc(state.totpDisableError)+'</div>':'')
+ '<form id="totpDisableForm"><div class="form-group"><label class="form-label">'+t('masterPwd')+'</label><input class="form-input" type="password" name="masterPassword" value="'+esc(state.totpDisablePassword)+'" required autofocus /></div><button class="btn btn-danger dialog-btn" type="submit">'+t('disableTotp')+'</button><button class="btn btn-secondary dialog-btn" type="button" data-action="totp-disable-cancel">'+t('cancel')+'</button></form>'
+ '</div></div>';
}
@@ -1096,7 +1181,7 @@ export function startNodewardenApp(runtimeConfig) {
+ '</div>'
+ '<div class="app-body">'
+ (showFolders?(' <aside class="sidebar">'
+ '<div class="sidebar-block"><div class="sidebar-title">'+t('filter')+'</div><input class="search-input" data-action="vault-search" placeholder="'+t('searchVault')+'" value="'+esc(state.vaultQuery)+'" /></div>'
+ '<div class="sidebar-block"><div class="sidebar-title">'+t('searchVault')+'</div><input class="search-input" data-action="vault-search" placeholder="'+t('searchVault')+'" value="'+esc(state.vaultQuery)+'" /></div>'
+ '<div class="sidebar-block"><div class="sidebar-title">'+t('allItems')+'</div>'+typeTree+'</div>'
+ '<div class="sidebar-block"><div class="sidebar-title">'+t('folders')+'</div>'+folders+'</div>'
+ '</aside>'):'')
@@ -1105,11 +1190,39 @@ export function startNodewardenApp(runtimeConfig) {
}
function render(){
if(state.phase==='loading'){ app.innerHTML='<div class="auth-page" style="align-items:center; justify-content:center;"><div style="display:flex; flex-direction:column; align-items:center; gap:16px;"><div class="auth-logo" style="margin:0;"></div><div style="color:var(--text-secondary); font-weight:500;">'+t('loading')+'</div></div></div>'; return; }
if(state.phase==='register'){ app.innerHTML=renderRegisterScreen(); return; }
if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; }
if(state.phase==='locked'){ app.innerHTML=renderLockedScreen(); return; }
app.innerHTML=renderApp();
var active = document.activeElement;
var keepSearchFocus = false;
var keepSearchSelStart = 0;
var keepSearchSelEnd = 0;
if (active instanceof HTMLInputElement && active.getAttribute('data-action') === 'vault-search') {
keepSearchFocus = true;
keepSearchSelStart = active.selectionStart == null ? 0 : active.selectionStart;
keepSearchSelEnd = active.selectionEnd == null ? keepSearchSelStart : active.selectionEnd;
}
if(state.phase==='loading'){ app.innerHTML='<div class="auth-page" style="align-items:center; justify-content:center;"><div style="display:flex; flex-direction:column; align-items:center; gap:16px;"><div class="auth-logo" style="margin:0;"></div><div style="color:var(--text-secondary); font-weight:500;">'+t('loading')+'</div></div></div>' + renderToasts() + renderDialog(); return; }
if(state.phase==='register'){ app.innerHTML=renderRegisterScreen() + renderToasts() + renderDialog(); return; }
if(state.phase==='login'){ app.innerHTML=renderLoginScreen() + renderToasts() + renderDialog(); return; }
if(state.phase==='locked'){ app.innerHTML=renderLockedScreen() + renderToasts() + renderDialog(); return; }
var prevContent = app.querySelector('.content');
var prevSidebar = app.querySelector('.sidebar');
var prevVaultList = app.querySelector('.vault-list');
var contentTop = prevContent ? prevContent.scrollTop : 0;
var sidebarTop = prevSidebar ? prevSidebar.scrollTop : 0;
var vaultListTop = prevVaultList ? prevVaultList.scrollTop : 0;
app.innerHTML=renderApp() + renderToasts() + renderDialog();
var nextContent = app.querySelector('.content');
var nextSidebar = app.querySelector('.sidebar');
var nextVaultList = app.querySelector('.vault-list');
if(nextContent) nextContent.scrollTop = contentTop;
if(nextSidebar) nextSidebar.scrollTop = sidebarTop;
if(nextVaultList) nextVaultList.scrollTop = vaultListTop;
if (keepSearchFocus) {
var nextSearch = app.querySelector('input[data-action="vault-search"]');
if (nextSearch instanceof HTMLInputElement) {
nextSearch.focus();
try { nextSearch.setSelectionRange(keepSearchSelStart, keepSearchSelEnd); } catch (_) {}
}
}
updateLiveTotpDisplay();
renderTotpSetupQr();
}
@@ -1245,12 +1358,12 @@ export function startNodewardenApp(runtimeConfig) {
}
}
async function onBulkDelete(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); if(!window.confirm('Delete selected '+ids.length+' items?')) return; for(var i=0;i<ids.length;i++) await authFetch('/api/ciphers/'+encodeURIComponent(ids[i]),{method:'DELETE'}); state.selectedMap={}; await loadVault(); render(); setMsg('Deleted selected items.', 'ok'); }
async function onBulkMove(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); var opts=['0) No folder']; for(var i=0;i<state.folders.length;i++){ var f=state.folders[i]; var label=(f.decName||f.name||f.id); opts.push(String(i+1)+') '+String(label)); } var pick=window.prompt('Move selected items to:\n'+opts.join('\n')+'\n\nInput number (empty to cancel):','0'); if(pick===null) return; pick=String(pick).trim(); if(!pick) return; var idx=Number(pick); if(!Number.isInteger(idx)||idx<0||idx>state.folders.length) return setMsg('Invalid folder selection.', 'err'); var folderId=idx===0?null:state.folders[idx-1].id; var r=await authFetch('/api/ciphers/move',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:ids,folderId:folderId})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Bulk move failed.', 'err'); await loadVault(); render(); setMsg('Moved selected items.', 'ok'); }
async function onBulkDelete(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); var ok=await askConfirm({title:'Delete items',message:'Are you sure you want to delete '+ids.length+' selected items?',okText:'Yes',cancelText:'No',danger:true}); if(!ok) return; for(var i=0;i<ids.length;i++) await authFetch('/api/ciphers/'+encodeURIComponent(ids[i]),{method:'DELETE'}); state.selectedMap={}; await loadVault(); render(); setMsg('Deleted selected items.', 'ok'); }
async function onBulkMove(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); var folderId=await askMoveFolder(); if(folderId===undefined) return; var r=await authFetch('/api/ciphers/move',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:ids,folderId:folderId})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Bulk move failed.', 'err'); await loadVault(); render(); setMsg('Moved selected items.', 'ok'); }
async function onCreateInvite(form){ var fd=new FormData(form); var h=Number(fd.get('hours')||168); var r=await authFetch('/api/admin/invites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({expiresInHours:h})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Create invite failed.', 'err'); await loadAdminData(); render(); setMsg('Invite created.', 'ok'); }
async function onToggleUserStatus(id,status){ var n=status==='active'?'banned':'active'; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id)+'/status',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:n})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Update user status failed.', 'err'); await loadAdminData(); render(); setMsg('User status updated.', 'ok'); }
async function onDeleteUser(id){ if(!window.confirm('Delete this user and all user data?')) return; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id),{method:'DELETE'}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Delete user failed.', 'err'); await loadAdminData(); render(); setMsg('User deleted.', 'ok'); }
async function onDeleteUser(id){ var ok=await askConfirm({title:'Delete user',message:'Delete this user and all user data?',okText:'Yes',cancelText:'No',danger:true}); if(!ok) return; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id),{method:'DELETE'}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Delete user failed.', 'err'); await loadAdminData(); render(); setMsg('User deleted.', 'ok'); }
async function onRevokeInvite(code){ var r=await authFetch('/api/admin/invites/'+encodeURIComponent(code),{method:'DELETE'}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Revoke invite failed.', 'err'); await loadAdminData(); render(); setMsg('Invite revoked.', 'ok'); }
app.addEventListener('submit', function(ev){
@@ -1267,12 +1380,36 @@ export function startNodewardenApp(runtimeConfig) {
if(form.id==='inviteForm') return void onCreateInvite(form);
});
app.addEventListener('click', function(ev){
app.addEventListener('click', async function(ev){
var n=ev.target; while(n&&n!==app&&!n.getAttribute('data-action')) n=n.parentElement; if(!n||n===app) return; var a=n.getAttribute('data-action'); if(!a) return;
ev.preventDefault();
if(a==='toast-close'){ dismissToast(n.getAttribute('data-id')||''); return; }
if(a==='dialog-cancel'){ closeDialog(false); return; }
if(a==='dialog-confirm'){
if(state.dialog&&state.dialog.type==='move'){
var sel=state.dialog.selectedFolderId;
closeDialog(sel==='__none__'?null:sel);
return;
}
closeDialog(true);
return;
}
if(a==='toggle-lang'){ state.lang = state.lang === 'zh' ? 'en' : 'zh'; render(); return; }
if(a==='goto-login'){ state.phase='login'; clearMsg(); render(); return; }
if(a==='goto-register'){ state.phase='register'; clearMsg(); render(); return; }
if(a==='logout'){ if(window.confirm('Log out now?')) logout(); return; }
if(a==='unlock-toggle-password' || a==='login-toggle-password' || a==='register-toggle-password' || a==='register2-toggle-password'){
var wrap = n.parentElement;
var input = wrap ? wrap.querySelector('input.unlock-pwd-input') : null;
if(!(input instanceof HTMLInputElement)) return;
var nextIsText = input.type !== 'text';
input.type = nextIsText ? 'text' : 'password';
if(a==='unlock-toggle-password') state.unlockShowPassword = nextIsText;
if(a==='login-toggle-password') state.loginShowPassword = nextIsText;
if(a==='register-toggle-password') state.registerShowPassword = nextIsText;
if(a==='register2-toggle-password') state.registerShowPassword2 = nextIsText;
return;
}
if(a==='logout'){ var okLogout=await askConfirm({title:'Log out',message:'Are you sure you want to log out?',okText:'Yes',cancelText:'No'}); if(okLogout) logout(); return; }
if(a==='lock'){ lockVault(true, true); return; }
if(a==='totp-cancel'){ state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; render(); return; }
if(a==='totp-disable-cancel'){ state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; render(); return; }
@@ -1309,7 +1446,14 @@ export function startNodewardenApp(runtimeConfig) {
if(a==='bulk-delete') return void onBulkDelete();
if(a==='bulk-move') return void onBulkMove();
if(a==='vault-refresh'){ loadVault().then(function(){ render(); setMsg('Vault refreshed.', 'ok'); }).catch(function(e){ setMsg('Refresh failed: '+(e&&e.message?e.message:String(e)), 'err'); }); return; }
if(a==='totp-secret-refresh'){ state.totpSetupSecret=randomBase32Secret(32); render(); return; }
if(a==='totp-secret-refresh'){
state.totpSetupSecret=randomBase32Secret(32);
var f=document.getElementById('totpEnableForm');
var s=f?f.querySelector('input[name=\"secret\"]'):null;
if(s instanceof HTMLInputElement) s.value=state.totpSetupSecret;
renderTotpSetupQr();
return;
}
if(a==='totp-secret-copy'){ navigator.clipboard.writeText(currentTotpSecret()).then(function(){ setMsg('TOTP secret copied.', 'ok'); }).catch(function(){ setMsg('Copy failed.', 'err'); }); return; }
if(a==='totp-disable'){ onDisableTotp(); return; }
if(a==='admin-refresh'){ loadAdminData().then(function(){ render(); setMsg('Admin data refreshed.', 'ok'); }).catch(function(e){ setMsg('Refresh failed: '+(e&&e.message?e.message:String(e)), 'err'); }); return; }
@@ -1327,7 +1471,7 @@ export function startNodewardenApp(runtimeConfig) {
state.vaultQuery=String(n.value||'');
if(ev.isComposing||state.vaultSearchComposing) return;
if(state.vaultSearchTimer) clearTimeout(state.vaultSearchTimer);
state.vaultSearchTimer=setTimeout(function(){ syncSelectedWithFilter(); render(); }, 120);
state.vaultSearchTimer=setTimeout(function(){ state.vaultSearchTimer=0; syncSelectedWithFilter(); render(); }, 120);
return;
}
if(a==='draft-change'&&state.detailDraft){
@@ -1353,6 +1497,7 @@ export function startNodewardenApp(runtimeConfig) {
if(!(n instanceof HTMLSelectElement)) return;
var a=n.getAttribute('data-action');
if(a==='field-modal-type'){ state.fieldModalType=String(n.value||'text'); return; }
if(a==='dialog-move-folder' && state.dialog && state.dialog.type==='move'){ state.dialog.selectedFolderId=String(n.value||'__none__'); return; }
});
app.addEventListener('compositionstart', function(ev){
@@ -1365,7 +1510,7 @@ export function startNodewardenApp(runtimeConfig) {
state.vaultSearchComposing=false;
state.vaultQuery=String(n.value||'');
if(state.vaultSearchTimer) clearTimeout(state.vaultSearchTimer);
state.vaultSearchTimer=setTimeout(function(){ syncSelectedWithFilter(); render(); }, 60);
state.vaultSearchTimer=setTimeout(function(){ state.vaultSearchTimer=0; syncSelectedWithFilter(); render(); }, 60);
}
});
+227 -1
View File
@@ -166,6 +166,197 @@
}
.alert-success { background: var(--success-bg); color: var(--success); border-color: #BADBCC; }
.alert-danger { background: var(--danger-bg); color: var(--danger); border-color: #F5C2C7; }
.toast-stack {
position: fixed;
top: 16px;
right: 16px;
z-index: 1200;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
width: min(420px, calc(100vw - 24px));
}
.toast-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-radius: 10px;
box-shadow: var(--shadow);
border: 1px solid #c9e9d6;
background: #dff4e5;
color: #0f5132;
padding: 14px 14px;
overflow: hidden;
}
.toast-item.error {
border-color: #f5c2c7;
background: #f8d7da;
color: #842029;
}
.toast-item.warning {
border-color: #ffe69c;
background: #fff3cd;
color: #664d03;
}
.toast-text {
font-size: 15px;
font-weight: 600;
padding-right: 10px;
}
.toast-close {
border: none;
background: transparent;
color: inherit;
font-size: 22px;
cursor: pointer;
line-height: 1;
opacity: 0.8;
}
.toast-close:hover { opacity: 1; }
.toast-bar {
position: absolute;
left: 0;
bottom: 0;
height: 3px;
width: 100%;
background: rgba(0,0,0,0.12);
transform-origin: left center;
animation: toastBar 4.5s linear forwards;
}
@keyframes toastBar { from { transform: scaleX(1); } to { transform: scaleX(0); } }
.dialog-mask {
position: fixed;
inset: 0;
background: rgba(17, 24, 39, 0.45);
z-index: 1300;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.dialog-card {
width: min(540px, 100%);
background: #fff;
border: 1px solid var(--border-color);
border-radius: 20px;
box-shadow: var(--shadow-lg);
padding: 24px 24px;
text-align: center;
}
.dialog-icon {
font-size: 34px;
line-height: 1;
color: #f4b400;
margin-bottom: 12px;
}
.dialog-title {
margin: 0 0 8px 0;
font-size: 34px;
line-height: 1.15;
color: #0f172a;
font-weight: 700;
}
.dialog-msg {
margin: 0 auto 18px auto;
color: #334155;
font-size: 20px;
max-width: 90%;
}
.dialog-btn {
width: 100%;
height: 56px;
border-radius: 999px;
font-size: 28px;
margin-bottom: 10px;
}
.form-dialog {
text-align: left;
}
.form-dialog .dialog-title {
font-size: 30px;
margin-bottom: 8px;
text-align: center;
}
.form-dialog .dialog-msg {
font-size: 16px;
max-width: 100%;
margin-bottom: 14px;
text-align: center;
}
.form-dialog .dialog-btn {
font-size: 22px;
}
.dialog-error {
background: #f8d7da;
border: 1px solid #f5c2c7;
color: #842029;
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
margin: 0 0 12px 0;
}
.unlock-card {
max-width: 620px;
padding: 30px 34px;
}
.unlock-pwd-wrap {
position: relative;
margin-bottom: 14px;
}
.unlock-pwd-input {
padding-right: 88px;
height: 48px;
border-radius: 10px;
border-color: #3f5b9e;
}
.auth-page .form-input {
height: 48px;
border-radius: 10px;
border-color: #3f5b9e;
padding: 10px 12px;
}
.auth-page .form-input:focus {
border-color: #3f5b9e;
box-shadow: none;
}
.unlock-eye-btn {
position: absolute;
right: 42px;
bottom: 8px;
width: 30px;
height: 30px;
border: none;
background: transparent;
color: #233a72;
font-size: 17px;
cursor: pointer;
}
.unlock-main-btn {
width: 100%;
margin-top: 8px;
height: 44px;
border-radius: 999px;
}
.unlock-secondary-btn {
width: 100%;
height: 44px;
border-radius: 999px;
border-color: var(--primary);
color: var(--primary);
background: #fff;
}
.unlock-or {
text-align: center;
color: #1f2f4f;
font-size: 16px;
margin: 10px 0;
line-height: 1;
}
.totp-qr-card {
background:#fff;
padding:16px;
@@ -323,6 +514,42 @@
overflow-y: auto;
background: #F8FAFC;
}
.content .btn {
height: 36px;
padding: 0 16px;
border-radius: 15px;
}
.content .btn-primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.content .btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
.content .btn-secondary {
background: #fff;
border-color: var(--primary);
color: var(--primary);
}
.content .btn-secondary:hover { background: #edf3ff; }
.content .btn-danger {
background: #fff;
border-color: #e11d48;
color: #e11d48;
}
.content .btn-danger:hover { background: #fff1f2; }
.content .btn-danger-icon {
width: 42px;
padding: 0;
border: none;
background: transparent;
color: #e11d48;
font-size: 26px;
line-height: 1;
}
.content .btn-danger-icon:hover {
border: 1px solid #fecdd3;
background: #fff1f2;
}
/* Vault Grid */
.vault-grid {
@@ -583,4 +810,3 @@
}
.vault-grid { grid-template-columns: 1fr; }
}