From 450b236e26e6b952454b2f09b2d41b61645ff93f Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 29 May 2026 13:29:59 -0400 Subject: [PATCH] 05/29 Update shared items displayed on Grantee items --- app/routes/sharing.py | 1 + app/routes/vault.py | 16 +++ app/static/css/app.css | 24 +++++ app/static/js/vault.js | 218 ++++++++++++++++++++++++++++++++++++++--- 4 files changed, 245 insertions(+), 14 deletions(-) diff --git a/app/routes/sharing.py b/app/routes/sharing.py index 593e669..cf5700a 100644 --- a/app/routes/sharing.py +++ b/app/routes/sharing.py @@ -101,6 +101,7 @@ def list_outgoing(): d = s.to_dict() recipient = db.session.get(User, s.recipient_id) if s.recipient_id else None d['recipient_name'] = recipient.email if recipient else s.recipient_email + d['recipient_public_key'] = recipient.sharing_public_key if recipient else None result.append(d) return jsonify(result), 200 diff --git a/app/routes/vault.py b/app/routes/vault.py index c2d9c25..3265886 100644 --- a/app/routes/vault.py +++ b/app/routes/vault.py @@ -142,6 +142,22 @@ def update_item(item_id): from datetime import datetime, timezone item.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) + # Re-encrypt accepted shared copies if the owner provided updated ciphertext. + # Each entry: { share_id, enc_data, iv, enc_name?, iv_name? } + from app.models.shared_item import SharedItem + for upd in (data.get('shared_updates') or []): + share = SharedItem.query.filter_by( + id=upd.get('share_id'), + owner_id=g.current_user_id, + accepted=True, + ).first() + if share and upd.get('enc_data') and upd.get('iv'): + share.enc_data = upd['enc_data'] + share.iv = upd['iv'] + if upd.get('enc_name') is not None: + share.enc_name = upd['enc_name'] + share.iv_name = upd.get('iv_name') + try: db.session.flush() AuditLog.log( diff --git a/app/static/css/app.css b/app/static/css/app.css index fb432dc..3b06349 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -343,6 +343,30 @@ ul { border-color: #d1d5db; } +/* Shared item — subtle left-border indicator */ +.vault-item--shared { + border-left: 3px solid #6366f1; +} + +.vault-item--shared:hover { + border-left-color: #4f46e5; +} + +.shared-avatar { + background: #ede9fe; + color: #4f46e5; + font-size: 18px; +} + +.item-shared-from { + font-size: 11px; + color: #6366f1; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* Initials avatar */ .item-avatar { width: 40px; diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 35e8a94..81d6028 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -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/. + */ + 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 = ` + +
+ ${escHtml(item.name)} + ${escHtml(subText)} + From: ${escHtml(item._sharedFrom)} +
+
+ ${showLaunch ? `` : ""} + ${showCopyUser ? `` : ""} + ${showCopyPass ? `` : ""} + +
`; + + 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");