From b10ce83ca001aefb697edf2b9daef338789748fe Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Fri, 27 Feb 2026 00:38:05 +0800 Subject: [PATCH] Add global styles for web client interface --- src/handlers/web.ts | 1044 +-------------------------------------- src/webclient/page.ts | 25 + src/webclient/script.ts | 680 +++++++++++++++++++++++++ src/webclient/styles.ts | 352 +++++++++++++ 4 files changed, 1058 insertions(+), 1043 deletions(-) create mode 100644 src/webclient/page.ts create mode 100644 src/webclient/script.ts create mode 100644 src/webclient/styles.ts diff --git a/src/handlers/web.ts b/src/handlers/web.ts index 313d1f0..d3aea56 100644 --- a/src/handlers/web.ts +++ b/src/handlers/web.ts @@ -1,1048 +1,6 @@ import { Env } from '../types'; import { htmlResponse } from '../utils/response'; -import { LIMITS } from '../config/limits'; - -function renderWebClientHTML(): string { - const defaultKdfIterations = LIMITS.auth.defaultKdfIterations; - - return ` - - - - - NodeWarden Web - - - -
- - -`; -} +import { renderWebClientHTML } from '../webclient/page'; export async function handleWebClientPage(request: Request, env: Env): Promise { void request; diff --git a/src/webclient/page.ts b/src/webclient/page.ts new file mode 100644 index 0000000..9b2dbae --- /dev/null +++ b/src/webclient/page.ts @@ -0,0 +1,25 @@ +import { LIMITS } from '../config/limits'; +import { renderWebClientScript } from './script'; +import { WEB_CLIENT_STYLES } from './styles'; + +export function renderWebClientHTML(): string { + const defaultKdfIterations = LIMITS.auth.defaultKdfIterations; + + return ` + + + + + NodeWarden Web + + + +
+ + +`; +} diff --git a/src/webclient/script.ts b/src/webclient/script.ts new file mode 100644 index 0000000..ae306f7 --- /dev/null +++ b/src/webclient/script.ts @@ -0,0 +1,680 @@ +export function renderWebClientScript(defaultKdfIterations: number): string { + return ` +(function () { + var app = document.getElementById('app'); + var defaultKdfIterations = ${defaultKdfIterations}; + var state = { + phase: 'loading', + lang: (navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en', + msg: '', + msgType: 'ok', + inviteCode: '', + registerName: '', + registerEmail: '', + registerPassword: '', + registerPassword2: '', + session: null, + profile: null, + tab: 'vault', + ciphers: [], + folders: [], + folderFilterId: '', + selectedCipherId: '', + selectedMap: {}, + users: [], + invites: [], + loginEmail: '', + loginPassword: '', + loginTotpToken: '', + loginTotpError: '', + pendingLogin: null, + totpSetupSecret: '', + totpSetupToken: '', + totpDisableOpen: false, + totpDisablePassword: '', + totpDisableError: '' + }; + var NO_FOLDER_FILTER = '__none__'; + + var i18n = { + en: { + brand: 'NodeWarden', + subtitle: 'Open Source Password Manager', + login: 'Log In', + register: 'Create Account', + email: 'Email Address', + masterPwd: 'Master Password', + confirmPwd: 'Confirm Master Password', + name: 'Name', + inviteCode: 'Invite Code (Optional)', + loginBtn: 'Log In', + registerBtn: 'Create Account', + backToLogin: 'Back to Log In', + vault: 'Vault', + settings: 'Settings', + admin: 'Admin', + help: 'Help', + logout: 'Log Out', + folders: 'Folders', + allItems: 'All Items', + noFolder: 'No Folder', + refresh: 'Refresh', + move: 'Move', + delete: 'Delete', + selectAll: 'Select All', + clear: 'Clear', + noItems: 'There are no items to list.', + selectItem: 'Select an item to view details.', + profile: 'Profile', + saveProfile: 'Save Profile', + changePwd: 'Change Master Password', + currentPwd: 'Current Master Password', + newPwd: 'New Master Password', + totpSetup: 'Two-Step Login (TOTP)', + enableTotp: 'Enable TOTP', + disableTotp: 'Disable TOTP', + secret: 'Authenticator Key', + verifyCode: 'Verification Code', + users: 'Users', + invites: 'Invites', + createInvite: 'Create Invite', + expiresIn: 'Expires in (hours)', + copyLink: 'Copy Link', + revoke: 'Revoke', + ban: 'Ban', + unban: 'Unban', + status: 'Status', + role: 'Role', + action: 'Options', + loading: 'Loading NodeWarden...', + totpVerify: 'Two-step verification', + totpVerifySub: 'Password is already verified.', + totpCode: 'TOTP Code', + verify: 'Verify', + cancel: 'Cancel', + totpDisableSub: 'Enter master password to disable two-step verification.', + helpSync: 'Upstream Sync', + helpSync1: 'Track upstream with a fork and scheduled sync workflow (recommended).', + helpSync2: 'Before merge: compare API routes, migration files, and auth logic changes.', + helpSync3: 'After merge: run local dev migration tests, then deploy Worker after validation.', + helpErr: 'Common Errors', + helpErr1: '401 Unauthorized: token expired or revoked, login again.', + helpErr2: '403 Account disabled: admin must unban user in User Management.', + helpErr3: '403 Invite invalid: invite expired/used/revoked, create a new invite.', + helpErr4: '429 Too many requests: wait retry seconds and avoid burst writes.', + helpTb: 'Troubleshooting', + helpTb1: 'Login OK but encrypted values shown: verify profile key and KDF settings are consistent.', + helpTb2: 'TOTP fails repeatedly: sync device time and re-scan QR using latest secret.', + helpTb3: 'Password change failed: ensure current password is correct and new password has at least 12 chars.', + helpTb4: 'Sync conflicts: refresh vault and retry one operation at a time.', + langSwitch: '中文' + }, + zh: { + brand: 'NodeWarden', + subtitle: '开源密码管理器', + login: '登录', + register: '创建账号', + email: '电子邮件地址', + masterPwd: '主密码', + confirmPwd: '确认主密码', + name: '姓名', + inviteCode: '邀请码 (可选)', + loginBtn: '登录', + registerBtn: '创建账号', + backToLogin: '返回登录', + vault: '密码库', + settings: '设置', + admin: '管理', + help: '帮助', + logout: '退出登录', + folders: '文件夹', + allItems: '所有项目', + noFolder: '无文件夹', + refresh: '刷新', + move: '移动', + delete: '删除', + selectAll: '全选', + clear: '清除', + noItems: '没有可列出的项目。', + selectItem: '选择一个项目以查看详细信息。', + profile: '个人资料', + saveProfile: '保存个人资料', + changePwd: '更改主密码', + currentPwd: '当前主密码', + newPwd: '新主密码', + totpSetup: '两步登录 (TOTP)', + enableTotp: '启用 TOTP', + disableTotp: '禁用 TOTP', + secret: '身份验证器密钥', + verifyCode: '验证码', + users: '用户', + invites: '邀请', + createInvite: '创建邀请', + expiresIn: '过期时间 (小时)', + copyLink: '复制链接', + revoke: '撤销', + ban: '封禁', + unban: '解封', + status: '状态', + role: '角色', + action: '选项', + loading: '正在加载 NodeWarden...', + totpVerify: '两步验证', + totpVerifySub: '密码已验证。', + totpCode: 'TOTP 验证码', + verify: '验证', + cancel: '取消', + totpDisableSub: '输入主密码以禁用两步验证。', + helpSync: '上游同步', + helpSync1: '建议通过 fork 和定时同步工作流跟踪上游。', + helpSync2: '合并前:比较 API 路由、迁移文件和认证逻辑的更改。', + helpSync3: '合并后:运行本地开发迁移测试,验证后部署 Worker。', + helpErr: '常见错误', + helpErr1: '401 未授权:令牌过期或被撤销,请重新登录。', + helpErr2: '403 账号被禁用:管理员必须在用户管理中解封用户。', + helpErr3: '403 邀请无效:邀请已过期/已使用/被撤销,请创建新邀请。', + helpErr4: '429 请求过多:等待重试时间,避免突发写入。', + helpTb: '故障排除', + helpTb1: '登录成功但显示加密值:验证个人资料密钥和 KDF 设置是否一致。', + helpTb2: 'TOTP 反复失败:同步设备时间并使用最新密钥重新扫描二维码。', + helpTb3: '密码更改失败:确保当前密码正确且新密码至少 12 个字符。', + helpTb4: '同步冲突:刷新密码库并一次重试一个操作。', + langSwitch: 'English' + } + }; + + function t(key) { return i18n[state.lang][key] || key; } + + function esc(v) { + return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + function sessionKey() { return 'nodewarden.web.session.v2'; } + function setMsg(t, ty) { state.msg = t || ''; state.msgType = ty || 'ok'; render(); } + function clearMsg() { state.msg = ''; } + function renderMsg() { return state.msg ? '
' + esc(state.msg) + '
' : ''; } + function saveSession() { if (state.session) localStorage.setItem(sessionKey(), JSON.stringify(state.session)); else localStorage.removeItem(sessionKey()); } + function loadSession() { try { var r = localStorage.getItem(sessionKey()); if (!r) return null; var p = JSON.parse(r); if (!p || !p.accessToken || !p.refreshToken) return null; return p; } catch (e) { return null; } } + function bytesToBase64(bytes) { var s=''; for (var i=0;i=0){ type=parseInt(s.substring(0,dotIdx),10); rest=s.substring(dotIdx+1); } + else{ var pp=s.split('|'); type=(pp.length===3)?2:0; rest=s; } + var parts=rest.split('|'); + if(type===2&&parts.length===3) return {type:2,iv:base64ToBytes(parts[0]),ct:base64ToBytes(parts[1]),mac:base64ToBytes(parts[2])}; + if((type===0||type===1||type===4)&&parts.length>=2) return {type:type,iv:base64ToBytes(parts[0]),ct:base64ToBytes(parts[1]),mac:null}; + return null; + } + async function decryptAesCbc(data,key,iv){ var ck=await crypto.subtle.importKey('raw',key,{name:'AES-CBC'},false,['decrypt']); return new Uint8Array(await crypto.subtle.decrypt({name:'AES-CBC',iv:iv},ck,data)); } + async function decryptBw(cipherString,encKey,macKey){ + var parsed=parseCipherString(cipherString); if(!parsed) return null; + if(parsed.type===2&&macKey&&parsed.mac){ + var macData=concatBytes(parsed.iv,parsed.ct); var computedMac=await hmacSha256(macKey,macData); + var match=true; if(computedMac.length!==parsed.mac.length) match=false; + else{ for(var i=0;i0) state.selectedCipherId=state.ciphers[0].id; await decryptVault(); } + async function loadAdminData(){ if(!state.profile||state.profile.role!=='admin') return; var u=await authFetch('/api/admin/users',{method:'GET'}); if(u.ok){ var uj=await u.json(); state.users=uj.data||[]; } var i=await authFetch('/api/admin/invites?includeInactive=true',{method:'GET'}); if(i.ok){ var ij=await i.json(); state.invites=ij.data||[]; } } + + function selectedCount(){ var n=0; for(var k in state.selectedMap){ if(state.selectedMap[k]) n++; } return n; } + function filteredCiphers(){ var out=[]; for(var i=0;i' + + '
'+t('langSwitch')+'
' + + '
' + + '
' + + ' ' + + '
'+t('brand')+'
' + + '
'+t('subtitle')+'
' + + '
' + + renderMsg() + + '
' + + '
' + + '
' + + ' ' + + '
' + + ' ' + + (state.pendingLogin ? '' + + '

'+t('totpVerify')+'

'+t('totpVerifySub')+'
' + + (state.loginTotpError?'
'+esc(state.loginTotpError)+'
':'') + + '
' + + '
' + : '') + + '
' + + ''; + } + + function renderRegisterScreen(){ + return '' + + '
' + + '
'+t('langSwitch')+'
' + + '
' + + '
' + + ' ' + + '
'+t('register')+'
' + + '
'+t('brand')+'
' + + '
' + + renderMsg() + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + ' ' + + '
' + + ' ' + + '
' + + '
'; + } + + function renderVaultTab(){ + var list=filteredCiphers(); + var rows=''; + for(var i=0;i
'+esc(nameText)+'
'+esc(c.id)+'
'; + } + if(!rows) rows='
'+t('noItems')+'
'; + + var c0=selectedCipher(); + var detail='
'+t('selectItem')+'
'; + if(c0){ + var login = c0.login||{}; + var fields=Array.isArray(c0.fields)?c0.fields:[]; + var fh=''; + for(var j=0;j '+esc(fields[j].decValue||fields[j].value||'')+''; + var uriHtml=''; if(login.uris){for(var j=0;j '+esc(u.decUri||u.uri||'')+'';}} + var cardHtml=''; if(c0.card){var cd=c0.card; cardHtml='
Cardholder: '+esc(cd.decCardholderName||cd.cardholderName||'')+'
Number: '+esc(cd.decNumber||cd.number||'')+'
Brand: '+esc(cd.decBrand||cd.brand||'')+'
Exp: '+esc(cd.decExpMonth||cd.expMonth||'')+'/'+esc(cd.decExpYear||cd.expYear||'')+'
CVV: '+esc(cd.decCode||cd.code||'')+'
';} + var identHtml=''; if(c0.identity){var id=c0.identity; identHtml='
Name: '+esc((id.decFirstName||id.firstName||'')+' '+(id.decLastName||id.lastName||''))+'
Email: '+esc(id.decEmail||id.email||'')+'
Phone: '+esc(id.decPhone||id.phone||'')+'
Company: '+esc(id.decCompany||id.company||'')+'
Username: '+esc(id.decUsername||id.username||'')+'
';} + detail='' + + '
Name: '+esc(c0.decName||c0.name||'')+'
' + + '
Notes: '+esc(c0.decNotes||c0.notes||'')+'
' + + (c0.login?('
Username: '+esc(login.decUsername||login.username||'')+'
' + + '
Password: '+esc(login.decPassword||login.password||'')+'
' + + '
TOTP: '+esc(login.decTotp||login.totp||'')+'
'+uriHtml):'' + ) + cardHtml + identHtml + fh; + } + + return '' + + renderMsg() + + '
' + + '

'+t('vault')+'

' + + '
' + + '
' + + '
'+rows+'
'+detail+'
'; + } + + function renderSettingsTab(){ + var p=state.profile||{}; + var secret=currentTotpSecret(); + var qr='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(buildTotpUri(secret)); + return '' + + renderMsg() + + '

'+t('settings')+'

' + + '

'+t('profile')+'

' + + '

'+t('changePwd')+'

After success, current sessions are revoked and you must log in again.
' + + '

'+t('totpSetup')+'

TOTP QR
Disable action prompts for master password.
'; + } + function renderTotpDisableModal(){ + if(!state.totpDisableOpen) return ''; + return '' + + '

'+t('disableTotp')+'

'+t('totpDisableSub')+'
' + + (state.totpDisableError?'
'+esc(state.totpDisableError)+'
':'') + + '
' + + '
'; + } + + function renderHelpTab(){ + return '' + + '

'+t('help')+'

' + + '

'+t('helpSync')+'

  • '+t('helpSync1')+'
  • '+t('helpSync2')+'
  • '+t('helpSync3')+'
' + + '

'+t('helpErr')+'

  • '+t('helpErr1')+'
  • '+t('helpErr2')+'
  • '+t('helpErr3')+'
  • '+t('helpErr4')+'
' + + '

'+t('helpTb')+'

  • '+t('helpTb1')+'
  • '+t('helpTb2')+'
  • '+t('helpTb3')+'
  • '+t('helpTb4')+'
'; + } + + function renderAdminTab(){ + var usersRows=''; + for(var i=0;i'+esc(u.name||'')+''+esc(u.role)+''+esc(u.status)+'' + + (canAct?'':'') + + (canAct?' ':'') + + ''; + } + if(!usersRows) usersRows='No users found.'; + + var inviteRows=''; + for(var j=0;j'+esc(inv.status)+''+esc(inv.expiresAt)+'' + + '' + + (inv.status==='active'?' ':'') + + ''; + } + if(!inviteRows) inviteRows='No invites found.'; + + return '' + + renderMsg() + + '
' + + '

'+t('admin')+'

' + + '' + + '
' + + '

'+t('createInvite')+'

' + + '

'+t('users')+'

'+usersRows+'
'+t('email')+''+t('name')+''+t('role')+''+t('status')+''+t('action')+'
' + + '

'+t('invites')+'

'+inviteRows+'
Code'+t('status')+'Expires At'+t('action')+'
'; + } + + function renderApp(){ + var isAdmin=state.profile&&state.profile.role==='admin'; + var showFolders=state.tab==='vault'; + var folders='' + + ''; + for(var i=0;i'+esc(folderName)+''; } + var content = state.tab==='vault'?renderVaultTab():state.tab==='settings'?renderSettingsTab():(state.tab==='admin'&&isAdmin)?renderAdminTab():renderHelpTab(); + + return '' + + '' + + '
' + + (showFolders?(' '):'') + + '
'+content+'
' + + '
'+renderTotpDisableModal(); + } + + function render(){ + if(state.phase==='loading'){ app.innerHTML='
'+t('loading')+'
'; return; } + if(state.phase==='register'){ app.innerHTML=renderRegisterScreen(); return; } + if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; } + app.innerHTML=renderApp(); + } + + async function init(){ + var url=new URL(window.location.href); state.inviteCode=(url.searchParams.get('invite')||'').trim(); state.session=loadSession(); + var st=await fetch('/setup/status'); var setup=await jsonOrNull(st); var registered=!!(setup&&setup.registered); + if(state.session){ + try{ await loadProfile(); await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; render(); return; } catch(e){ state.session=null; saveSession(); } + } + state.phase=registered?'login':'register'; render(); + } + + async function onRegister(form){ + clearMsg(); + var fd=new FormData(form); var name=String(fd.get('name')||'').trim(); var email=String(fd.get('email')||'').trim().toLowerCase(); var p=String(fd.get('password')||''); var p2=String(fd.get('password2')||''); var invite=String(fd.get('inviteCode')||'').trim(); + state.registerName=name; state.registerEmail=email; state.registerPassword=p; state.registerPassword2=p2; state.inviteCode=invite; + if(!email||!p) return setMsg('Please input email and password.', 'err'); + if(p.length<12) return setMsg('Master password must be at least 12 chars.', 'err'); + if(p!==p2) return setMsg('Passwords do not match.', 'err'); + try{ + var it=defaultKdfIterations; var mk=await pbkdf2(p,email,it,32); var hash=await pbkdf2(mk,p,1,32); var ek=await hkdfExpand(mk,'enc',32); var em=await hkdfExpand(mk,'mac',32); var sym=crypto.getRandomValues(new Uint8Array(64)); var encKey=await encryptBw(sym,ek,em); + var kp=await crypto.subtle.generateKey({name:'RSA-OAEP', modulusLength:2048, publicExponent:new Uint8Array([1,0,1]), hash:'SHA-1'}, true, ['encrypt','decrypt']); + var pub=new Uint8Array(await crypto.subtle.exportKey('spki',kp.publicKey)); var prv=new Uint8Array(await crypto.subtle.exportKey('pkcs8',kp.privateKey)); var encPrv=await encryptBw(prv,sym.slice(0,32),sym.slice(32,64)); + var resp=await fetch('/api/accounts/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,name:name,masterPasswordHash:bytesToBase64(hash),key:encKey,kdf:0,kdfIterations:it,inviteCode:invite||undefined,keys:{publicKey:bytesToBase64(pub),encryptedPrivateKey:encPrv}})}); + var j=await jsonOrNull(resp); if(!resp.ok) return setMsg((j&&(j.error||j.error_description))||'Register failed.', 'err'); + state.registerName=''; state.registerEmail=''; state.registerPassword=''; state.registerPassword2=''; state.inviteCode=''; + state.phase='login'; state.loginEmail=email; state.loginPassword=''; setMsg('Registration succeeded. Please sign in.', 'ok'); + }catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); } + } + + async function onLoginPassword(form){ + clearMsg(); + var fd=new FormData(form); state.loginEmail=String(fd.get('email')||'').trim().toLowerCase(); state.loginPassword=String(fd.get('password')||''); + if(!state.loginEmail||!state.loginPassword) return setMsg('Please input email and password.', 'err'); + try{ + var d=await deriveLoginHash(state.loginEmail,state.loginPassword); + var body=new URLSearchParams(); body.set('grant_type','password'); body.set('username',state.loginEmail); body.set('password',d.hash); body.set('scope','api offline_access'); + var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body.toString()}); + var j=await jsonOrNull(resp); + if(!resp.ok){ + if(j&&j.TwoFactorProviders){ state.pendingLogin={email:state.loginEmail,passwordHash:d.hash,masterKey:d.masterKey}; state.loginTotpToken=''; state.loginTotpError=''; clearMsg(); render(); return; } + return setMsg((j&&(j.error_description||j.error))||'Login failed.', 'err'); + } + await onLoginSuccess(j,d.masterKey,state.loginEmail,state.loginPassword); + }catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); } + } + + async function onLoginTotp(form){ + if(!state.pendingLogin) return setMsg('TOTP flow is not ready.', 'err'); + var fd=new FormData(form); state.loginTotpToken=String(fd.get('totpToken')||'').trim(); if(!state.loginTotpToken){ state.loginTotpError='Please input TOTP code.'; render(); return; } + var b=new URLSearchParams(); b.set('grant_type','password'); b.set('username',state.pendingLogin.email); b.set('password',state.pendingLogin.passwordHash); b.set('scope','api offline_access'); b.set('twoFactorProvider','0'); b.set('twoFactorToken',state.loginTotpToken); + var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:b.toString()}); + var j=await jsonOrNull(resp); if(!resp.ok){ state.loginTotpError=(j&&(j.error_description||j.error))||'TOTP verification failed.'; render(); return; } + state.loginTotpError=''; + await onLoginSuccess(j,state.pendingLogin.masterKey,state.pendingLogin.email,state.loginPassword); + } + + async function onLoginSuccess(tokenJson, masterKey, email, password){ + state.session={accessToken:tokenJson.access_token,refreshToken:tokenJson.refresh_token,email:email}; saveSession(); state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; + await loadProfile(); + try{ + var ek=await hkdfExpand(masterKey,'enc',32); var em=await hkdfExpand(masterKey,'mac',32); + var symKeyBytes=await decryptBw(state.profile.key,ek,em); + if(symKeyBytes){ state.session.symEncKey=bytesToBase64(symKeyBytes.slice(0,32)); state.session.symMacKey=bytesToBase64(symKeyBytes.slice(32,64)); saveSession(); } + }catch(e){ console.warn('Key derivation failed:',e); } + await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; + setMsg('Login success.', 'ok'); + } + async function onSaveProfile(form){ var fd=new FormData(form); var n=String(fd.get('name')||'').trim(); var em=String(fd.get('email')||'').trim().toLowerCase(); var r=await authFetch('/api/accounts/profile',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n,email:em})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Save profile failed.', 'err'); state.profile=j; render(); setMsg('Profile updated.', 'ok'); } + async function onChangePassword(form){ + var fd=new FormData(form); + var currentPassword=String(fd.get('currentPassword')||''); + var newPassword=String(fd.get('newPassword')||''); + var newPassword2=String(fd.get('newPassword2')||''); + if(!currentPassword||!newPassword) return setMsg('Current/new password is required.', 'err'); + if(newPassword.length<12) return setMsg('New master password must be at least 12 chars.', 'err'); + if(newPassword!==newPassword2) return setMsg('New passwords do not match.', 'err'); + if(newPassword===currentPassword) return setMsg('New password must be different.', 'err'); + var email=String(state.profile&&state.profile.email?state.profile.email:'').toLowerCase(); + if(!email) return setMsg('Profile email missing.', 'err'); + try{ + var current=await deriveLoginHash(email,currentPassword); + var userSym=buildSymmetricKeyBytes(); + if(!userSym){ + var oldEk=await hkdfExpand(current.masterKey,'enc',32); + var oldEm=await hkdfExpand(current.masterKey,'mac',32); + userSym=await decryptBw(state.profile.key,oldEk,oldEm); + } + if(!userSym||userSym.length<64) return setMsg('Unable to load vault key for password rotation.', 'err'); + var nextMasterKey=await pbkdf2(newPassword,email,current.kdfIterations,32); + var nextHash=await pbkdf2(nextMasterKey,newPassword,1,32); + var nextEk=await hkdfExpand(nextMasterKey,'enc',32); + var nextEm=await hkdfExpand(nextMasterKey,'mac',32); + var newKey=await encryptBw(userSym.slice(0,64),nextEk,nextEm); + var r=await authFetch('/api/accounts/password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({currentPasswordHash:current.hash,newMasterPasswordHash:bytesToBase64(nextHash),newKey:newKey,kdf:0,kdfIterations:current.kdfIterations})}); + var j=await jsonOrNull(r); + if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Change master password failed.', 'err'); + logout(); + setMsg('Master password changed. Please log in again.', 'ok'); + }catch(e){ + setMsg('Change master password failed: '+(e&&e.message?e.message:String(e)), 'err'); + } + } + async function onEnableTotp(form){ var fd=new FormData(form); state.totpSetupSecret=String(fd.get('secret')||'').toUpperCase().replace(/[\\s-]/g,'').replace(/=+$/g,''); state.totpSetupToken=String(fd.get('token')||'').trim(); if(!state.totpSetupSecret) return setMsg('TOTP secret is required.', 'err'); if(!state.totpSetupToken) return setMsg('TOTP token is required.', 'err'); var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,secret:state.totpSetupSecret,token:state.totpSetupToken})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Enable TOTP failed.', 'err'); state.totpSetupToken=''; render(); setMsg('TOTP enabled.', 'ok'); } + function onDisableTotp(){ state.totpDisableOpen=true; state.totpDisablePassword=''; state.totpDisableError=''; render(); } + async function onDisableTotpSubmit(form){ + var fd=new FormData(form); state.totpDisablePassword=String(fd.get('masterPassword')||''); + if(!state.totpDisablePassword){ state.totpDisableError='Please input master password.'; render(); return; } + try{ + var d=await deriveLoginHash(state.profile.email,state.totpDisablePassword); + var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:false,masterPasswordHash:d.hash})}); + var j=await jsonOrNull(r); + if(!r.ok){ state.totpDisableError=(j&&(j.error||j.error_description))||'Disable TOTP failed.'; render(); return; } + state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; + render(); setMsg('TOTP disabled.', 'ok'); + }catch(e){ + state.totpDisableError='Disable TOTP failed: '+(e&&e.message?e.message:String(e)); + render(); + } + } + + 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;istate.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 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 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){ + var form=ev.target; if(!(form instanceof HTMLFormElement)) return; ev.preventDefault(); + if(form.id==='registerForm') return void onRegister(form); + if(form.id==='loginForm') return void onLoginPassword(form); + if(form.id==='loginTotpForm') return void onLoginTotp(form); + if(form.id==='profileForm') return void onSaveProfile(form); + if(form.id==='passwordForm') return void onChangePassword(form); + if(form.id==='totpEnableForm') return void onEnableTotp(form); + if(form.id==='totpDisableForm') return void onDisableTotpSubmit(form); + if(form.id==='inviteForm') return void onCreateInvite(form); + }); + + app.addEventListener('click', 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; + 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==='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; } + if(a==='tab'){ state.tab=n.getAttribute('data-tab')||'vault'; clearMsg(); render(); return; } + if(a==='folder-filter'){ state.folderFilterId=n.getAttribute('data-folder')||''; var filtered=filteredCiphers(); state.selectedCipherId=filtered.length?filtered[0].id:''; render(); return; } + if(a==='pick-cipher'){ state.selectedCipherId=n.getAttribute('data-id')||''; render(); return; } + if(a==='toggle-select'){ ev.stopPropagation(); state.selectedMap[n.getAttribute('data-id')]=!!n.checked; render(); return; } + if(a==='select-all'){ var list=filteredCiphers(); state.selectedMap={}; for(var i=0;i