diff --git a/app/static/css/app.css b/app/static/css/app.css
index 7fd5ed0..d7df05f 100644
--- a/app/static/css/app.css
+++ b/app/static/css/app.css
@@ -2123,3 +2123,88 @@ html.sidebar-open {
color: #dc2626;
border: 1px solid #fecaca;
}
+
+/* ── Collapsible vault folder groups ───────────────────────────────────────── */
+
+.vault-group-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+ user-select: none;
+ transition: color 0.12s;
+}
+
+.vault-group-header:hover { color: var(--color-primary); }
+
+.group-name { flex: 1; }
+
+.group-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+}
+
+.group-count {
+ background: #e5e7eb;
+ color: #374151;
+ border-radius: 999px;
+ padding: 1px 7px;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.group-chevron {
+ font-size: 10px;
+ color: var(--color-text-muted);
+ width: 14px;
+ text-align: center;
+ transition: transform 0.15s;
+}
+
+.vault-group-header.collapsed .group-chevron { color: var(--color-primary); }
+
+/* ── Vault item tags ────────────────────────────────────────────────────────── */
+
+.item-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 3px;
+}
+
+.item-tag {
+ display: inline-block;
+ background: #ede9fe;
+ color: #6d28d9;
+ font-size: 10px;
+ font-weight: 600;
+ padding: 1px 6px;
+ border-radius: 999px;
+ letter-spacing: 0.2px;
+}
+
+.tag-preview {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 6px;
+ min-height: 20px;
+}
+
+/* ── Sidebar tags ────────────────────────────────────────────────────────────── */
+
+.sidebar-tag-list {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 4px;
+}
+
+.sidebar-tag-empty {
+ font-size: 12px;
+ color: var(--color-text-muted);
+ padding: 4px 16px 8px;
+}
+
+.sidebar-tag-item .sidebar-icon { font-size: 14px; }
diff --git a/app/static/js/vault.js b/app/static/js/vault.js
index 4385dcc..c003680 100644
--- a/app/static/js/vault.js
+++ b/app/static/js/vault.js
@@ -211,6 +211,7 @@ const Vault = (() => {
);
renderFolderList();
+ renderTagList();
applyCurrentFilter();
} catch (err) {
showToast("Failed to load vault: " + err.message, "error");
@@ -326,6 +327,50 @@ const Vault = (() => {
return _folders.find((f) => f.id === id)?.name || "Unknown";
}
+ /** Parse a comma-separated tag string into a sorted, deduped lowercase array. */
+ function _parseTags(str) {
+ return [...new Set(
+ (str || '').split(',').map(t => t.trim().toLowerCase()).filter(Boolean)
+ )].sort();
+ }
+
+ /** Collect all unique tags across all loaded items. */
+ function _allTags() {
+ const set = new Set();
+ _items.forEach(i => (i.plain?.tags || []).forEach(t => set.add(t)));
+ return [...set].sort();
+ }
+
+ /** Render the tags sidebar list. */
+ function renderTagList() {
+ const ul = document.getElementById('sidebar-tags');
+ if (!ul) return;
+ ul.innerHTML = '';
+ const tags = _allTags();
+ if (!tags.length) {
+ ul.innerHTML = '
';
+ return;
+ }
+ tags.forEach(tag => {
+ const li = document.createElement('li');
+ li.className = 'sidebar-item sidebar-tag-item';
+ li.dataset.tag = tag;
+ li.innerHTML = ``;
+ li.addEventListener('click', () => {
+ document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active'));
+ li.classList.add('active');
+ _activeFilter = { type: 'tag', value: tag };
+ switchView('vault');
+ updateVaultTitle('#' + tag);
+ renderItemList(_items.filter(i => (i.plain?.tags || []).includes(tag)));
+ });
+ ul.appendChild(li);
+ });
+ }
+
+ // Persists which folder groups are collapsed across re-renders.
+ const _collapsedGroups = new Set();
+
function renderItemList(items) {
const list = document.getElementById("vault-list");
if (!list) return;
@@ -356,13 +401,34 @@ const Vault = (() => {
keys.forEach((groupName) => {
if (!groups[groupName]) return;
+ const isCollapsed = _collapsedGroups.has(groupName);
+ const count = groups[groupName].length;
+
+ // ── Collapsible group header ────────────────────────────────────────────
const header = document.createElement("li");
- header.className = "vault-group-header";
- header.textContent = groupName;
+ header.className = "vault-group-header" + (isCollapsed ? " collapsed" : "");
+ header.innerHTML =
+ `${escHtml(groupName)}` +
+ `` +
+ `${count}` +
+ `${isCollapsed ? '▶' : '▼'}` +
+ ``;
+ header.addEventListener("click", () => {
+ if (_collapsedGroups.has(groupName)) {
+ _collapsedGroups.delete(groupName);
+ } else {
+ _collapsedGroups.add(groupName);
+ }
+ renderItemList(items); // re-render preserving collapse state
+ });
list.appendChild(header);
- groups[groupName].forEach((item) =>
- list.appendChild(createItemElement(item)),
- );
+
+ // ── Items (hidden when collapsed) ───────────────────────────────────────
+ if (!isCollapsed) {
+ groups[groupName].forEach((item) =>
+ list.appendChild(createItemElement(item)),
+ );
+ }
});
}
@@ -382,6 +448,7 @@ const Vault = (() => {
};
const icon = iconMap[item.item_type] || "🔑";
+ const itemTags = item.plain?.tags || [];
let subText = "";
if (item.plain) {
switch (item.item_type) {
@@ -420,11 +487,16 @@ const Vault = (() => {
item.item_type === "password" &&
!!extractTotpSecret(item.plain?.totp_uri);
+ const tagBadges = itemTags.map(t =>
+ `${escHtml(t)}`
+ ).join('');
+
li.innerHTML = `
${icon}
${escHtml(item.name)}
${escHtml(subText)}
+ ${tagBadges ? `
${tagBadges}
` : ''}
${showTotp ? `
······` : ""}
@@ -2375,6 +2447,8 @@ const Vault = (() => {
renderItemList(_items.filter((i) => i.item_type === _activeFilter.value));
else if (_activeFilter.type === "folder")
renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value));
+ else if (_activeFilter.type === "tag")
+ renderItemList(_items.filter((i) => (i.plain?.tags || []).includes(_activeFilter.value)));
}
function filterByFolder(folderId, name) {
@@ -2476,6 +2550,7 @@ const Vault = (() => {
setVal("field-password", p.password);
setVal("field-totp-uri", p.totp_uri || "");
setVal("field-notes", p.notes);
+ setVal("field-tags", (p.tags || []).join(', '));
break;
case "note":
setVal("field-note-body", p.note_body);
@@ -2551,6 +2626,7 @@ const Vault = (() => {
password: getVal("field-password"),
totp_uri: getVal("field-totp-uri").trim(),
notes: getVal("field-notes").trim(),
+ tags: _parseTags(getVal("field-tags")),
};
case "note":
return { note_body: getVal("field-note-body") };
@@ -2959,6 +3035,14 @@ const Vault = (() => {
el.type = el.type === "password" ? "text" : "password";
});
+ // Live tag preview in the item modal.
+ document.getElementById("field-tags")?.addEventListener("input", (e) => {
+ const preview = document.getElementById("field-tags-preview");
+ if (!preview) return;
+ const tags = _parseTags(e.target.value);
+ preview.innerHTML = tags.map(t => `${escHtml(t)}`).join('');
+ });
+
// Sidebar navigation
document.getElementById("sidebar-all")?.addEventListener("click", () => {
_activeFilter = null;
diff --git a/app/templates/vault/index.html b/app/templates/vault/index.html
index a052ea1..cf1abd0 100644
--- a/app/templates/vault/index.html
+++ b/app/templates/vault/index.html
@@ -140,6 +140,8 @@
+
+
+
+
diff --git a/extension/popup/popup.js b/extension/popup/popup.js
index 0686519..891cf75 100644
--- a/extension/popup/popup.js
+++ b/extension/popup/popup.js
@@ -19,9 +19,11 @@ const ALERTS_URL = 'https://pwkeeper.ngodanguyen.tech/vault#security';
let _vaultKey = null;
let _items = [];
+let _folders = []; // fetched alongside vault items for folder grouping
let _mfaToken = null;
let _currentUrl = '';
let _activeTab = 'relevant';
+const _collapsedFolders = new Set(); // persists collapsed state across re-renders
// ── DOM helpers ───────────────────────────────────────────────────────────────
@@ -91,6 +93,11 @@ function siteLabel(item) {
return item.name;
}
+function folderName(id) {
+ if (!id) return '(none)';
+ return _folders.find(f => f.id === id)?.name || '(none)';
+}
+
// ── Domain matching ───────────────────────────────────────────────────────────
function currentHostname() {
@@ -332,9 +339,15 @@ async function restoreSessionIfAvailable() {
async function fetchAndDecryptVault() {
$('vault-spinner').classList.remove('hidden');
try {
- const res = await apiFetch('/api/vault');
+ const [res, foldersRes] = await Promise.all([
+ apiFetch('/api/vault'),
+ apiFetch('/api/folders'),
+ ]);
if (!res) return;
const raw = await res.json();
+ try {
+ if (foldersRes?.ok) _folders = await foldersRes.json();
+ } catch (e) { _folders = []; }
_items = await Promise.all(raw.map(async item => {
try {
@@ -459,6 +472,9 @@ function getTabItems() {
items = items.filter(isMatch).sort((a, b) => a.name.localeCompare(b.name));
} else if (_activeTab === 'recents') {
items = [...items].sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at)).slice(0, 20);
+ } else if (_activeTab === 'favorites') {
+ items = items.filter(i => (i.plain?.tags || []).includes('favorite'))
+ .sort((a, b) => a.name.localeCompare(b.name));
} else {
items = [...items].sort((a, b) => a.name.localeCompare(b.name));
}
@@ -496,7 +512,7 @@ function renderList() {
const svgFill = `
`;
const svgTotp = `
`;
- listEl.innerHTML = items.map(item => {
+ function buildItemHtml(item) {
const matched = isMatch(item);
const site = escHtml(siteLabel(item));
const name = escHtml(item.name);
@@ -506,12 +522,15 @@ function renderList() {
const canFill = item.item_type === 'password' && item.plain?.username && item.plain?.password;
const canCopy = item.item_type === 'password' && item.plain?.password;
const hasTotp = item.item_type === 'password' && !!_extractTotpSecret(item.plain?.totp_uri);
-
+ const tags = (item.plain?.tags || []).filter(t => t !== 'favorite');
+ const tagHtml = tags.map(t => `
${escHtml(t)}`).join('');
+ const isFav = (item.plain?.tags || []).includes('favorite');
return `
${emoji}
-
${site}${badge}
+
${site}${badge}${isFav ? '★' : ''}
${name}
+ ${tagHtml ? `
${tagHtml}
` : ''}
${hasTotp ? `
······
` : ''}
@@ -522,7 +541,49 @@ function renderList() {
`;
- }).join('');
+ }
+
+ // Build the vault list — grouped by folder for 'all' tab, flat otherwise.
+ if (_activeTab === 'all' && _folders.length > 0) {
+ const groups = {};
+ items.forEach(item => {
+ const key = folderName(item.folder_id);
+ if (!groups[key]) groups[key] = [];
+ groups[key].push(item);
+ });
+ const keys = ['(none)', ..._folders.map(f => f.name)].filter(k => groups[k]);
+ Object.keys(groups).forEach(k => { if (!keys.includes(k)) keys.push(k); });
+
+ listEl.innerHTML = keys.map(groupName => {
+ if (!groups[groupName]) return '';
+ const isCollapsed = _collapsedFolders.has(groupName);
+ const count = groups[groupName].length;
+ const chevron = isCollapsed ? '›' : '⌄';
+ const itemsHtml = isCollapsed ? '' : groups[groupName].map(buildItemHtml).join('');
+ return `
`;
+ }).join('');
+
+ // Wire collapse toggle on group headers.
+ listEl.querySelectorAll('.pk-group-header').forEach(hdr => {
+ hdr.addEventListener('click', () => {
+ const groupName = hdr.closest('.pk-group').dataset.group;
+ if (_collapsedFolders.has(groupName)) _collapsedFolders.delete(groupName);
+ else _collapsedFolders.add(groupName);
+ renderList();
+ });
+ });
+ } else {
+ listEl.innerHTML = items.map(buildItemHtml).join('');
+ }
// Start TOTP tickers for items that have a totp_uri.
items.forEach(item => {