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 = { 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', searchVault: 'Search vault', filter: 'Filter', typeAll: 'All items', typeLogin: 'Logins', typeCard: 'Cards', typeIdentity: 'Identities', typeNote: 'Secure notes', typeOther: 'Other', addWebsite: '+ Add website', addField: '+ Add field', fieldType: 'Field type', fieldLabel: 'Field label', fieldValue: 'Field value', fieldText: 'Text', fieldHidden: 'Hidden', fieldBoolean: 'Boolean', fieldLinked: 'Linked', add: 'Add', newTypeLogin: 'Login', newTypeCard: 'Card', newTypeIdentity: 'Identity', newTypeNote: 'Note', newTypeSsh: 'SSH key', refresh: 'Sync', move: 'Move', delete: 'Delete', selectAll: 'Select All', clear: 'Cancel', 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)', totpLiveIn: 'Refresh in', enableTotp: 'Enable TOTP', disableTotp: 'Disable TOTP', secret: 'Authenticator Key', verifyCode: 'Verification Code', credentials: 'Login credentials', autofillOptions: 'Autofill', itemHistory: 'Item history', website: 'Website', folder: 'Folder', createdAt: 'Created', updatedAt: 'Last edited', open: 'Open', copy: 'Copy', reveal: 'Reveal', hide: 'Hide', 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: '无文件夹', searchVault: '搜索密码库', filter: '筛选', typeAll: '所有项目', typeLogin: '登录', typeCard: '支付卡', typeIdentity: '身份', typeNote: '备注', typeOther: '其他', addWebsite: '+ 添加网站', addField: '+ 添加字段', fieldType: '字段类型', fieldLabel: '字段标签', fieldValue: '字段值', fieldText: '文本型', fieldHidden: '隐藏型', fieldBoolean: '复选框型', fieldLinked: '链接型', add: '添加', newTypeLogin: '登录', newTypeCard: '支付卡', newTypeIdentity: '身份', newTypeNote: '笔记', newTypeSsh: 'SSH 密钥', refresh: '同步', move: '移动', delete: '删除', selectAll: '全选', clear: '取消', noItems: '没有可列出的项目。', selectItem: '选择一个项目以查看详细信息。', profile: '个人资料', saveProfile: '保存个人资料', changePwd: '更改主密码', currentPwd: '当前主密码', newPwd: '新主密码', totpSetup: '两步登录 (TOTP)', totpLiveIn: '刷新剩余', enableTotp: '启用 TOTP', disableTotp: '禁用 TOTP', secret: '身份验证器密钥', verifyCode: '验证码', credentials: '登录凭据', autofillOptions: '自动填充', itemHistory: '项目历史记录', website: '网站', folder: '文件夹', createdAt: '创建于', updatedAt: '最后编辑', open: '打开', copy: '复制', reveal: '显示', hide: '隐藏', 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 cipherTypeKey(c){ var tnum=Number(c&&c.type); if(tnum===1) return 'login'; if(tnum===3) return 'card'; if(tnum===4) return 'identity'; if(tnum===2) return 'note'; return 'other'; } 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>>0, false); var key=await crypto.subtle.importKey('raw', keyBytes, {name:'HMAC', hash:'SHA-1'}, false, ['sign']); var sig=new Uint8Array(await crypto.subtle.sign('HMAC', key, buf)); var offset=sig[sig.length-1]&0x0f; var bin=((sig[offset]&0x7f)<<24)|((sig[offset+1]&0xff)<<16)|((sig[offset+2]&0xff)<<8)|(sig[offset+3]&0xff); var token=String(bin%1000000).padStart(6,'0'); return { token: token.slice(0,3)+' '+token.slice(3), remain: remain }; } async function updateLiveTotpDisplay(){ if(state.phase!=='app'||state.tab!=='vault') return; if(state.totpTickBusy) return; var vEl=document.getElementById('totp-live-value'); var rEl=document.getElementById('totp-live-remain'); if(!vEl||!rEl) return; var c=selectedCipher(); if(!c||!c.login) return; var raw=(c.login.decTotp||c.login.totp||'').trim(); if(!raw){ vEl.textContent=''; rEl.textContent=''; return; } state.totpTickBusy=true; try{ var x=await calcTotpNow(raw); if(!x){ vEl.textContent='N/A'; rEl.textContent=''; return; } vEl.textContent=x.token; rEl.textContent=t('totpLiveIn')+': '+x.remain+'s'; }catch(e){ vEl.textContent='N/A'; rEl.textContent=''; }finally{ state.totpTickBusy=false; } } function ensureTotpTicker(){ if(state.totpTicking) return; state.totpTicking=true; setInterval(function(){ updateLiveTotpDisplay(); }, 1000); } function filteredCiphers(){ var out=[]; var q=String(state.vaultQuery||'').toLowerCase(); 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