05/29 Update shared items displayed on Grantee items
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
+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