From 829008db7fc4f27de437862987016fbce272ed9f Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Fri, 27 Feb 2026 02:05:40 +0800 Subject: [PATCH] Add vault-utils.js with utility functions for field type parsing, selection counting, cipher type mapping, URI handling, and extracting first cipher URI --- public/web/app.js | 1518 +------------------------------------ public/web/crypto.js | 135 ++++ public/web/i18n.js | 217 ++++++ public/web/main.js | 1183 +++++++++++++++++++++++++++++ public/web/vault-utils.js | 44 ++ 5 files changed, 1580 insertions(+), 1517 deletions(-) create mode 100644 public/web/crypto.js create mode 100644 public/web/i18n.js create mode 100644 public/web/main.js create mode 100644 public/web/vault-utils.js 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')+'

' - + '
' - + '
' - + '
' - + '
' - + '
'; - } - 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= 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')+'

' + + '
' + + '
' + + '
' + + '
' + + '
'; + } + 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