05/29 Update shared items displayed on Grantee items

This commit is contained in:
2026-05-29 13:29:59 -04:00
parent cd2992a193
commit 450b236e26
4 changed files with 245 additions and 14 deletions
+204 -14
View File
@@ -8,6 +8,7 @@
const Vault = (() => {
let _items = [];
let _folders = [];
let _sharedItems = []; // Accepted shared items from other users, decrypted
let _currentView = "vault";
let _vaultLoaded = false;
@@ -285,6 +286,9 @@ const Vault = (() => {
// blocking the vault render. Results are cached so opening the Security
// tab doesn't re-run HIBP checks.
runBackgroundHealthCheck();
// Load accepted shared items asynchronously so own items render first,
// then re-render once shared items are decrypted.
loadSharedItemsForVault().then(() => applyCurrentFilter());
} catch (err) {
showToast("Failed to load vault: " + err.message, "error");
} finally {
@@ -303,6 +307,120 @@ const Vault = (() => {
}
}
// ── Sharing session + shared-item vault integration ───────────────────────
/**
* Load the user's ECDH private key into SharingSession if not already loaded.
* Called before any operation that needs to decrypt shared items.
*/
async function ensureSharingSession() {
if (SharingSession.isReady()) return true;
const vaultKey = VaultSession.getKey();
if (!vaultKey) return false;
try {
const res = await apiFetch("/api/sharing/keys");
if (!res) return false;
const data = await res.json();
if (!data.keys_setup) return false;
const privKey = await SharingCrypto.decryptPrivateKey(
vaultKey,
data.private_key_enc,
data.private_key_iv,
);
SharingSession.setKey(privKey);
return true;
} catch {
return false;
}
}
/**
* Fetch and decrypt all accepted shared items, populating _sharedItems.
* Runs asynchronously after the vault loads so own items appear first.
*/
async function loadSharedItemsForVault() {
const ready = await ensureSharingSession();
if (!ready) { _sharedItems = []; return; }
try {
const res = await apiFetch("/api/sharing/inbox");
if (!res) { _sharedItems = []; return; }
const inbox = await res.json();
const accepted = inbox.filter((s) => s.accepted && s.owner_public_key);
const results = await Promise.all(
accepted.map(async (s) => {
try {
const ownerPub = await SharingCrypto.importPublicKey(s.owner_public_key);
const sharedKey = await SharingCrypto.deriveSharedKey(
SharingSession.getKey(),
ownerPub,
);
const plain = await SharingCrypto.decryptShare(sharedKey, s.enc_data, s.iv);
let displayName = s.item_name;
if (s.enc_name && s.iv_name) {
const dec = await SharingCrypto.decryptName(sharedKey, s.enc_name, s.iv_name);
if (dec) displayName = dec;
}
return {
id: `shared-${s.id}`,
_shareId: s.id,
_sharedFrom: s.owner_email,
_shared: true,
item_type: s.item_type,
name: displayName,
folder_id: null,
created_at: s.created_at,
enc_data: s.enc_data,
iv: s.iv,
plain,
};
} catch {
return null;
}
}),
);
_sharedItems = results.filter(Boolean);
} catch (err) {
console.error("[PassKeeper] loadSharedItemsForVault:", err);
_sharedItems = [];
}
}
/**
* After editing a vault item, re-encrypt its data for every accepted share
* so recipients always see the latest version.
* Returns an array suitable for the shared_updates field of PUT /api/vault/<id>.
*/
async function buildSharedUpdates(itemId, plainData, itemName) {
if (!SharingSession.isReady()) return [];
try {
const res = await apiFetch("/api/sharing");
if (!res) return [];
const outgoing = await res.json();
const mine = outgoing.filter(
(s) => s.item_id === parseInt(itemId) && s.accepted && s.recipient_public_key,
);
if (!mine.length) return [];
return Promise.all(
mine.map(async (s) => {
const recipPub = await SharingCrypto.importPublicKey(s.recipient_public_key);
const sharedKey = await SharingCrypto.deriveSharedKey(
SharingSession.getKey(),
recipPub,
);
const { enc_data, iv } = await SharingCrypto.encryptForShare(sharedKey, plainData);
const { enc_name, iv_name } = await SharingCrypto.encryptName(sharedKey, itemName);
return { share_id: s.id, enc_data, iv, enc_name, iv_name };
}),
);
} catch (err) {
console.error("[PassKeeper] buildSharedUpdates:", err);
return [];
}
}
// ── View switching ────────────────────────────────────────────────────────
function switchView(view, { pushState = true } = {}) {
@@ -470,10 +588,11 @@ const Vault = (() => {
return;
}
const SHARED_GROUP = "🔗 Shared with me";
const sorted = getSortedItems(items);
const groups = {};
sorted.forEach((item) => {
const key = folderName(item.folder_id);
const key = item._shared ? SHARED_GROUP : folderName(item.folder_id);
if (!groups[key]) groups[key] = [];
groups[key].push(item);
});
@@ -481,7 +600,7 @@ const Vault = (() => {
const keys =
_sortOrder === "folder"
? Object.keys(groups).sort()
: ["(No folder)", ..._folders.map((f) => f.name)].filter(
: ["(No folder)", ..._folders.map((f) => f.name), SHARED_GROUP].filter(
(k) => groups[k],
);
Object.keys(groups).forEach((k) => {
@@ -540,7 +659,77 @@ const Vault = (() => {
return name.slice(0, 2).toUpperCase();
}
function _createSharedItemElement(item) {
const li = document.createElement("li");
li.className = "vault-item vault-item--shared";
li.dataset.id = item.id;
const p = item.plain || {};
let subText = "";
switch (item.item_type) {
case "password": subText = p.username || p.url || ""; break;
case "note": subText = (p.note_body || "").slice(0, 60); break;
case "card": subText = p.card_number ? "•••• " + String(p.card_number).replace(/\s/g, "").slice(-4) : ""; break;
case "bank": subText = p.bank_name || ""; break;
case "address": subText = [p.first_name, p.last_name].filter(Boolean).join(" "); break;
case "ssn": subText = "•••-••-••••"; break;
case "passkey": subText = [p.username, p.rp_id ? `@ ${p.rp_id}` : ""].filter(Boolean).join(" "); break;
}
const showCopyUser = item.item_type === "password";
const showCopyPass = ["password", "card", "bank", "ssn"].includes(item.item_type);
const showLaunch = item.item_type === "password" && p.url;
li.innerHTML = `
<div class="item-avatar shared-avatar" aria-hidden="true">🔗</div>
<div class="item-info">
<span class="item-name">${escHtml(item.name)}</span>
<span class="item-sub">${escHtml(subText)}</span>
<span class="item-shared-from">From: ${escHtml(item._sharedFrom)}</span>
</div>
<div class="item-actions">
${showLaunch ? `<button class="btn-icon" title="Open URL" data-action="launch">🌐</button>` : ""}
${showCopyUser ? `<button class="btn-icon" title="Copy username" data-action="copy-user">👤</button>` : ""}
${showCopyPass ? `<button class="btn-icon" title="Copy secret" data-action="copy-pass">📋</button>` : ""}
<button class="btn-icon" title="View details" data-action="view">👁️</button>
</div>`;
if (showLaunch) {
li.querySelector('[data-action="launch"]').addEventListener("click", (e) => {
e.stopPropagation();
let url = p.url || "";
if (url && !/^https?:\/\//i.test(url)) url = "https://" + url;
window.open(url, "_blank", "noopener,noreferrer");
});
}
if (showCopyUser) {
li.querySelector('[data-action="copy-user"]').addEventListener("click", (e) => {
e.stopPropagation();
copyToClipboard(p.username || "", "Username copied");
});
}
if (showCopyPass) {
li.querySelector('[data-action="copy-pass"]').addEventListener("click", (e) => {
e.stopPropagation();
const secret = item.item_type === "password" ? p.password
: item.item_type === "card" ? p.cvv
: item.item_type === "bank" ? p.account_number
: p.ssn_number;
copyToClipboard(secret || "", "Copied to clipboard");
});
}
li.querySelector('[data-action="view"]').addEventListener("click", (e) => {
e.stopPropagation();
showSharedItemDetails(item.name, item.item_type, item.plain);
});
li.addEventListener("click", () => showSharedItemDetails(item.name, item.item_type, item.plain));
return li;
}
function createItemElement(item) {
// ── Shared-item (read-only) card ──────────────────────────────────────────
if (item._shared) return _createSharedItemElement(item);
const li = document.createElement("li");
li.className = "vault-item";
li.dataset.id = item.id;
@@ -3634,19 +3823,19 @@ const Vault = (() => {
// ── Filtering / Search ────────────────────────────────────────────────────
function applyCurrentFilter() {
const all = [..._items, ..._sharedItems];
if (!_activeFilter) {
renderItemList(_items);
renderItemList(all);
return;
}
if (_activeFilter.type === "itemType")
renderItemList(_items.filter((i) => i.item_type === _activeFilter.value));
renderItemList(all.filter((i) => i.item_type === _activeFilter.value));
else if (_activeFilter.type === "folder")
// Shared items belong to no folder — exclude them from folder filters.
renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value));
else if (_activeFilter.type === "tag")
renderItemList(
_items.filter((i) =>
(i.plain?.tags || []).includes(_activeFilter.value),
),
all.filter((i) => (i.plain?.tags || []).includes(_activeFilter.value)),
);
}
@@ -3669,17 +3858,16 @@ const Vault = (() => {
applyCurrentFilter();
return;
}
const all = [..._items, ..._sharedItems];
const pool = !_activeFilter
? _items
? all
: _activeFilter.type === "itemType"
? _items.filter((i) => i.item_type === _activeFilter.value)
? all.filter((i) => i.item_type === _activeFilter.value)
: _activeFilter.type === "folder"
? _items.filter((i) => i.folder_id === _activeFilter.value)
: _activeFilter.type === "tag"
? _items.filter((i) =>
(i.plain?.tags || []).includes(_activeFilter.value),
)
: _items;
? all.filter((i) => (i.plain?.tags || []).includes(_activeFilter.value))
: all;
renderItemList(
pool.filter((item) => {
const p = item.plain || {};
@@ -4067,9 +4255,11 @@ const Vault = (() => {
if (!res) return;
showToast("Item added");
} else {
// Re-encrypt shared copies so recipients always see the latest data.
const shared_updates = await buildSharedUpdates(itemId, plainData, name);
const res = await apiFetch(`/api/vault/${itemId}`, {
method: "PUT",
body: JSON.stringify(payload),
body: JSON.stringify({ ...payload, shared_updates }),
});
if (!res) return;
showToast("Item updated");