import { I18N } from './i18n.js'; import { bytesToBase64, base64ToBytes, concatBytes, pbkdf2, hkdfExpand, encryptBw, decryptBw, decryptStr, extractTotpSecret, calcTotpNow } from './crypto.js'; import { parseFieldType as parseFieldTypeUtil, selectedCount as selectedCountUtil, cipherTypeKey as cipherTypeKeyUtil, firstCipherUri as firstCipherUriUtil, hostFromUri as hostFromUriUtil } from './vault-utils.js'; export function startNodewardenApp(runtimeConfig) { var app = document.getElementById('app'); var defaultKdfIterations = Number(runtimeConfig && runtimeConfig.defaultKdfIterations) || 600000; 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: '', vaultQuery: '', vaultType: 'all', showSelectedPassword: false, vaultSearchComposing: false, vaultSearchTimer: 0, totpTicking: false, totpTickBusy: false, detailMode: 'view', detailDraft: null, createMenuOpen: false, fieldModalOpen: false, fieldModalType: 'text', fieldModalLabel: '', fieldModalValue: '', selectedCipherId: '', selectedMap: {}, users: [], invites: [], loginEmail: '', loginPassword: '', loginTotpToken: '', loginTotpError: '', pendingLogin: null, totpSetupSecret: '', totpSetupToken: '', totpDisableOpen: false, totpDisablePassword: '', totpDisableError: '' }; var NO_FOLDER_FILTER = '__none__'; var i18n = I18N; 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; } } async function jsonOrNull(resp){ var t=await resp.text(); if(!t) return null; try{ return JSON.parse(t);} catch(e){ return null; } } async function decryptVault(){ if(!state.session||!state.session.symEncKey||!state.session.symMacKey) return; var encKey=base64ToBytes(state.session.symEncKey); var macKey=base64ToBytes(state.session.symMacKey); 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(){ return selectedCountUtil(state.selectedMap); } function cipherTypeKey(c){ return cipherTypeKeyUtil(c); } function cipherTypeLabel(c){ var k=cipherTypeKey(c); if(k==='login') return t('typeLogin'); if(k==='card') return t('typeCard'); if(k==='identity') return t('typeIdentity'); if(k==='note') return t('typeNote'); return t('typeOther'); } function folderNameById(id){ for(var i=0;i=64) return { enc: raw.slice(0,32), mac: raw.slice(32,64), key: cipher.key }; }catch(e){} } return { enc: user.enc, mac: user.mac, key: null }; } async function encryptTextValue(v, enc, mac){ var s=String(v==null?'':v); if(!s) return null; return encryptBw(new TextEncoder().encode(s), enc, mac); } function openCreateDraft(){ state.detailMode='create'; state.showSelectedPassword=true; state.createMenuOpen=false; state.detailDraft={ id: '', type: 1, name: '', folderId: state.folderFilterId&&state.folderFilterId!==NO_FOLDER_FILTER?state.folderFilterId:'', reprompt: false, loginUsername: '', loginPassword: '', loginTotp: '', websites: [''], cardholderName: '', cardNumber: '', cardBrand: '', cardExpMonth: '', cardExpYear: '', cardCode: '', identTitle: '', identFirstName: '', identMiddleName: '', identLastName: '', identUsername: '', identCompany: '', identSsn: '', identPassportNumber: '', identLicenseNumber: '', identEmail: '', identPhone: '', identAddress1: '', identAddress2: '', identAddress3: '', identCity: '', identState: '', identPostalCode: '', identCountry: '', sshPrivateKey: '', sshPublicKey: '', sshFingerprint: '', customFields: [], notes: '' }; } function openEditDraft(cipher){ if(!cipher) return; var login=cipher.login||{}; var uris=Array.isArray(login.uris)?login.uris:[]; var ws=[]; for(var i=0;i'+l+''; } return opt('text',t('fieldText'))+opt('hidden',t('fieldHidden'))+opt('boolean',t('fieldBoolean'))+opt('linked',t('fieldLinked')); } function renderCreateMenu(){ if(!state.createMenuOpen) return ''; return '
'; } function renderFieldModal(){ if(!state.fieldModalOpen) return ''; return '' + '

'+t('addField')+'

' + '
' + '
' + '
' + '
' + '
'; } function fieldTypeTextByNum(n){ var x=parseFieldType(n); if(x===1) return t('fieldHidden'); if(x===2) return t('fieldBoolean'); if(x===3) return t('fieldLinked'); return t('fieldText'); } function renderCardBrandOptions(selected){ var s=String(selected||'').toLowerCase(); var brands=['','visa','mastercard','amex','discover','jcb','unionpay','dinersclub','maestro']; var labels={ '':'-- Select --', visa:'Visa', mastercard:'Mastercard', amex:'American Express', discover:'Discover', jcb:'JCB', unionpay:'UnionPay', dinersclub:'Diners Club', maestro:'Maestro' }; var out=''; for(var i=0;i'+labels[b]+''; } return out; } function renderMonthOptions(selected){ var s=String(selected||''); var out=''; for(var m=1;m<=12;m++){ var mm=m<10?('0'+m):String(m); out += ''; } return out; } function renderDraftTypeCards(d){ var typeNum=Number(d&&d.type||1); if(typeNum===3){ return '' + '
Card details
' + '
Cardholder name
' + '
Number
' + '
Brand
' + '
Exp month
Exp year
' + '
Security code (CVV)
' + '
'; } if(typeNum===4){ return '' + '
Personal details
' + '
Title
' + '
First name
' + '
Middle name
' + '
Last name
' + '
Username
' + '
Company
' + '
' + '
Identity
' + '
SSN
' + '
Passport number
' + '
License number
' + '
' + '
Contact information
' + '
Email
' + '
Phone
' + '
' + '
Address
' + '
Address 1
' + '
Address 2
' + '
Address 3
' + '
City / Town
' + '
State / Province
' + '
ZIP / Postal code
' + '
Country
' + '
'; } if(typeNum===5){ return '' + '
SSH key
' + '
Private key
' + '
Public key
' + '
Fingerprint
' + '
'; } if(typeNum===2){ return ''; } return '' + '
'+t('credentials')+'
' + '
Username
' + '
Password
' + '
TOTP Secret
' + '
'; } function renderReadOnlyCustomFields(cipher){ var fs=Array.isArray(cipher&&cipher.fields)?cipher.fields:[]; if(!fs.length) return ''; var rows=''; for(var i=0;i
'+esc(value||'')+'
'; } return '
Fields
'+rows+'
'; } function renderReadOnlyTypeDetails(c0, folderLabel, created, updated){ var typeNum=Number(c0&&c0.type||1); var notes=c0&&((c0.decNotes||c0.notes)||''); var baseHead='' + '
' + '
'+esc(c0.decName||c0.name||'')+'
' + '
'+t('folder')+': '+esc(folderLabel||t('noFolder'))+'
' + '
'; var history='' + '
'+t('itemHistory')+'
' + '
'+t('updatedAt')+': '+esc(updated)+'
' + '
'+t('createdAt')+': '+esc(created)+'
' + '
'; if(typeNum===3){ var c=c0.card||{}; return baseHead + '
Card details
' + '
Cardholder name
'+esc(c.decCardholderName||c.cardholderName||'')+'
' + '
Number
'+esc(c.decNumber||c.number||'')+'
' + '
Brand
'+esc(c.decBrand||c.brand||'')+'
' + '
Exp month/year
'+esc((c.decExpMonth||c.expMonth||'')+' / '+(c.decExpYear||c.expYear||''))+'
' + '
Security code (CVV)
'+esc(c.decCode||c.code||'')+'
' + '
' + '
Additional options
Notes
'+esc(notes)+'
' + renderReadOnlyCustomFields(c0) + history; } if(typeNum===4){ var id=c0.identity||{}; return baseHead + '
Personal details
' + '
Title
'+esc(id.decTitle||id.title||'')+'
' + '
First name
'+esc(id.decFirstName||id.firstName||'')+'
' + '
Middle name
'+esc(id.decMiddleName||id.middleName||'')+'
' + '
Last name
'+esc(id.decLastName||id.lastName||'')+'
' + '
Username
'+esc(id.decUsername||id.username||'')+'
' + '
Company
'+esc(id.decCompany||id.company||'')+'
' + '
' + '
Identity
' + '
SSN
'+esc(id.decSsn||id.ssn||'')+'
' + '
Passport number
'+esc(id.decPassportNumber||id.passportNumber||'')+'
' + '
License number
'+esc(id.decLicenseNumber||id.licenseNumber||'')+'
' + '
' + '
Contact information
' + '
Email
'+esc(id.decEmail||id.email||'')+'
' + '
Phone
'+esc(id.decPhone||id.phone||'')+'
' + '
' + '
Address
' + '
Address 1
'+esc(id.decAddress1||id.address1||'')+'
' + '
Address 2
'+esc(id.decAddress2||id.address2||'')+'
' + '
Address 3
'+esc(id.decAddress3||id.address3||'')+'
' + '
City / Town
'+esc(id.decCity||id.city||'')+'
' + '
State / Province
'+esc(id.decState||id.state||'')+'
' + '
ZIP / Postal code
'+esc(id.decPostalCode||id.postalCode||'')+'
' + '
Country
'+esc(id.decCountry||id.country||'')+'
' + '
' + '
Additional options
Notes
'+esc(notes)+'
' + renderReadOnlyCustomFields(c0) + history; } if(typeNum===5){ var ssh=c0.sshKey||{}; var privateKey=ssh.decPrivateKey||ssh.privateKey||''; return baseHead + '
SSH key
' + '
Private key
'+esc(privateKey?new Array(Math.max(String(privateKey).length,12)+1).join('•'):'')+'
' + '
Public key
'+esc(ssh.decPublicKey||ssh.publicKey||'')+'
' + '
Fingerprint
'+esc(ssh.decFingerprint||ssh.fingerprint||'')+'
' + '
' + '
Additional options
Notes
'+esc(notes)+'
' + renderReadOnlyCustomFields(c0) + history; } if(typeNum===2){ return baseHead + '
Additional options
Notes
'+esc(notes)+'
' + renderReadOnlyCustomFields(c0) + history; } var login=c0.login||{}; var username=login.decUsername||login.username||''; var rawPwd=login.decPassword||login.password||''; var masked=rawPwd?new Array(Math.max(rawPwd.length,12)+1).join('•'):''; var pwdText=state.showSelectedPassword?rawPwd:masked; var totp=login.decTotp||login.totp||''; var uri0=firstCipherUri(c0); return baseHead + '
'+t('credentials')+'
' + '
Username
'+esc(username)+'
' + '
Password
'+esc(pwdText)+'
' + (totp?('
TOTP
...
'):'') + '
' + '
'+t('autofillOptions')+'
' + '
'+t('website')+'
'+esc(uri0||'')+'
'+(uri0?(''):'')+(uri0?(''):'')+'
' + '
' + renderReadOnlyCustomFields(c0) + history; } function renderLoginScreen(){ return '' + '
' + '
'+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(); function renderFolderOptions(selectedId){ var html=''; for(var fi=0;fi'+esc(ff.decName||ff.name||ff.id)+''; } return html; } var rows=''; for(var i=0;i') : '🌐'; rows += '' + '
' + '' + '
'+icon+'
'+esc(nameText)+'
'+esc(subtitle||'')+'
' + '
'; } if(!rows) rows='
'+t('noItems')+'
'; var c0=selectedCipher(); var detail='
'+t('selectItem')+'
'; if(state.detailMode==='create'){ var dc=state.detailDraft||{}; var wsHtml=''; var cws=Array.isArray(dc.websites)?dc.websites:['']; for(var wci=0;wci'+(cws.length>1?'':'')+''; } var cfHtml=''; var cfs=Array.isArray(dc.customFields)?dc.customFields:[]; for(var cfi=0;cfi
'+esc(cf.value||'')+'
'; } detail='' + '
'+t('folder')+':
' + renderDraftTypeCards(dc) + (Number(dc.type||1)===1?('
'+t('autofillOptions')+'
'+wsHtml+'' + '
'):'') + '
Additional options
' + '' + '
' + '
' + '
Fields
'+cfHtml+'
' + '
'; } else if(c0){ var folderLabel=c0.folderId?folderNameById(c0.folderId):t('noFolder'); var updated=c0.revisionDate||c0.updatedAt||''; var created=c0.creationDate||c0.createdAt||''; if(state.detailMode==='edit'){ var de=state.detailDraft||{}; var ewsHtml=''; var ews=Array.isArray(de.websites)?de.websites:['']; for(var wei=0;wei'+(ews.length>1?'':'')+''; } var efsHtml=''; var efs=Array.isArray(de.customFields)?de.customFields:[]; for(var efi=0;efi
'+esc(ef.value||'')+'
'; } detail='' + '
'+t('folder')+':
' + renderDraftTypeCards(de) + (Number(de.type||1)===1?('
'+t('autofillOptions')+'
'+ewsHtml+'' + '
'):'') + '
Additional options
' + '' + '
' + '
' + '
Fields
'+efsHtml+'
' + '
'; } else detail=renderReadOnlyTypeDetails(c0, folderLabel, created, updated) + '
'; } return '' + renderMsg() + '
'+renderCreateMenu()+'
'+rows+'
'+detail+renderFieldModal()+'
'; } 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 typeTree='' + '' + '' + '' + '' + '' + ''; 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(); updateLiveTotpDisplay(); } async function init(){ var url=new URL(window.location.href); state.inviteCode=(url.searchParams.get('invite')||'').trim(); state.session=loadSession(); ensureTotpTicker(); 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')||''; syncSelectedWithFilter(); state.showSelectedPassword=false; render(); return; } if(a==='type-filter'){ state.vaultType=n.getAttribute('data-type')||'all'; syncSelectedWithFilter(); state.showSelectedPassword=false; render(); return; } if(a==='pick-cipher'){ state.selectedCipherId=n.getAttribute('data-id')||''; state.showSelectedPassword=false; render(); return; } if(a==='detail-create-toggle'){ state.createMenuOpen=!state.createMenuOpen; render(); return; } if(a==='detail-create-type'){ openCreateDraft(); if(state.detailDraft) state.detailDraft.type=Number(n.getAttribute('data-type')||1); render(); return; } if(a==='detail-edit'){ openEditDraft(selectedCipher()); render(); return; } if(a==='detail-cancel'){ closeDetailEdit(); render(); return; } if(a==='detail-save'){ return void saveDetailDraft(); } if(a==='detail-delete'){ return void deleteSelectedCipher(); } if(a==='draft-website-add'){ if(state.detailDraft){ if(!Array.isArray(state.detailDraft.websites)) state.detailDraft.websites=[]; state.detailDraft.websites.push(''); render(); } return; } if(a==='draft-website-remove'){ if(state.detailDraft&&Array.isArray(state.detailDraft.websites)){ var wi=Number(n.getAttribute('data-index')||-1); if(wi>=0&&wi=0&&fi=0&&wi