diff --git a/app/__init__.py b/app/__init__.py
index 044b884..536553a 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -195,9 +195,18 @@ def create_app(config_name: str = 'development') -> Flask:
from app.models.token_blacklist import TokenBlacklist
from app.models.recovery_challenge import RecoveryChallenge
from app.models.totp_used_code import TotpUsedCode
+ from app.models.shared_item import SharedItem
TokenBlacklist.cleanup_expired()
RecoveryChallenge.cleanup_expired()
TotpUsedCode.cleanup_expired()
+ # Delete expired unaccepted shares.
+ from datetime import datetime, timezone
+ SharedItem.query.filter(
+ SharedItem.accepted == False,
+ SharedItem.expires_at != None,
+ SharedItem.expires_at <= datetime.now(timezone.utc).replace(tzinfo=None),
+ ).delete()
+ db.session.commit()
import logging
logging.getLogger(__name__).debug(
'[PassKeeper] token_blacklist + recovery_challenges cleanup completed'
diff --git a/app/models/shared_item.py b/app/models/shared_item.py
index 44959f4..db47140 100644
--- a/app/models/shared_item.py
+++ b/app/models/shared_item.py
@@ -48,6 +48,15 @@ class SharedItem(db.Model):
accepted = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False)
+ # Optional expiry — NULL means the share never expires.
+ # Expired unaccepted shares are hidden from the inbox; accepted shares remain.
+ expires_at = db.Column(db.DateTime, nullable=True)
+
+ def is_expired(self) -> bool:
+ """Return True if the share has an expiry and it has passed."""
+ if not self.expires_at:
+ return False
+ return datetime.now(timezone.utc).replace(tzinfo=None) >= self.expires_at
def to_dict(self):
return {
@@ -64,4 +73,6 @@ class SharedItem(db.Model):
'iv_name': self.iv_name,
'accepted': self.accepted,
'created_at': self.created_at.isoformat() if self.created_at else None,
+ 'expires_at': self.expires_at.isoformat() if self.expires_at else None,
+ 'is_expired': self.is_expired(),
}
diff --git a/app/routes/sharing.py b/app/routes/sharing.py
index c57cfd5..6314252 100644
--- a/app/routes/sharing.py
+++ b/app/routes/sharing.py
@@ -1,3 +1,5 @@
+from datetime import datetime, timezone
+
from flask import Blueprint, request, jsonify, g
from app import db, limiter, client_ip
from app.models.user import User
@@ -126,6 +128,21 @@ def create_share():
# Encrypted display name — encrypted with the ECDH shared secret client-side.
enc_name = data.get('enc_name') or None
iv_name = data.get('iv_name') or None
+ # Optional expiry: number of days until the share expires (None = never).
+ # Accepted values: 1, 7, 30, 90, None.
+ expires_days = data.get('expires_days')
+ expires_at = None
+ if expires_days is not None:
+ try:
+ expires_days = int(expires_days)
+ if expires_days > 0:
+ from datetime import timedelta
+ expires_at = (
+ datetime.now(timezone.utc).replace(tzinfo=None)
+ + timedelta(days=expires_days)
+ )
+ except (TypeError, ValueError):
+ pass
if not all([item_id, recipient_email, enc_data, iv, item_name]):
return jsonify({'error': 'item_id, recipient_email, enc_data, iv, item_name are required'}), 400
@@ -153,6 +170,7 @@ def create_share():
iv=iv,
enc_name=enc_name,
iv_name=iv_name,
+ expires_at=expires_at,
)
db.session.add(share)
db.session.flush() # populate share.id before logging
@@ -201,15 +219,25 @@ def delete_share(share_id):
@limiter.limit('60 per minute')
@require_jwt
def inbox():
- """List all items shared with the current user."""
+ """List all items shared with the current user.
+ Expired unaccepted shares are excluded — they can no longer be acted on.
+ Expired accepted shares remain visible since the data was already accepted.
+ """
user = db.session.get(User, g.current_user_id)
+ now = datetime.now(timezone.utc).replace(tzinfo=None)
shares = (
SharedItem.query
.filter(
db.or_(
SharedItem.recipient_email == user.email,
SharedItem.recipient_id == user.id,
- )
+ ),
+ # Exclude expired unaccepted shares.
+ db.or_(
+ SharedItem.accepted == True,
+ SharedItem.expires_at == None,
+ SharedItem.expires_at > now,
+ ),
)
.order_by(SharedItem.created_at.desc())
.all()
diff --git a/app/static/js/vault.js b/app/static/js/vault.js
index 6987ed1..cef2abb 100644
--- a/app/static/js/vault.js
+++ b/app/static/js/vault.js
@@ -471,8 +471,8 @@ const Vault = (() => {
_sortOrder === "folder"
? Object.keys(groups).sort()
: ["(No folder)", ..._folders.map((f) => f.name)].filter(
- (k) => groups[k],
- );
+ (k) => groups[k],
+ );
Object.keys(groups).forEach((k) => {
if (!keys.includes(k)) keys.push(k);
});
@@ -540,7 +540,7 @@ const Vault = (() => {
case "card":
subText = item.plain.card_number
? "•••• " +
- String(item.plain.card_number).replace(/\s/g, "").slice(-4)
+ String(item.plain.card_number).replace(/\s/g, "").slice(-4)
: "";
break;
case "bank":
@@ -920,9 +920,9 @@ const Vault = (() => {
0,
Math.round(
100 -
- (weak.length / total) * 40 -
- (reused.length / total) * 30 -
- (old.length / total) * 15,
+ (weak.length / total) * 40 -
+ (reused.length / total) * 30 -
+ (old.length / total) * 15,
),
);
const cls =
@@ -1072,16 +1072,16 @@ const Vault = (() => {
${breached
- .map((i) => {
- const count =
- hibpResults.find((r) => r.item.id === i.id)?.count || 0;
- return `-
+ .map((i) => {
+ const count =
+ hibpResults.find((r) => r.item.id === i.id)?.count || 0;
+ return `
-
${escHtml(i.name)}
${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches
`;
- })
- .join("")}
+ })
+ .join("")}
`;
hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => {
btn.addEventListener("click", () => {
@@ -1577,12 +1577,21 @@ const Vault = (() => {
// name for shares created before the enc_name migration.
const vaultItem = _items.find((i) => i.id === s.item_id);
const displayName = vaultItem?.name || s.item_name;
+ const expiryLabel = (() => {
+ if (!s.expires_at) return "";
+ const daysLeft = Math.ceil(
+ (new Date(s.expires_at).getTime() - Date.now()) / 86400000,
+ );
+ if (daysLeft <= 0) return " · Expired";
+ if (daysLeft <= 3) return ` · Expires in ${daysLeft}d`;
+ return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`;
+ })();
return `
${itemIcon(s.item_type)}
${escHtml(displayName)}
- Shared with ${escHtml(s.recipient_email)} · ${s.accepted ? "✅ Accepted" : "⏳ Pending"}
+ Shared with ${escHtml(s.recipient_email)} · ${s.accepted ? "✅ Accepted" : "⏳ Pending"}${expiryLabel}
`;
@@ -1616,14 +1625,26 @@ const Vault = (() => {
}
ul.innerHTML = shares
.map(
- (s) => `
+ (s) => {
+ // Expiry label for pending (unaccepted) items.
+ const expiryLabel = (() => {
+ if (!s.expires_at || s.accepted) return "";
+ const daysLeft = Math.ceil(
+ (new Date(s.expires_at).getTime() - Date.now()) / 86400000,
+ );
+ if (daysLeft <= 3)
+ return ` · Expires in ${daysLeft}d`;
+ return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`;
+ })();
+ return `
${itemIcon(s.item_type)}
${escHtml(s.item_name)}
- From ${escHtml(s.owner_email)}
+ From ${escHtml(s.owner_email)}${expiryLabel}
- ${!s.accepted
+ ${
+ !s.accepted
? ``
: ``
- }
- `,
+ }
+ `;
+ },
)
.join("");
@@ -1814,6 +1836,11 @@ const Vault = (() => {
item_type: item.item_type,
enc_name,
iv_name,
+ expires_days: (() => {
+ const sel = document.getElementById("share-expiry-select");
+ const v = sel ? parseInt(sel.value) : NaN;
+ return isNaN(v) || v === 0 ? null : v;
+ })(),
}),
});
if (!res) return;
@@ -2170,9 +2197,10 @@ const Vault = (() => {
▸
- ${hasFields
- ? '
'
- : '
Could not decrypt this item.
'
+ ${
+ hasFields
+ ? '
'
+ : '
Could not decrypt this item.
'
}
`;
@@ -2501,8 +2529,8 @@ const Vault = (() => {
const typeLabel = transports.includes("internal")
? "📱 Device"
: transports.some((t) => ["usb", "nfc", "ble", "smart-card"].includes(t))
- ? "🔑 Security key"
- : "🔑 Passkey";
+ ? "🔑 Security key"
+ : "🔑 Passkey";
return `
${escHtml(c.name)}
@@ -2765,15 +2793,16 @@ const Vault = (() => {
${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"}
- ${isFirstTime
- ? `
+ ${
+ isFirstTime
+ ? `
These codes let you sign in if you lose access to your authenticator app.
Save them now — they will not be shown again.
`
- : `
+ : `
Your previous codes have been invalidated. Save these new codes securely.
`
- }
+ }
${codesHtml}
@@ -3282,23 +3311,29 @@ const Vault = (() => {
const pool = !_activeFilter
? _items
: _activeFilter.type === "itemType"
- ? _items.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;
+ ? _items.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;
renderItemList(
- pool.filter(
- (item) =>
+ pool.filter((item) => {
+ const p = item.plain || {};
+ return (
item.name.toLowerCase().includes(q) ||
- (item.plain?.username || "").toLowerCase().includes(q) ||
- (item.plain?.url || "").toLowerCase().includes(q) ||
- (item.plain?.note_body || "").toLowerCase().includes(q) ||
- (item.plain?.rp_id || "").toLowerCase().includes(q),
- ),
+ (p.username || "").toLowerCase().includes(q) ||
+ (p.url || "").toLowerCase().includes(q) ||
+ (p.note_body || "").toLowerCase().includes(q) ||
+ (p.rp_id || "").toLowerCase().includes(q) ||
+ (p.notes || "").toLowerCase().includes(q) ||
+ (p.cardholder_name || "").toLowerCase().includes(q) ||
+ (p.email || "").toLowerCase().includes(q) ||
+ (p.tags || []).some((t) => t.toLowerCase().includes(q))
+ );
+ }),
);
}
@@ -3440,7 +3475,7 @@ const Vault = (() => {
if (
(mode === "add" &&
(document.getElementById("field-type").value || "password") ===
- "password") ||
+ "password") ||
(mode === "edit" && (item?.item_type || "password") === "password")
) {
initPasswordFieldEnhancements();
@@ -3697,7 +3732,7 @@ const Vault = (() => {
"X-CSRFToken": csrfToken(),
},
body: JSON.stringify({ refresh_token: refreshToken }),
- }).catch(() => { });
+ }).catch(() => {});
VaultSession.clear();
SharingSession.clear();
sessionStorage.removeItem("access_token");
@@ -3802,11 +3837,11 @@ const Vault = (() => {
// Auto-clear clipboard after 30 seconds — industry-standard hygiene.
if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer);
_clipboardClearTimer = setTimeout(() => {
- navigator.clipboard.writeText("").catch(() => { });
+ navigator.clipboard.writeText("").catch(() => {});
_clipboardClearTimer = null;
}, 30_000);
})
- .catch(() => { });
+ .catch(() => {});
}
function escHtml(str) {
@@ -4425,4 +4460,4 @@ const Vault = (() => {
}
})();
-document.addEventListener("DOMContentLoaded", Vault.init);
\ No newline at end of file
+document.addEventListener("DOMContentLoaded", Vault.init);
diff --git a/app/templates/vault/index.html b/app/templates/vault/index.html
index f91f625..44bf24c 100644
--- a/app/templates/vault/index.html
+++ b/app/templates/vault/index.html
@@ -1156,6 +1156,16 @@
placeholder="recipient@example.com"
/>
+
+
+
+