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()
|
d = s.to_dict()
|
||||||
recipient = db.session.get(User, s.recipient_id) if s.recipient_id else None
|
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_name'] = recipient.email if recipient else s.recipient_email
|
||||||
|
d['recipient_public_key'] = recipient.sharing_public_key if recipient else None
|
||||||
result.append(d)
|
result.append(d)
|
||||||
return jsonify(result), 200
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,22 @@ def update_item(item_id):
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
item.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
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:
|
try:
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
AuditLog.log(
|
AuditLog.log(
|
||||||
|
|||||||
@@ -343,6 +343,30 @@ ul {
|
|||||||
border-color: #d1d5db;
|
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 */
|
/* Initials avatar */
|
||||||
.item-avatar {
|
.item-avatar {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
|
|||||||
+204
-14
@@ -8,6 +8,7 @@
|
|||||||
const Vault = (() => {
|
const Vault = (() => {
|
||||||
let _items = [];
|
let _items = [];
|
||||||
let _folders = [];
|
let _folders = [];
|
||||||
|
let _sharedItems = []; // Accepted shared items from other users, decrypted
|
||||||
let _currentView = "vault";
|
let _currentView = "vault";
|
||||||
let _vaultLoaded = false;
|
let _vaultLoaded = false;
|
||||||
|
|
||||||
@@ -285,6 +286,9 @@ const Vault = (() => {
|
|||||||
// blocking the vault render. Results are cached so opening the Security
|
// blocking the vault render. Results are cached so opening the Security
|
||||||
// tab doesn't re-run HIBP checks.
|
// tab doesn't re-run HIBP checks.
|
||||||
runBackgroundHealthCheck();
|
runBackgroundHealthCheck();
|
||||||
|
// Load accepted shared items asynchronously so own items render first,
|
||||||
|
// then re-render once shared items are decrypted.
|
||||||
|
loadSharedItemsForVault().then(() => applyCurrentFilter());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast("Failed to load vault: " + err.message, "error");
|
showToast("Failed to load vault: " + err.message, "error");
|
||||||
} finally {
|
} 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 ────────────────────────────────────────────────────────
|
// ── View switching ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function switchView(view, { pushState = true } = {}) {
|
function switchView(view, { pushState = true } = {}) {
|
||||||
@@ -470,10 +588,11 @@ const Vault = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SHARED_GROUP = "🔗 Shared with me";
|
||||||
const sorted = getSortedItems(items);
|
const sorted = getSortedItems(items);
|
||||||
const groups = {};
|
const groups = {};
|
||||||
sorted.forEach((item) => {
|
sorted.forEach((item) => {
|
||||||
const key = folderName(item.folder_id);
|
const key = item._shared ? SHARED_GROUP : folderName(item.folder_id);
|
||||||
if (!groups[key]) groups[key] = [];
|
if (!groups[key]) groups[key] = [];
|
||||||
groups[key].push(item);
|
groups[key].push(item);
|
||||||
});
|
});
|
||||||
@@ -481,7 +600,7 @@ const Vault = (() => {
|
|||||||
const keys =
|
const keys =
|
||||||
_sortOrder === "folder"
|
_sortOrder === "folder"
|
||||||
? Object.keys(groups).sort()
|
? 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],
|
(k) => groups[k],
|
||||||
);
|
);
|
||||||
Object.keys(groups).forEach((k) => {
|
Object.keys(groups).forEach((k) => {
|
||||||
@@ -540,7 +659,77 @@ const Vault = (() => {
|
|||||||
return name.slice(0, 2).toUpperCase();
|
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) {
|
function createItemElement(item) {
|
||||||
|
// ── Shared-item (read-only) card ──────────────────────────────────────────
|
||||||
|
if (item._shared) return _createSharedItemElement(item);
|
||||||
|
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.className = "vault-item";
|
li.className = "vault-item";
|
||||||
li.dataset.id = item.id;
|
li.dataset.id = item.id;
|
||||||
@@ -3634,19 +3823,19 @@ const Vault = (() => {
|
|||||||
// ── Filtering / Search ────────────────────────────────────────────────────
|
// ── Filtering / Search ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function applyCurrentFilter() {
|
function applyCurrentFilter() {
|
||||||
|
const all = [..._items, ..._sharedItems];
|
||||||
if (!_activeFilter) {
|
if (!_activeFilter) {
|
||||||
renderItemList(_items);
|
renderItemList(all);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_activeFilter.type === "itemType")
|
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")
|
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));
|
renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value));
|
||||||
else if (_activeFilter.type === "tag")
|
else if (_activeFilter.type === "tag")
|
||||||
renderItemList(
|
renderItemList(
|
||||||
_items.filter((i) =>
|
all.filter((i) => (i.plain?.tags || []).includes(_activeFilter.value)),
|
||||||
(i.plain?.tags || []).includes(_activeFilter.value),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3669,17 +3858,16 @@ const Vault = (() => {
|
|||||||
applyCurrentFilter();
|
applyCurrentFilter();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const all = [..._items, ..._sharedItems];
|
||||||
const pool = !_activeFilter
|
const pool = !_activeFilter
|
||||||
? _items
|
? all
|
||||||
: _activeFilter.type === "itemType"
|
: _activeFilter.type === "itemType"
|
||||||
? _items.filter((i) => i.item_type === _activeFilter.value)
|
? all.filter((i) => i.item_type === _activeFilter.value)
|
||||||
: _activeFilter.type === "folder"
|
: _activeFilter.type === "folder"
|
||||||
? _items.filter((i) => i.folder_id === _activeFilter.value)
|
? _items.filter((i) => i.folder_id === _activeFilter.value)
|
||||||
: _activeFilter.type === "tag"
|
: _activeFilter.type === "tag"
|
||||||
? _items.filter((i) =>
|
? all.filter((i) => (i.plain?.tags || []).includes(_activeFilter.value))
|
||||||
(i.plain?.tags || []).includes(_activeFilter.value),
|
: all;
|
||||||
)
|
|
||||||
: _items;
|
|
||||||
renderItemList(
|
renderItemList(
|
||||||
pool.filter((item) => {
|
pool.filter((item) => {
|
||||||
const p = item.plain || {};
|
const p = item.plain || {};
|
||||||
@@ -4067,9 +4255,11 @@ const Vault = (() => {
|
|||||||
if (!res) return;
|
if (!res) return;
|
||||||
showToast("Item added");
|
showToast("Item added");
|
||||||
} else {
|
} 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}`, {
|
const res = await apiFetch(`/api/vault/${itemId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({ ...payload, shared_updates }),
|
||||||
});
|
});
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
showToast("Item updated");
|
showToast("Item updated");
|
||||||
|
|||||||
Reference in New Issue
Block a user