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: '',
unlockPassword: '',
unlockError: '',
lockTimeoutMinutes: 15,
lockLastActiveTs: Date.now(),
lockCheckTimer: 0,
lockChannel: null
};
var NO_FOLDER_FILTER = '__none__';
var i18n = I18N;
function t(key) { return i18n[state.lang][key] || key; }
function esc(v) {
return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
function sessionKey() { return 'nodewarden.web.session.v2'; }
function lockSettingsKey() { return 'nodewarden.web.lock.v1'; }
function 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.removeItem(sessionKey()); return; }
var persisted = {
accessToken: state.session.accessToken || '',
refreshToken: state.session.refreshToken || '',
email: state.session.email || ''
};
localStorage.setItem(sessionKey(), JSON.stringify(persisted));
}
function loadSession() {
try {
var r = localStorage.getItem(sessionKey());
if (!r) return null;
var p = JSON.parse(r);
if (!p || !p.accessToken || !p.refreshToken) return null;
return { accessToken: p.accessToken, refreshToken: p.refreshToken, email: p.email || '' };
} catch (e) { return null; }
}
function saveLockSettings() {
localStorage.setItem(lockSettingsKey(), JSON.stringify({ lockTimeoutMinutes: Number(state.lockTimeoutMinutes) || 0 }));
}
function loadLockSettings() {
try {
var r = localStorage.getItem(lockSettingsKey());
if (!r) return;
var p = JSON.parse(r);
var mins = Number(p && p.lockTimeoutMinutes);
if (Number.isFinite(mins) && mins >= 0) state.lockTimeoutMinutes = mins;
} catch (_) {}
}
async function jsonOrNull(resp){ var t=await resp.text(); if(!t) return null; try{ return JSON.parse(t);} catch(e){ return null; } }
async function decryptVault(){
if(!state.session||!state.session.symEncKey||!state.session.symMacKey) return;
var encKey=base64ToBytes(state.session.symEncKey); var macKey=base64ToBytes(state.session.symMacKey);
for(var i=0;i= mins * 60 * 1000) {
lockVault(true, true);
}
}, 5000);
}
function lockVault(showMsg, broadcast) {
if (state.session) {
delete state.session.symEncKey;
delete state.session.symMacKey;
}
clearVaultMemory();
state.pendingLogin = null;
state.loginTotpToken = '';
state.loginTotpError = '';
state.unlockPassword = '';
state.unlockError = '';
state.phase = 'locked';
if (broadcast !== false && state.lockChannel) {
try { state.lockChannel.postMessage({ type: 'lock', at: Date.now() }); } catch (_) {}
}
if (showMsg) setMsg('Vault locked.', 'ok');
else render();
}
async function onUnlock(form) {
clearMsg();
state.unlockError = '';
var fd = new FormData(form);
state.unlockPassword = String(fd.get('password') || '');
if (!state.unlockPassword) {
state.unlockError = 'Please input master password.';
render();
return;
}
try {
var email = String(state.profile && state.profile.email ? state.profile.email : state.session && state.session.email ? state.session.email : '').toLowerCase();
if (!email) throw new Error('email missing');
var d = await deriveLoginHash(email, state.unlockPassword);
var ek = await hkdfExpand(d.masterKey, 'enc', 32);
var em = await hkdfExpand(d.masterKey, 'mac', 32);
var symKeyBytes = await decryptBw(state.profile.key, ek, em);
if (!symKeyBytes || symKeyBytes.length < 64) throw new Error('invalid key');
state.session.symEncKey = bytesToBase64(symKeyBytes.slice(0, 32));
state.session.symMacKey = bytesToBase64(symKeyBytes.slice(32, 64));
state.unlockPassword = '';
state.unlockError = '';
await loadVault();
await loadAdminData();
state.phase = 'app';
state.tab = 'vault';
state.lockLastActiveTs = Date.now();
render();
setMsg('Unlocked.', 'ok');
} catch (e) {
state.unlockError = 'Unlock failed. Master password is incorrect.';
render();
}
}
function logout(){
state.session=null; state.profile=null; state.ciphers=[]; state.folders=[]; state.users=[]; state.invites=[]; state.folderFilterId=''; state.selectedCipherId=''; state.selectedMap={}; state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; state.unlockPassword=''; state.unlockError=''; state.phase='login'; saveSession(); clearMsg(); render();
}
async function authFetch(path, options){
var opts=options||{}; if(!state.session||!state.session.accessToken) throw new Error('unauthorized');
var h=opts.headers?Object.assign({},opts.headers):{}; h.Authorization='Bearer '+state.session.accessToken;
var r=await fetch(path,Object.assign({},opts,{headers:h})); if(r.status!==401) return r; if(!state.session.refreshToken) return r;
var f=new URLSearchParams(); f.set('grant_type','refresh_token'); f.set('refresh_token',state.session.refreshToken);
var rr=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:f.toString()});
if(!rr.ok){ logout(); return r; }
var tj=await rr.json(); state.session.accessToken=tj.access_token; state.session.refreshToken=tj.refresh_token||state.session.refreshToken; saveSession();
h.Authorization='Bearer '+state.session.accessToken; return fetch(path,Object.assign({},opts,{headers:h}));
}
async function loadProfile(){ var r=await authFetch('/api/accounts/profile',{method:'GET'}); if(!r.ok) throw new Error('profile'); state.profile=await r.json(); }
async function loadVault(){ var cr=await authFetch('/api/ciphers',{method:'GET'}); var fr=await authFetch('/api/folders',{method:'GET'}); if(!cr.ok||!fr.ok) throw new Error('vault'); var cj=await cr.json(); var fj=await fr.json(); state.ciphers=cj.data||[]; state.folders=fj.data||[]; if(!state.selectedCipherId&&state.ciphers.length>0) state.selectedCipherId=state.ciphers[0].id; await decryptVault(); }
async function loadAdminData(){ if(!state.profile||state.profile.role!=='admin') return; var u=await authFetch('/api/admin/users',{method:'GET'}); if(u.ok){ var uj=await u.json(); state.users=uj.data||[]; } var i=await authFetch('/api/admin/invites?includeInactive=true',{method:'GET'}); if(i.ok){ var ij=await i.json(); state.invites=ij.data||[]; } }
function selectedCount(){ return selectedCountUtil(state.selectedMap); }
function cipherTypeKey(c){ return cipherTypeKeyUtil(c); }
function cipherTypeLabel(c){
var k=cipherTypeKey(c);
if(k==='login') return t('typeLogin');
if(k==='card') return t('typeCard');
if(k==='identity') return t('typeIdentity');
if(k==='note') return t('typeNote');
return t('typeOther');
}
function folderNameById(id){
for(var i=0;i ';
}catch(_){
box.innerHTML='QR unavailable
Use secret key below
';
}
}
function buildSymmetricKeyBytes(){
if(!state.session||!state.session.symEncKey||!state.session.symMacKey) return null;
try{
var enc=base64ToBytes(state.session.symEncKey);
var mac=base64ToBytes(state.session.symMacKey);
if(enc.length!==32||mac.length!==32) return null;
var out=new Uint8Array(64);
out.set(enc,0);
out.set(mac,32);
return out;
}catch(e){
return null;
}
}
function getUserCryptoKeys(){
var sym=buildSymmetricKeyBytes();
if(!sym||sym.length<64) return null;
return { enc: sym.slice(0,32), mac: sym.slice(32,64) };
}
async function getCipherCryptoKeys(cipher){
var user=getUserCryptoKeys();
if(!user) return null;
if(cipher&&cipher.key){
try{
var raw=await decryptBw(cipher.key,user.enc,user.mac);
if(raw&&raw.length>=64) return { enc: raw.slice(0,32), mac: raw.slice(32,64), key: cipher.key };
}catch(e){}
}
return { enc: user.enc, mac: user.mac, key: null };
}
async function encryptTextValue(v, enc, mac){
var s=String(v==null?'':v);
if(!s) return null;
return encryptBw(new TextEncoder().encode(s), enc, mac);
}
function openCreateDraft(){
state.detailMode='create';
state.showSelectedPassword=true;
state.createMenuOpen=false;
state.detailDraft={
id: '',
type: 1,
name: '',
folderId: state.folderFilterId&&state.folderFilterId!==NO_FOLDER_FILTER?state.folderFilterId:'',
reprompt: false,
loginUsername: '',
loginPassword: '',
loginTotp: '',
websites: [''],
cardholderName: '',
cardNumber: '',
cardBrand: '',
cardExpMonth: '',
cardExpYear: '',
cardCode: '',
identTitle: '',
identFirstName: '',
identMiddleName: '',
identLastName: '',
identUsername: '',
identCompany: '',
identSsn: '',
identPassportNumber: '',
identLicenseNumber: '',
identEmail: '',
identPhone: '',
identAddress1: '',
identAddress2: '',
identAddress3: '',
identCity: '',
identState: '',
identPostalCode: '',
identCountry: '',
sshPrivateKey: '',
sshPublicKey: '',
sshFingerprint: '',
customFields: [],
notes: ''
};
}
function openEditDraft(cipher){
if(!cipher) return;
var login=cipher.login||{};
var uris=Array.isArray(login.uris)?login.uris:[];
var ws=[]; for(var i=0;i'+l+''; }
return opt('text',t('fieldText'))+opt('hidden',t('fieldHidden'))+opt('boolean',t('fieldBoolean'))+opt('linked',t('fieldLinked'));
}
function renderCreateMenu(){
if(!state.createMenuOpen) return '';
return '';
}
function renderFieldModal(){
if(!state.fieldModalOpen) return '';
return ''
+ '
'+t('addField')+' ✕ '
+ '
'+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 renderLockedScreen(){
var email = String(state.profile && state.profile.email ? state.profile.email : state.session && state.session.email ? state.session.email : '');
return ''
+ ''
+ '
'+t('langSwitch')+'
'
+ '
'
+ ' '
+ renderMsg()
+ (state.unlockError?('
'+esc(state.unlockError)+'
'):'')
+ '
'
+ '
'
+ ' Log Out '
+ '
'
+ '
'
+ '
';
}
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 lockMins = Number(state.lockTimeoutMinutes)||0;
return ''
+ renderMsg()
+ ''+t('settings')+' '
+ ''
+ ''
+ ''
+ ''+t('totpSetup')+' QR loading...
Use secret key below
'+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:'')+' '
+ '
Lock '
+ '
'+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; }
if(state.phase==='locked'){ app.innerHTML=renderLockedScreen(); return; }
app.innerHTML=renderApp();
updateLiveTotpDisplay();
renderTotpSetupQr();
}
async function init(){
var url=new URL(window.location.href); state.inviteCode=(url.searchParams.get('invite')||'').trim(); state.session=loadSession();
loadLockSettings();
ensureTotpTicker();
ensureLockChannel();
ensureAutoLockTicker();
var st=await fetch('/setup/status'); var setup=await jsonOrNull(st); var registered=!!(setup&&setup.registered);
if(state.session){
try{ await loadProfile(); state.phase='locked'; state.tab='vault'; render(); return; } catch(e){ state.session=null; saveSession(); }
}
state.phase=registered?'login':'register'; render();
}
async function onRegister(form){
clearMsg();
var fd=new FormData(form); var name=String(fd.get('name')||'').trim(); var email=String(fd.get('email')||'').trim().toLowerCase(); var p=String(fd.get('password')||''); var p2=String(fd.get('password2')||''); var invite=String(fd.get('inviteCode')||'').trim();
state.registerName=name; state.registerEmail=email; state.registerPassword=p; state.registerPassword2=p2; state.inviteCode=invite;
if(!email||!p) return setMsg('Please input email and password.', 'err');
if(p.length<12) return setMsg('Master password must be at least 12 chars.', 'err');
if(p!==p2) return setMsg('Passwords do not match.', 'err');
try{
var it=defaultKdfIterations; var mk=await pbkdf2(p,email,it,32); var hash=await pbkdf2(mk,p,1,32); var ek=await hkdfExpand(mk,'enc',32); var em=await hkdfExpand(mk,'mac',32); var sym=crypto.getRandomValues(new Uint8Array(64)); var encKey=await encryptBw(sym,ek,em);
var kp=await crypto.subtle.generateKey({name:'RSA-OAEP', modulusLength:2048, publicExponent:new Uint8Array([1,0,1]), hash:'SHA-1'}, true, ['encrypt','decrypt']);
var pub=new Uint8Array(await crypto.subtle.exportKey('spki',kp.publicKey)); var prv=new Uint8Array(await crypto.subtle.exportKey('pkcs8',kp.privateKey)); var encPrv=await encryptBw(prv,sym.slice(0,32),sym.slice(32,64));
var resp=await fetch('/api/accounts/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,name:name,masterPasswordHash:bytesToBase64(hash),key:encKey,kdf:0,kdfIterations:it,inviteCode:invite||undefined,keys:{publicKey:bytesToBase64(pub),encryptedPrivateKey:encPrv}})});
var j=await jsonOrNull(resp); if(!resp.ok) return setMsg((j&&(j.error||j.error_description))||'Register failed.', 'err');
state.registerName=''; state.registerEmail=''; state.registerPassword=''; state.registerPassword2=''; state.inviteCode='';
state.phase='login'; state.loginEmail=email; state.loginPassword=''; setMsg('Registration succeeded. Please sign in.', 'ok');
}catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); }
}
async function onLoginPassword(form){
clearMsg();
var fd=new FormData(form); state.loginEmail=String(fd.get('email')||'').trim().toLowerCase(); state.loginPassword=String(fd.get('password')||'');
if(!state.loginEmail||!state.loginPassword) return setMsg('Please input email and password.', 'err');
try{
var d=await deriveLoginHash(state.loginEmail,state.loginPassword);
var body=new URLSearchParams(); body.set('grant_type','password'); body.set('username',state.loginEmail); body.set('password',d.hash); body.set('scope','api offline_access');
var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body.toString()});
var j=await jsonOrNull(resp);
if(!resp.ok){
if(j&&j.TwoFactorProviders){ state.pendingLogin={email:state.loginEmail,passwordHash:d.hash,masterKey:d.masterKey}; state.loginTotpToken=''; state.loginTotpError=''; clearMsg(); render(); return; }
return setMsg((j&&(j.error_description||j.error))||'Login failed.', 'err');
}
await onLoginSuccess(j,d.masterKey,state.loginEmail,state.loginPassword);
}catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); }
}
async function onLoginTotp(form){
if(!state.pendingLogin) return setMsg('TOTP flow is not ready.', 'err');
var fd=new FormData(form); state.loginTotpToken=String(fd.get('totpToken')||'').trim(); if(!state.loginTotpToken){ state.loginTotpError='Please input TOTP code.'; render(); return; }
var b=new URLSearchParams(); b.set('grant_type','password'); b.set('username',state.pendingLogin.email); b.set('password',state.pendingLogin.passwordHash); b.set('scope','api offline_access'); b.set('twoFactorProvider','0'); b.set('twoFactorToken',state.loginTotpToken);
var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:b.toString()});
var j=await jsonOrNull(resp); if(!resp.ok){ state.loginTotpError=(j&&(j.error_description||j.error))||'TOTP verification failed.'; render(); return; }
state.loginTotpError='';
await onLoginSuccess(j,state.pendingLogin.masterKey,state.pendingLogin.email,state.loginPassword);
}
async function onLoginSuccess(tokenJson, masterKey, email, password){
state.session={accessToken:tokenJson.access_token,refreshToken:tokenJson.refresh_token,email:email}; saveSession(); state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError='';
await loadProfile();
try{
var ek=await hkdfExpand(masterKey,'enc',32); var em=await hkdfExpand(masterKey,'mac',32);
var symKeyBytes=await decryptBw(state.profile.key,ek,em);
if(symKeyBytes){ state.session.symEncKey=bytesToBase64(symKeyBytes.slice(0,32)); state.session.symMacKey=bytesToBase64(symKeyBytes.slice(32,64)); }
}catch(e){ console.warn('Key derivation failed:',e); }
await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; state.lockLastActiveTs=Date.now();
setMsg('Login success.', 'ok');
}
async function onSaveLockSettings(form){
var fd=new FormData(form);
var mins=Number(fd.get('lockTimeoutMinutes')||0);
if(!Number.isFinite(mins)||mins<0) mins=15;
state.lockTimeoutMinutes=mins;
saveLockSettings();
state.lockLastActiveTs=Date.now();
setMsg('Lock settings saved.', 'ok');
}
async function onSaveProfile(form){ var fd=new FormData(form); var n=String(fd.get('name')||'').trim(); var em=String(fd.get('email')||'').trim().toLowerCase(); var r=await authFetch('/api/accounts/profile',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n,email:em})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Save profile failed.', 'err'); state.profile=j; render(); setMsg('Profile updated.', 'ok'); }
async function onChangePassword(form){
var fd=new FormData(form);
var currentPassword=String(fd.get('currentPassword')||'');
var newPassword=String(fd.get('newPassword')||'');
var newPassword2=String(fd.get('newPassword2')||'');
if(!currentPassword||!newPassword) return setMsg('Current/new password is required.', 'err');
if(newPassword.length<12) return setMsg('New master password must be at least 12 chars.', 'err');
if(newPassword!==newPassword2) return setMsg('New passwords do not match.', 'err');
if(newPassword===currentPassword) return setMsg('New password must be different.', 'err');
var email=String(state.profile&&state.profile.email?state.profile.email:'').toLowerCase();
if(!email) return setMsg('Profile email missing.', 'err');
try{
var current=await deriveLoginHash(email,currentPassword);
var userSym=buildSymmetricKeyBytes();
if(!userSym){
var oldEk=await hkdfExpand(current.masterKey,'enc',32);
var oldEm=await hkdfExpand(current.masterKey,'mac',32);
userSym=await decryptBw(state.profile.key,oldEk,oldEm);
}
if(!userSym||userSym.length<64) return setMsg('Unable to load vault key for password rotation.', 'err');
var nextMasterKey=await pbkdf2(newPassword,email,current.kdfIterations,32);
var nextHash=await pbkdf2(nextMasterKey,newPassword,1,32);
var nextEk=await hkdfExpand(nextMasterKey,'enc',32);
var nextEm=await hkdfExpand(nextMasterKey,'mac',32);
var newKey=await encryptBw(userSym.slice(0,64),nextEk,nextEm);
var r=await authFetch('/api/accounts/password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({currentPasswordHash:current.hash,newMasterPasswordHash:bytesToBase64(nextHash),newKey:newKey,kdf:0,kdfIterations:current.kdfIterations})});
var j=await jsonOrNull(r);
if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Change master password failed.', 'err');
logout();
setMsg('Master password changed. Please log in again.', 'ok');
}catch(e){
setMsg('Change master password failed: '+(e&&e.message?e.message:String(e)), 'err');
}
}
async function onEnableTotp(form){ var fd=new FormData(form); state.totpSetupSecret=String(fd.get('secret')||'').toUpperCase().replace(/[\s-]/g,'').replace(/=+$/g,''); state.totpSetupToken=String(fd.get('token')||'').trim(); if(!state.totpSetupSecret) return setMsg('TOTP secret is required.', 'err'); if(!state.totpSetupToken) return setMsg('TOTP token is required.', 'err'); var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,secret:state.totpSetupSecret,token:state.totpSetupToken})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Enable TOTP failed.', 'err'); state.totpSetupToken=''; render(); setMsg('TOTP enabled.', 'ok'); }
function onDisableTotp(){ state.totpDisableOpen=true; state.totpDisablePassword=''; state.totpDisableError=''; render(); }
async function onDisableTotpSubmit(form){
var fd=new FormData(form); state.totpDisablePassword=String(fd.get('masterPassword')||'');
if(!state.totpDisablePassword){ state.totpDisableError='Please input master password.'; render(); return; }
try{
var d=await deriveLoginHash(state.profile.email,state.totpDisablePassword);
var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:false,masterPasswordHash:d.hash})});
var j=await jsonOrNull(r);
if(!r.ok){ state.totpDisableError=(j&&(j.error||j.error_description))||'Disable TOTP failed.'; render(); return; }
state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError='';
render(); setMsg('TOTP disabled.', 'ok');
}catch(e){
state.totpDisableError='Disable TOTP failed: '+(e&&e.message?e.message:String(e));
render();
}
}
async function onBulkDelete(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); 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==='unlockForm') return void onUnlock(form);
if(form.id==='lockForm') return void onSaveLockSettings(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==='lock'){ lockVault(true, true); 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