05/29 Update shared items displayed on Grantee items
This commit is contained in:
+204
-14
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user