Passwords tab: unlock your Vaultwarden vault through the Bitwarden CLI, search, view and copy entries
build-container / build (push) Successful in 5m34s
build-container / build (push) Successful in 5m34s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d9d3743816
commit
af939869a7
9 files changed
+223
-9
No files matched your search
+7
-6
@@ -109,6 +109,7 @@ const ICONS = {
|
||||
book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M19 19v2H6a2 2 0 01-2-2"/><path d="M9 7h6"/>',
|
||||
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0014 0M12 18v3M9 21h6"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
|
||||
link: '<path d="M10 14a4 4 0 005.7 0l3-3a4 4 0 00-5.7-5.7l-1 1M14 10a4 4 0 00-5.7 0l-3 3a4 4 0 005.7 5.7l1-1"/>',
|
||||
check: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>',
|
||||
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
||||
@@ -173,8 +174,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
|
||||
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
// ---- Navigation
|
||||
const TABS = [['home', 'Home'], ['family', 'Family'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['chores', 'Chores'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
||||
const TABS = [['home', 'Home'], ['family', 'Family'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['chores', 'Chores'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['vault', 'Passwords'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', vault: 'key', planner: 'cal' };
|
||||
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */
|
||||
const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id));
|
||||
const sgInChat = () => allowed('chat') && allowed('signal'); // with both, Signal conversations are in the Chat list and there is one entry, not two
|
||||
@@ -186,14 +187,14 @@ const navTabs = () => { const out = [], done = new Set(); for (const t of TABS)
|
||||
const leafTabs = () => TABS.filter(t => t[0] !== 'verse' && t[0] !== 'home' && allowed(t[0]));
|
||||
const tabLabel = id => (TABS.find(x => x[0] === id) || (MERGED[id] ? [0, MERGED[id][0]] : id === 'planner' ? [0, 'Planner'] : [0, '']))[1];
|
||||
/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
|
||||
const NAV_GROUPS = [['', ['home', 'family', 'search']], ['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]];
|
||||
const NAV_GROUPS = [['', ['home', 'family', 'search']], ['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'vault', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]];
|
||||
const navGroups = (tabs = navTabs()) => {
|
||||
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
|
||||
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
|
||||
return out.filter(g => g.tabs.length);
|
||||
};
|
||||
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
|
||||
const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', family: '#d9578a', notes: '#e5b800', chores: '#2f9e6e', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
|
||||
const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', vault: '#1f6fb2', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', family: '#d9578a', notes: '#e5b800', chores: '#2f9e6e', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
|
||||
// The phone's bottom bar shows up to five entries you choose (Edit bottom bar, in the Apps sheet), then "More".
|
||||
const DEFAULT_PINS = ['home', 'files', 'planner', 'listen', 'mail'];
|
||||
const navPins = () => { const have = new Set(navTabs().map(t => t[0])), saved = store.get('nav_pins'); const out = (Array.isArray(saved) && saved.length ? saved : DEFAULT_PINS).filter(id => have.has(id)).slice(0, 5); return out.length ? out : [...have].slice(0, 4); };
|
||||
@@ -265,7 +266,7 @@ function drawSubnav(name) {
|
||||
const navIdOf = name => name === 'signal' && sgInChat() ? 'chat' : name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
|
||||
let curRoute = '';
|
||||
|
||||
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
||||
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, vault: viewVault, settings: viewSettings, edit: viewEdit });
|
||||
let editor;
|
||||
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
||||
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
|
||||
@@ -347,7 +348,7 @@ function homeApps() {
|
||||
mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
|
||||
};
|
||||
T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible');
|
||||
const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
|
||||
const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'vault', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
|
||||
return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id) && !(id === 'signal' && sgInChat()))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<script defer src="/recipes-view.js"></script>
|
||||
<script defer src="/pantry-view.js"></script>
|
||||
<script defer src="/contacts-view.js"></script>
|
||||
<script defer src="/vault-view.js"></script>
|
||||
<script defer src="/bible-view.js"></script>
|
||||
<script defer src="/devotion-view.js"></script>
|
||||
<script defer src="/save-to-files.js"></script>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
// ---- Passwords: your Vaultwarden vault. The vault stays end-to-end encrypted; unlocking asks for your master password, which goes to the server's
|
||||
// Bitwarden tool only to open it for a few idle minutes. Lists never carry passwords: a password, code or note is fetched only when you open that entry.
|
||||
const vtReq = async (p, body) => {
|
||||
const r = await fetch('/api/vault' + p, body === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), j, { status: r.status });
|
||||
return j;
|
||||
};
|
||||
const vtState = { q: '', folder: '', items: null, folders: [] };
|
||||
const vtType = { 1: 'Login', 2: 'Note', 3: 'Card', 4: 'Identity' };
|
||||
let vtClip = 0;
|
||||
/** Copy something private, and clear the clipboard again after half a minute if it is still ours. */
|
||||
async function vtCopy(text, what) {
|
||||
try { await navigator.clipboard.writeText(text); toast(what + ' copied (cleared in 30 seconds)'); clearTimeout(vtClip); vtClip = setTimeout(async () => { try { if ((await navigator.clipboard.readText()) === text) await navigator.clipboard.writeText(''); } catch { try { await navigator.clipboard.writeText(''); } catch {} } }, 30000); }
|
||||
catch { toast('Could not copy'); }
|
||||
}
|
||||
const vtHost = u => { try { return new URL(/^[a-z]+:\/\//i.test(u) ? u : 'https://' + u).hostname.replace(/^www\./, ''); } catch { return ''; } };
|
||||
const vtSafeUrl = u => { try { const x = new URL(/^[a-z]+:\/\//i.test(u) ? u : 'https://' + u); return /^https?:$/.test(x.protocol) ? x.href : ''; } catch { return ''; } };
|
||||
|
||||
async function viewVault() {
|
||||
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Passwords</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||
let st; try { st = await vtReq('/status'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Passwords</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
if (!location.hash.startsWith('#/vault')) return;
|
||||
st.unlocked ? vtList(st) : vtUnlock(st);
|
||||
}
|
||||
|
||||
function vtUnlock(st, need2fa) {
|
||||
const v = $('#view'), host = vtHost((typeof cfg !== 'undefined' && cfg.vault) || st.url);
|
||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Passwords</h1></div>
|
||||
<form id="vtf" class="card" style="max-width:420px;display:grid;gap:10px" autocomplete="off">
|
||||
<p class="sub" style="margin:0">Unlock your vault on <b>${esc(host)}</b>. Your master password is only used to open it and is never saved.</p>
|
||||
${st.loggedIn ? `<p class="sub" style="margin:0">Signed in as <b>${esc(st.email)}</b></p>` : '<label for="vte">Email address</label><input type="email" id="vte" autocomplete="username" autocapitalize="off" required>'}
|
||||
<label for="vtp">Master password</label><input type="password" id="vtp" autocomplete="current-password" required>
|
||||
${need2fa ? '<label for="vtc">Authenticator code</label><input type="text" id="vtc" inputmode="numeric" autocomplete="one-time-code" maxlength="8" required>' : ''}
|
||||
<p class="err" id="vterr" role="alert"></p>
|
||||
<div class="bar" style="margin:0"><button class="btn" id="vtgo">Unlock</button>${st.loggedIn ? '<button type="button" class="btn ghost" id="vtout">Use a different account</button>' : ''}</div>
|
||||
<p class="sub" style="margin:0">No account yet? <a href="${esc(vtSafeUrl(st.url) ? st.url.replace(/\/+$/, '') + '/#/signup' : '#')}" target="_blank" rel="noopener" style="color:var(--accent)">Create one on ${esc(host)}</a>, then come back here to unlock it.</p>
|
||||
</form></div>`;
|
||||
const f = $('#vtf'); setTimeout(() => (f.querySelector(need2fa ? '#vtc' : st.loggedIn ? '#vtp' : '#vte') || {}).focus?.(), 0);
|
||||
if ($('#vtout')) $('#vtout').onclick = () => confirmSheet('Use a different account?', 'This signs the hub out of the vault. Nothing is deleted from the vault itself.', 'Sign out of vault', async () => { try { await vtReq('/forget', {}); viewVault(); } catch (e) { toast(e.message); } });
|
||||
f.onsubmit = async e => {
|
||||
e.preventDefault(); $('#vterr').textContent = ''; $('#vtgo').disabled = true;
|
||||
try { const body = { password: $('#vtp').value }; if ($('#vte')) body.email = $('#vte').value; if ($('#vtc')) body.code = $('#vtc').value; const r = await vtReq('/unlock', body); $('#vtp').value = ''; vtState.items = null; vtList(r); }
|
||||
catch (err) { if (err.need2fa) return vtUnlock({ ...st, email: st.email || ($('#vte') && $('#vte').value) }, true) || void 0; $('#vterr').textContent = err.message; $('#vtgo').disabled = false; }
|
||||
};
|
||||
}
|
||||
|
||||
async function vtList(st) {
|
||||
const v = $('#view');
|
||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Passwords</h1><span class="pspace grow"></span><button class="btn ghost" id="vtsync" aria-label="Refresh from the server" title="Refresh">${icon('refresh')}</button><button class="btn ghost" id="vtlock">Lock</button></div>
|
||||
<input type="search" id="vtq" placeholder="Search your vault" value="${esc(vtState.q)}" autocomplete="off" aria-label="Search your vault" style="width:100%;margin-bottom:8px"><div id="vtfo"></div><div id="vtl"><p class="empty loading" role="status">Loading…</p></div>
|
||||
<p class="sub">Locks itself after a few idle minutes. <a href="${esc(vtSafeUrl(st.url) || '#')}" target="_blank" rel="noopener" style="color:var(--accent)">Open the full vault</a> to add or change entries.</p></div>`;
|
||||
const lockedOut = () => { vtState.items = null; vtUnlock({ ...st, unlocked: false }); };
|
||||
$('#vtlock').onclick = async () => { try { await vtReq('/lock', {}); } catch {} lockedOut(); toast('Vault locked'); };
|
||||
$('#vtsync').onclick = async () => { try { await vtReq('/sync', {}); await load(); toast('Refreshed'); } catch (e) { e.locked ? lockedOut() : toast(e.message); } };
|
||||
let timer; $('#vtq').oninput = e => { vtState.q = e.target.value; clearTimeout(timer); timer = setTimeout(load, 250); };
|
||||
async function load() {
|
||||
try { const r = await vtReq('/items?q=' + encodeURIComponent(vtState.q.trim())); if (!location.hash.startsWith('#/vault')) return; vtState.items = r.items; vtState.folders = r.folders; draw(); }
|
||||
catch (e) { if (e.locked) return lockedOut(); $('#vtl').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||
}
|
||||
function draw() {
|
||||
const fo = vtState.folders; $('#vtfo').innerHTML = fo.length ? `<div class="seg" role="tablist"><a href="#" data-f="" class="${vtState.folder ? '' : 'on'}">All</a>${fo.map(f => `<a href="#" data-f="${esc(f.id)}" class="${vtState.folder === f.id ? 'on' : ''}">${esc(f.name)}</a>`).join('')}</div>` : '';
|
||||
$('#vtfo').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); vtState.folder = a.dataset.f; draw(); });
|
||||
const rows = vtState.items.filter(i => !vtState.folder || i.folderId === vtState.folder);
|
||||
$('#vtl').innerHTML = rows.length ? `<div class="list">${rows.map(i => `<button class="row" data-id="${esc(i.id)}"><span class="ic" style="background:${i.type === 1 ? '#1f6fb2' : i.type === 3 ? '#b25a1f' : '#6a7280'}">${icon(i.type === 1 ? 'key' : i.type === 3 ? 'store' : 'doc')}</span><span class="grow"><b>${esc(i.name)}</b><small>${esc(i.username || vtHost(i.uri) || vtType[i.type] || '')}</small></span>${i.favorite ? '<span aria-label="Favorite">★</span>' : ''}</button>`).join('')}</div>` : `<p class="empty">${vtState.q ? 'Nothing matches.' : 'Your vault is empty.'}</p>`;
|
||||
$('#vtl').querySelectorAll('.row').forEach(b => b.onclick = () => vtOpen(b.dataset.id, lockedOut));
|
||||
}
|
||||
if (vtState.items) draw(); load();
|
||||
}
|
||||
|
||||
async function vtOpen(id, lockedOut) {
|
||||
let it; try { it = await vtReq('/item/' + encodeURIComponent(id)); } catch (e) { return e.locked ? lockedOut() : toast(e.message); }
|
||||
const field = (label, value, key, secret) => value ? `<div class="vtf" style="margin:10px 0"><small style="color:var(--muted)">${esc(label)}</small><div style="display:flex;gap:8px;align-items:center"><span class="grow vtv" data-k="${key}" style="word-break:break-all;font-family:${secret ? 'ui-monospace,monospace' : 'inherit'}">${secret ? '••••••••••' : esc(value)}</span>${secret ? `<button class="btn ghost vtshow" data-k="${key}">Show</button>` : ''}<button class="btn ghost vtcp" data-k="${key}" data-w="${esc(label)}">Copy</button></div></div>` : '';
|
||||
const vals = { user: it.username, pass: it.password, totp: it.totp };
|
||||
const extra = []; if (it.card) { vals.cnum = it.card.number; vals.ccode = it.card.code; }
|
||||
(it.fields || []).forEach((f, n) => { vals['f' + n] = f.value; });
|
||||
const links = it.uris.map(vtSafeUrl).filter(Boolean);
|
||||
const el = openSheet(it.name || 'Entry', vtType[it.type] || '', `${field('Username', it.username, 'user')}${field('Password', it.password, 'pass', true)}${field('One-time code', it.totp, 'totp')}
|
||||
${it.card ? field('Cardholder', it.card.holder, 'chold') + field('Card number', it.card.number, 'cnum', true) + field('Security code', it.card.code, 'ccode', true) + field('Expires', [it.card.expMonth, it.card.expYear].filter(Boolean).join('/'), 'cexp') : ''}
|
||||
${(it.fields || []).map((f, n) => field(f.name || 'Field', f.value, 'f' + n, f.hidden)).join('')}
|
||||
${links.map(u => `<div class="vtf" style="margin:10px 0"><small style="color:var(--muted)">Website</small><div><a href="${esc(u)}" target="_blank" rel="noopener" style="color:var(--accent);word-break:break-all">${esc(u)}</a></div></div>`).join('')}
|
||||
${it.notes ? `<div class="vtf" style="margin:10px 0"><small style="color:var(--muted)">Notes</small><div style="white-space:pre-wrap">${esc(it.notes)}</div></div>` : ''}`);
|
||||
Object.assign(vals, { chold: it.card && it.card.holder, cexp: it.card && [it.card.expMonth, it.card.expYear].filter(Boolean).join('/') });
|
||||
el.querySelectorAll('.vtcp').forEach(b => b.onclick = () => vtCopy(vals[b.dataset.k], b.dataset.w));
|
||||
el.querySelectorAll('.vtshow').forEach(b => b.onclick = () => { const s = el.querySelector(`.vtv[data-k="${b.dataset.k}"]`), on = b.textContent === 'Show'; s.textContent = on ? vals[b.dataset.k] : '••••••••••'; b.textContent = on ? 'Hide' : 'Show'; });
|
||||
}
|
||||
Reference in new issue
Block a user