diff --git a/public/web/app.js b/public/web/app.js
index d28ca4d..1dad3d1 100644
--- a/public/web/app.js
+++ b/public/web/app.js
@@ -1,1518 +1,2 @@
+export { startNodewardenApp } from './main.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 = {
- 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')+' ✕ '
- + '
'+t('fieldType')+' '+renderFieldTypeOptions(state.fieldModalType)+'
'
- + '
'+t('fieldLabel')+'
'
- + '
'+t('fieldValue')+'
'
- + '
'+t('add')+' '+t('cancel')+'
'
- + '
';
- }
- 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='-- Select -- ';
- for(var m=1;m<=12;m++){
- var mm=m<10?('0'+m):String(m);
- out += ''+mm+' ';
- }
- return out;
- }
- function renderDraftTypeCards(d){
- var typeNum=Number(d&&d.type||1);
- if(typeNum===3){
- return ''
- + 'Card details
'
- + '
'
- + '
'
- + '
Brand
'+renderCardBrandOptions(d.cardBrand||'')+' '
- + '
Exp month
'+renderMonthOptions(d.cardExpMonth||'')+' '
- + '
'
- + '
';
- }
- if(typeNum===4){
- return ''
- + 'Personal details
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
'
- + 'Identity
'
- + '
'
- + '
'
- + '
'
- + '
'
- + 'Contact information
'
- + '
'
- + '
'
- + '
'
- + 'Address
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
';
- }
- if(typeNum===5){
- return ''
- + 'SSH key
'
- + '
'
- + '
'
- + '
'
- + '
';
- }
- if(typeNum===2){
- return '';
- }
- return ''
- + ''+t('credentials')+'
'
- + '
'
- + '
'
- + '
'
- + '
';
- }
- function renderReadOnlyCustomFields(cipher){
- var fs=Array.isArray(cipher&&cipher.fields)?cipher.fields:[];
- if(!fs.length) return '';
- var rows='';
- for(var i=0;i'+esc(name)+' ('+esc(fieldTypeTextByNum(typeNum))+')
'+esc(value||'')+'
';
- }
- return '';
- }
- 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||'')+'
'
- + '
'
- + ''
- + 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||'')+'
'
- + '
'
- + ''
- + 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||'')+'
'
- + '
'
- + ''
- + renderReadOnlyCustomFields(c0)
- + history;
- }
- if(typeNum===2){
- return baseHead
- + ''
- + 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)+'
'+t('copy')+' '
- + '
'+(state.showSelectedPassword?t('hide'):t('reveal'))+' '+t('copy')+'
'
- + (totp?('
'):'')
- + '
'
- + ''+t('autofillOptions')+'
'
- + '
'+t('website')+'
'+esc(uri0||'')+'
'+(uri0?(''+t('open')+' '):'')+(uri0?(''+t('copy')+' '):'')+'
'
- + '
'
- + renderReadOnlyCustomFields(c0)
- + history;
- }
- function renderLoginScreen(){
- return ''
- + ''
- + '
'+t('langSwitch')+'
'
- + '
'
- + ' '
- + renderMsg()
- + '
'
- + ' '
- + (state.pendingLogin ? ''
- + '
'+t('totpVerify')+' '+t('totpVerifySub')+'
'
- + (state.loginTotpError?'
'+esc(state.loginTotpError)+'
':'')
- + '
'
- + '
'
- : '')
- + '
'
- + '
';
- }
-
- function renderRegisterScreen(){
- return ''
- + ''
- + '
'+t('langSwitch')+'
'
- + '
'
- + ' '
- + renderMsg()
- + '
'
- + ' '
- + '
'
- + '
';
- }
-
- function renderVaultTab(){
- var list=filteredCiphers();
- function renderFolderOptions(selectedId){
- var html=''+t('noFolder')+' ';
- 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.label||'')+' ('+esc(fieldTypeTextByNum(cf.type))+')
'+esc(cf.value||'')+'
✕ ';
- }
- detail=''
- + ''
- + renderDraftTypeCards(dc)
- + (Number(dc.type||1)===1?(''+t('autofillOptions')+'
'+wsHtml+'
'+t('addWebsite')+' '
- + '
'):'')
- + 'Additional options
'
- + '
'
- + '
Master password reprompt
'
- + '
'
- + 'Fields
'+cfHtml+'
'+t('addField')+' '
- + '';
- } 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.label||'')+' ('+esc(fieldTypeTextByNum(ef.type))+')
'+esc(ef.value||'')+'
✕ ';
- }
- detail=''
- + ''
- + renderDraftTypeCards(de)
- + (Number(de.type||1)===1?(''+t('autofillOptions')+'
'+ewsHtml+'
'+t('addWebsite')+' '
- + '
'):'')
- + 'Additional options
'
- + '
'
- + '
Master password reprompt
'
- + '
'
- + 'Fields
'+efsHtml+'
'+t('addField')+' '
- + '';
- } else detail=renderReadOnlyTypeDetails(c0, folderLabel, created, updated)
- + '';
- }
-
- return ''
- + renderMsg()
- + ''+t('refresh')+' '+t('move')+' '+t('delete')+' ('+selectedCount()+') '+t('selectAll')+' '+t('clear')+'
'+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('totpSetup')+' '+t('disableTotp')+' 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.email)+' '+esc(u.name||'')+' '+esc(u.role)+' '+esc(u.status)+' '
- + (canAct?''+(u.status==='active'?t('ban'):t('unban'))+' ':'')
- + (canAct?' '+t('delete')+' ':'')
- + ' ';
- }
- if(!usersRows) usersRows='No users found. ';
-
- var inviteRows='';
- for(var j=0;j'+esc(inv.code)+''+esc(inv.status)+' '+esc(inv.expiresAt)+' '
- + ''+t('copyLink')+' '
- + (inv.status==='active'?' '+t('revoke')+' ':'')
- + ' ';
- }
- if(!inviteRows) inviteRows='No invites found. ';
-
- return ''
- + renderMsg()
- + ''
- + '
'+t('admin')+' '
- + ''+t('refresh')+' '
- + ''
- + ''
- + ''+t('users')+' '+t('email')+' '+t('name')+' '+t('role')+' '+t('status')+' '+t('action')+' '+usersRows+'
'
- + ''+t('invites')+' Code '+t('status')+' Expires At '+t('action')+' '+inviteRows+'
';
- }
-
- function renderApp(){
- var isAdmin=state.profile&&state.profile.role==='admin';
- var showFolders=state.tab==='vault';
- var folders=''
- + '▾ '+t('allItems')+' '
- + '📁 '+t('noFolder')+' ';
- for(var i=0;i📁 '+esc(folderName)+' '; }
- var typeTree=''
- + '◉ '+t('typeAll')+' '
- + '⊕ '+t('typeLogin')+' '
- + '◧ '+t('typeCard')+' '
- + '◫ '+t('typeIdentity')+' '
- + '☰ '+t('typeNote')+' '
- + '• '+t('typeOther')+' ';
- var content = state.tab==='vault'?renderVaultTab():state.tab==='settings'?renderSettingsTab():(state.tab==='admin'&&isAdmin)?renderAdminTab():renderHelpTab();
-
- return ''
- + ''
- + '
'
- + '
'
- + '
'
- + '
'+t('langSwitch')+'
'
- + '
'+esc(state.profile&&state.profile.email?state.profile.email:'')+' '
- + '
'+t('logout')+' '
- + '
'
- + '
'
- + ''
- + (showFolders?(' '):'')
- + ' '+content+' '
- + '
'+renderTotpDisableModal();
- }
-
- function render(){
- if(state.phase==='loading'){ app.innerHTML=''; 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= 2) return { type: type, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: null };
+ throw new Error('unsupported enc type or format');
+}
+
+export async function decryptBw(cipherString, encKey, macKey) {
+ var parsed = parseCipherString(cipherString);
+ if (parsed.type === 2 && macKey && parsed.mac) {
+ var expect = await hmacSha256(macKey, concatBytes(parsed.iv, parsed.ct));
+ if (bytesToBase64(expect) !== bytesToBase64(parsed.mac)) throw new Error('MAC mismatch');
+ }
+ return decryptAesCbc(parsed.ct, encKey, parsed.iv);
+}
+
+export async function decryptStr(cipherString, encKey, macKey) {
+ if (!cipherString || typeof cipherString !== 'string') return '';
+ var plain = await decryptBw(cipherString, encKey, macKey);
+ return new TextDecoder().decode(plain);
+}
+
+export function extractTotpSecret(raw) {
+ if (!raw) return '';
+ var s = String(raw).trim();
+ if (!s) return '';
+ if (/^otpauth:\/\//i.test(s)) {
+ try {
+ var u = new URL(s);
+ var qp = u.searchParams.get('secret') || '';
+ return qp.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
+ } catch (_) {}
+ }
+ return s.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
+}
+
+export function base32ToBytes(input) {
+ var alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+ var clean = String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
+ var bits = 0, value = 0, out = [];
+ for (var i = 0; i < clean.length; i++) {
+ var idx = alphabet.indexOf(clean.charAt(i));
+ if (idx < 0) continue;
+ value = (value << 5) | idx;
+ bits += 5;
+ if (bits >= 8) {
+ out.push((value >>> (bits - 8)) & 0xff);
+ bits -= 8;
+ }
+ }
+ return new Uint8Array(out);
+}
+
+export async function calcTotpNow(rawSecret) {
+ var secret = extractTotpSecret(rawSecret);
+ if (!secret) return null;
+ var keyBytes = base32ToBytes(secret);
+ if (!keyBytes.length) return null;
+ var step = 30;
+ var epoch = Math.floor(Date.now() / 1000);
+ var counter = Math.floor(epoch / step);
+ var remain = step - (epoch % step);
+ var msg = new Uint8Array(8);
+ var c = counter;
+ for (var i = 7; i >= 0; i--) { msg[i] = c & 0xff; c = Math.floor(c / 256); }
+ var key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
+ var hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
+ var off = hs[hs.length - 1] & 0x0f;
+ var bin = ((hs[off] & 0x7f) << 24) | ((hs[off + 1] & 0xff) << 16) | ((hs[off + 2] & 0xff) << 8) | (hs[off + 3] & 0xff);
+ var code = (bin % 1000000).toString().padStart(6, '0');
+ return { code: code, remain: remain };
+}
+
diff --git a/public/web/i18n.js b/public/web/i18n.js
new file mode 100644
index 0000000..711e546
--- /dev/null
+++ b/public/web/i18n.js
@@ -0,0 +1,217 @@
+export const 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: '登录成功但显示密文:检查 profile key 和 KDF 参数是否一致。',
+ helpTb2: 'TOTP 持续失败:同步设备时间并使用最新密钥重新扫码。',
+ helpTb3: '修改密码失败:确认当前密码正确且新密码至少 12 位。',
+ helpTb4: '同步冲突:先刷新密码库,再逐个操作重试。',
+ langSwitch: 'English',
+ },
+};
+
diff --git a/public/web/main.js b/public/web/main.js
new file mode 100644
index 0000000..8e96fc5
--- /dev/null
+++ b/public/web/main.js
@@ -0,0 +1,1183 @@
+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')+' ✕ '
+ + '
'+t('fieldType')+' '+renderFieldTypeOptions(state.fieldModalType)+'
'
+ + '
'+t('fieldLabel')+'
'
+ + '
'+t('fieldValue')+'
'
+ + '
'+t('add')+' '+t('cancel')+'
'
+ + '
';
+ }
+ 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='-- Select -- ';
+ for(var m=1;m<=12;m++){
+ var mm=m<10?('0'+m):String(m);
+ out += ''+mm+' ';
+ }
+ return out;
+ }
+ function renderDraftTypeCards(d){
+ var typeNum=Number(d&&d.type||1);
+ if(typeNum===3){
+ return ''
+ + 'Card details
'
+ + '
'
+ + '
'
+ + '
Brand
'+renderCardBrandOptions(d.cardBrand||'')+' '
+ + '
Exp month
'+renderMonthOptions(d.cardExpMonth||'')+' '
+ + '
'
+ + '
';
+ }
+ if(typeNum===4){
+ return ''
+ + 'Personal details
'
+ + '
'
+ + '
'
+ + '
'
+ + '
'
+ + '
'
+ + '
'
+ + '
'
+ + 'Identity
'
+ + '
'
+ + '
'
+ + '
'
+ + '
'
+ + 'Contact information
'
+ + '
'
+ + '
'
+ + '
'
+ + 'Address
'
+ + '
'
+ + '
'
+ + '
'
+ + '
'
+ + '
'
+ + '
'
+ + '
'
+ + '
';
+ }
+ if(typeNum===5){
+ return ''
+ + 'SSH key
'
+ + '
'
+ + '
'
+ + '
'
+ + '
';
+ }
+ if(typeNum===2){
+ return '';
+ }
+ return ''
+ + ''+t('credentials')+'
'
+ + '
'
+ + '
'
+ + '
'
+ + '
';
+ }
+ function renderReadOnlyCustomFields(cipher){
+ var fs=Array.isArray(cipher&&cipher.fields)?cipher.fields:[];
+ if(!fs.length) return '';
+ var rows='';
+ for(var i=0;i'+esc(name)+' ('+esc(fieldTypeTextByNum(typeNum))+')
'+esc(value||'')+'
';
+ }
+ return '';
+ }
+ 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||'')+'
'
+ + '
'
+ + ''
+ + 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||'')+'
'
+ + '
'
+ + ''
+ + 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||'')+'
'
+ + '
'
+ + ''
+ + renderReadOnlyCustomFields(c0)
+ + history;
+ }
+ if(typeNum===2){
+ return baseHead
+ + ''
+ + 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)+'
'+t('copy')+' '
+ + '
'+(state.showSelectedPassword?t('hide'):t('reveal'))+' '+t('copy')+'
'
+ + (totp?('
'):'')
+ + '
'
+ + ''+t('autofillOptions')+'
'
+ + '
'+t('website')+'
'+esc(uri0||'')+'
'+(uri0?(''+t('open')+' '):'')+(uri0?(''+t('copy')+' '):'')+'
'
+ + '
'
+ + renderReadOnlyCustomFields(c0)
+ + history;
+ }
+ function renderLoginScreen(){
+ return ''
+ + ''
+ + '
'+t('langSwitch')+'
'
+ + '
'
+ + ' '
+ + renderMsg()
+ + '
'
+ + ' '
+ + (state.pendingLogin ? ''
+ + '
'+t('totpVerify')+' '+t('totpVerifySub')+'
'
+ + (state.loginTotpError?'
'+esc(state.loginTotpError)+'
':'')
+ + '
'
+ + '
'
+ : '')
+ + '
'
+ + '
';
+ }
+
+ function renderRegisterScreen(){
+ return ''
+ + ''
+ + '
'+t('langSwitch')+'
'
+ + '
'
+ + ' '
+ + renderMsg()
+ + '
'
+ + ' '
+ + '
'
+ + '
';
+ }
+
+ function renderVaultTab(){
+ var list=filteredCiphers();
+ function renderFolderOptions(selectedId){
+ var html=''+t('noFolder')+' ';
+ 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.label||'')+' ('+esc(fieldTypeTextByNum(cf.type))+')
'+esc(cf.value||'')+'
✕ ';
+ }
+ detail=''
+ + ''
+ + renderDraftTypeCards(dc)
+ + (Number(dc.type||1)===1?(''+t('autofillOptions')+'
'+wsHtml+'
'+t('addWebsite')+' '
+ + '
'):'')
+ + 'Additional options
'
+ + '
'
+ + '
Master password reprompt
'
+ + '
'
+ + 'Fields
'+cfHtml+'
'+t('addField')+' '
+ + '';
+ } 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.label||'')+' ('+esc(fieldTypeTextByNum(ef.type))+')
'+esc(ef.value||'')+'
✕ ';
+ }
+ detail=''
+ + ''
+ + renderDraftTypeCards(de)
+ + (Number(de.type||1)===1?(''+t('autofillOptions')+'
'+ewsHtml+'
'+t('addWebsite')+' '
+ + '
'):'')
+ + 'Additional options
'
+ + '
'
+ + '
Master password reprompt
'
+ + '
'
+ + 'Fields
'+efsHtml+'
'+t('addField')+' '
+ + '';
+ } else detail=renderReadOnlyTypeDetails(c0, folderLabel, created, updated)
+ + '';
+ }
+
+ return ''
+ + renderMsg()
+ + ''+t('refresh')+' '+t('move')+' '+t('delete')+' ('+selectedCount()+') '+t('selectAll')+' '+t('clear')+'
'+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('totpSetup')+' '+t('disableTotp')+' 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.email)+' '+esc(u.name||'')+' '+esc(u.role)+' '+esc(u.status)+' '
+ + (canAct?''+(u.status==='active'?t('ban'):t('unban'))+' ':'')
+ + (canAct?' '+t('delete')+' ':'')
+ + ' ';
+ }
+ if(!usersRows) usersRows='No users found. ';
+
+ var inviteRows='';
+ for(var j=0;j'+esc(inv.code)+''+esc(inv.status)+' '+esc(inv.expiresAt)+' '
+ + ''+t('copyLink')+' '
+ + (inv.status==='active'?' '+t('revoke')+' ':'')
+ + ' ';
+ }
+ if(!inviteRows) inviteRows='No invites found. ';
+
+ return ''
+ + renderMsg()
+ + ''
+ + '
'+t('admin')+' '
+ + ''+t('refresh')+' '
+ + ''
+ + ''
+ + ''+t('users')+' '+t('email')+' '+t('name')+' '+t('role')+' '+t('status')+' '+t('action')+' '+usersRows+'
'
+ + ''+t('invites')+' Code '+t('status')+' Expires At '+t('action')+' '+inviteRows+'
';
+ }
+
+ function renderApp(){
+ var isAdmin=state.profile&&state.profile.role==='admin';
+ var showFolders=state.tab==='vault';
+ var folders=''
+ + '▾ '+t('allItems')+' '
+ + '📁 '+t('noFolder')+' ';
+ for(var i=0;i📁 '+esc(folderName)+' '; }
+ var typeTree=''
+ + '◉ '+t('typeAll')+' '
+ + '⊕ '+t('typeLogin')+' '
+ + '◧ '+t('typeCard')+' '
+ + '◫ '+t('typeIdentity')+' '
+ + '☰ '+t('typeNote')+' '
+ + '• '+t('typeOther')+' ';
+ var content = state.tab==='vault'?renderVaultTab():state.tab==='settings'?renderSettingsTab():(state.tab==='admin'&&isAdmin)?renderAdminTab():renderHelpTab();
+
+ return ''
+ + ''
+ + '
'
+ + '
'
+ + '
'
+ + '
'+t('langSwitch')+'
'
+ + '
'+esc(state.profile&&state.profile.email?state.profile.email:'')+' '
+ + '
'+t('logout')+' '
+ + '
'
+ + '
'
+ + ''
+ + (showFolders?(' '):'')
+ + ' '+content+' '
+ + '
'+renderTotpDisableModal();
+ }
+
+ function render(){
+ if(state.phase==='loading'){ app.innerHTML=''; 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