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: '', registerShowPassword: false, registerShowPassword2: false, 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: '', loginShowPassword: false, loginTotpToken: '', loginTotpError: '', pendingLogin: null, totpSetupSecret: '', totpSetupToken: '', totpDisableOpen: false, totpDisablePassword: '', totpDisableError: '', unlockPassword: '', unlockError: '', unlockShowPassword: false, lockTimeoutMinutes: 15, lockLastActiveTs: Date.now(), lockCheckTimer: 0, lockChannel: null, toasts: [], toastSeq: 0, dialog: null }; 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 lockSettingsKey() { return 'nodewarden.web.lock.v1'; } 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 += '
  • ' + '
    ' + esc(x.text) + '
    ' + '' + '
    ' + '
  • '; } return ''; } 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 = ''; for (var i = 0; i < state.folders.length; i++) { var f = state.folders[i]; var id = String(f.id || ''); options += ''; } return '

    ' + esc(d.title) + '

    ' + esc(d.message) + '
    '; } return '

    ' + esc(d.title) + '

    ' + esc(d.message) + '
    '; } function saveSession() { if (!state.session) { localStorage.removeItem(sessionKey()); return; } var persisted = { accessToken: state.session.accessToken || '', refreshToken: state.session.refreshToken || '', email: state.session.email || '' }; localStorage.setItem(sessionKey(), JSON.stringify(persisted)); } 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 { accessToken: p.accessToken, refreshToken: p.refreshToken, email: p.email || '' }; } catch (e) { return null; } } function saveLockSettings() { localStorage.setItem(lockSettingsKey(), JSON.stringify({ lockTimeoutMinutes: Number(state.lockTimeoutMinutes) || 0 })); } function loadLockSettings() { try { var r = localStorage.getItem(lockSettingsKey()); if (!r) return; var p = JSON.parse(r); var mins = Number(p && p.lockTimeoutMinutes); if (Number.isFinite(mins) && mins >= 0) state.lockTimeoutMinutes = mins; } catch (_) {} } 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;i= mins * 60 * 1000) { lockVault(true, true); } }, 5000); } function lockVault(showMsg, broadcast) { if (state.session) { delete state.session.symEncKey; delete state.session.symMacKey; } clearVaultMemory(); state.pendingLogin = null; state.loginTotpToken = ''; 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 (_) {} } if (showMsg) setMsg('Vault locked.', 'ok'); else render(); } async function onUnlock(form) { clearMsg(); state.unlockError = ''; var fd = new FormData(form); state.unlockPassword = String(fd.get('password') || ''); if (!state.unlockPassword) { state.unlockError = 'Please input master password.'; render(); return; } try { var email = String(state.profile && state.profile.email ? state.profile.email : state.session && state.session.email ? state.session.email : '').toLowerCase(); if (!email) throw new Error('email missing'); var d = await deriveLoginHash(email, state.unlockPassword); var ek = await hkdfExpand(d.masterKey, 'enc', 32); var em = await hkdfExpand(d.masterKey, 'mac', 32); var symKeyBytes = await decryptBw(state.profile.key, ek, em); if (!symKeyBytes || symKeyBytes.length < 64) throw new Error('invalid key'); state.session.symEncKey = bytesToBase64(symKeyBytes.slice(0, 32)); state.session.symMacKey = bytesToBase64(symKeyBytes.slice(32, 64)); state.unlockPassword = ''; state.unlockError = ''; state.unlockShowPassword = false; await loadVault(); await loadAdminData(); state.phase = 'app'; state.tab = 'vault'; state.lockLastActiveTs = Date.now(); render(); setMsg('Unlocked.', 'ok'); } catch (e) { state.unlockError = 'Unlock failed. Master password is incorrect.'; render(); } } 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.unlockShowPassword=false; state.phase='login'; saveSession(); clearMsg(); render(); } async function authFetch(path, options){ var opts=options||{}; if(!state.session||!state.session.accessToken) throw new Error('unauthorized'); var h=opts.headers?Object.assign({},opts.headers):{}; h.Authorization='Bearer '+state.session.accessToken; var r=await fetch(path,Object.assign({},opts,{headers:h})); if(r.status!==401) return r; if(!state.session.refreshToken) return r; var f=new URLSearchParams(); f.set('grant_type','refresh_token'); f.set('refresh_token',state.session.refreshToken); var rr=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:f.toString()}); if(!rr.ok){ logout(); return r; } var tj=await rr.json(); state.session.accessToken=tj.access_token; state.session.refreshToken=tj.refresh_token||state.session.refreshToken; saveSession(); h.Authorization='Bearer '+state.session.accessToken; return fetch(path,Object.assign({},opts,{headers:h})); } async function loadProfile(){ var r=await authFetch('/api/accounts/profile',{method:'GET'}); if(!r.ok) throw new Error('profile'); state.profile=await r.json(); } async function loadVault(){ var cr=await authFetch('/api/ciphers',{method:'GET'}); var fr=await authFetch('/api/folders',{method:'GET'}); if(!cr.ok||!fr.ok) throw new Error('vault'); var cj=await cr.json(); var fj=await fr.json(); state.ciphers=cj.data||[]; state.folders=fj.data||[]; if(!state.selectedCipherId&&state.ciphers.length>0) 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'; }catch(_){ box.innerHTML='
    QR unavailable
    Use secret key below
    '; } } function buildSymmetricKeyBytes(){ if(!state.session||!state.session.symEncKey||!state.session.symMacKey) return null; try{ var enc=base64ToBytes(state.session.symEncKey); var mac=base64ToBytes(state.session.symMacKey); if(enc.length!==32||mac.length!==32) return null; var out=new Uint8Array(64); out.set(enc,0); out.set(mac,32); return out; }catch(e){ return null; } } function getUserCryptoKeys(){ var sym=buildSymmetricKeyBytes(); if(!sym||sym.length<64) return null; return { enc: sym.slice(0,32), mac: sym.slice(32,64) }; } async function getCipherCryptoKeys(cipher){ var user=getUserCryptoKeys(); if(!user) return null; if(cipher&&cipher.key){ try{ var raw=await decryptBw(cipher.key,user.enc,user.mac); if(raw&&raw.length>=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('login')+'
    ' + '
    '+t('brand')+'
    ' + '
    ' + renderMsg() + '
    ' + '
    ' + '
    ' + ' ' + '
    ' + '
    or
    ' + '
    ' + ' ' + '
    ' + (state.pendingLogin ? '' + '

    '+t('totpVerify')+'

    '+t('totpVerifySub')+'
    ' + (state.loginTotpError?'
    '+esc(state.loginTotpError)+'
    ':'') + '
    ' + '
    ' : '') + '
    ' + '
    '; } function renderRegisterScreen(){ return '' + '
    ' + '
    '+t('langSwitch')+'
    ' + '
    ' + '
    ' + '
    '+t('register')+'
    ' + '
    '+t('brand')+'
    ' + '
    ' + renderMsg() + '
    ' + '
    ' + '
    ' + '
    ' + '
    ' + '
    ' + ' ' + '
    ' + '
    or
    ' + '
    ' + ' ' + '
    ' + '
    ' + '
    '; } function renderLockedScreen(){ var email = String(state.profile && state.profile.email ? state.profile.email : state.session && state.session.email ? state.session.email : ''); return '' + '
    ' + '
    '+t('langSwitch')+'
    ' + '
    ' + '
    ' + '
    Unlock Vault
    ' + '
    '+esc(email)+'
    ' + '
    ' + renderMsg() + (state.unlockError?('
    '+esc(state.unlockError)+'
    '):'') + '
    ' + '
    ' + ' ' + '
    ' + '
    or
    ' + '
    ' + ' ' + '
    ' + '
    ' + '
    '; } 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 lockMins = Number(state.lockTimeoutMinutes)||0; return '' + renderMsg() + '

    '+t('settings')+'

    ' + '

    Vault Lock

    ' + '

    '+t('profile')+'

    ' + '

    '+t('changePwd')+'

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

    '+t('totpSetup')+'

    QR loading...
    Use secret key below
    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(){ 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='
    '+t('loading')+'
    ' + 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(); } async function init(){ var url=new URL(window.location.href); state.inviteCode=(url.searchParams.get('invite')||'').trim(); state.session=loadSession(); loadLockSettings(); ensureTotpTicker(); ensureLockChannel(); ensureAutoLockTicker(); var st=await fetch('/setup/status'); var setup=await jsonOrNull(st); var registered=!!(setup&&setup.registered); if(state.session){ try{ await loadProfile(); state.phase='locked'; 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)); } }catch(e){ console.warn('Key derivation failed:',e); } await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; state.lockLastActiveTs=Date.now(); setMsg('Login success.', 'ok'); } async function onSaveLockSettings(form){ var fd=new FormData(form); var mins=Number(fd.get('lockTimeoutMinutes')||0); if(!Number.isFinite(mins)||mins<0) mins=15; state.lockTimeoutMinutes=mins; saveLockSettings(); state.lockLastActiveTs=Date.now(); setMsg('Lock settings saved.', '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'); 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=0&&wi=0&&fi=0&&wi