05/19 Enhance codes 2
This commit is contained in:
@@ -195,9 +195,18 @@ def create_app(config_name: str = 'development') -> Flask:
|
|||||||
from app.models.token_blacklist import TokenBlacklist
|
from app.models.token_blacklist import TokenBlacklist
|
||||||
from app.models.recovery_challenge import RecoveryChallenge
|
from app.models.recovery_challenge import RecoveryChallenge
|
||||||
from app.models.totp_used_code import TotpUsedCode
|
from app.models.totp_used_code import TotpUsedCode
|
||||||
|
from app.models.shared_item import SharedItem
|
||||||
TokenBlacklist.cleanup_expired()
|
TokenBlacklist.cleanup_expired()
|
||||||
RecoveryChallenge.cleanup_expired()
|
RecoveryChallenge.cleanup_expired()
|
||||||
TotpUsedCode.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
|
import logging
|
||||||
logging.getLogger(__name__).debug(
|
logging.getLogger(__name__).debug(
|
||||||
'[PassKeeper] token_blacklist + recovery_challenges cleanup completed'
|
'[PassKeeper] token_blacklist + recovery_challenges cleanup completed'
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ class SharedItem(db.Model):
|
|||||||
|
|
||||||
accepted = db.Column(db.Boolean, default=False, nullable=False)
|
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)
|
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):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
@@ -64,4 +73,6 @@ class SharedItem(db.Model):
|
|||||||
'iv_name': self.iv_name,
|
'iv_name': self.iv_name,
|
||||||
'accepted': self.accepted,
|
'accepted': self.accepted,
|
||||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
'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(),
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-2
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from app import db, limiter, client_ip
|
from app import db, limiter, client_ip
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -126,6 +128,21 @@ def create_share():
|
|||||||
# Encrypted display name — encrypted with the ECDH shared secret client-side.
|
# Encrypted display name — encrypted with the ECDH shared secret client-side.
|
||||||
enc_name = data.get('enc_name') or None
|
enc_name = data.get('enc_name') or None
|
||||||
iv_name = data.get('iv_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]):
|
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
|
return jsonify({'error': 'item_id, recipient_email, enc_data, iv, item_name are required'}), 400
|
||||||
@@ -153,6 +170,7 @@ def create_share():
|
|||||||
iv=iv,
|
iv=iv,
|
||||||
enc_name=enc_name,
|
enc_name=enc_name,
|
||||||
iv_name=iv_name,
|
iv_name=iv_name,
|
||||||
|
expires_at=expires_at,
|
||||||
)
|
)
|
||||||
db.session.add(share)
|
db.session.add(share)
|
||||||
db.session.flush() # populate share.id before logging
|
db.session.flush() # populate share.id before logging
|
||||||
@@ -201,15 +219,25 @@ def delete_share(share_id):
|
|||||||
@limiter.limit('60 per minute')
|
@limiter.limit('60 per minute')
|
||||||
@require_jwt
|
@require_jwt
|
||||||
def inbox():
|
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)
|
user = db.session.get(User, g.current_user_id)
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
shares = (
|
shares = (
|
||||||
SharedItem.query
|
SharedItem.query
|
||||||
.filter(
|
.filter(
|
||||||
db.or_(
|
db.or_(
|
||||||
SharedItem.recipient_email == user.email,
|
SharedItem.recipient_email == user.email,
|
||||||
SharedItem.recipient_id == user.id,
|
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())
|
.order_by(SharedItem.created_at.desc())
|
||||||
.all()
|
.all()
|
||||||
|
|||||||
+81
-46
@@ -471,8 +471,8 @@ const Vault = (() => {
|
|||||||
_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)].filter(
|
||||||
(k) => groups[k],
|
(k) => groups[k],
|
||||||
);
|
);
|
||||||
Object.keys(groups).forEach((k) => {
|
Object.keys(groups).forEach((k) => {
|
||||||
if (!keys.includes(k)) keys.push(k);
|
if (!keys.includes(k)) keys.push(k);
|
||||||
});
|
});
|
||||||
@@ -540,7 +540,7 @@ const Vault = (() => {
|
|||||||
case "card":
|
case "card":
|
||||||
subText = item.plain.card_number
|
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;
|
break;
|
||||||
case "bank":
|
case "bank":
|
||||||
@@ -920,9 +920,9 @@ const Vault = (() => {
|
|||||||
0,
|
0,
|
||||||
Math.round(
|
Math.round(
|
||||||
100 -
|
100 -
|
||||||
(weak.length / total) * 40 -
|
(weak.length / total) * 40 -
|
||||||
(reused.length / total) * 30 -
|
(reused.length / total) * 30 -
|
||||||
(old.length / total) * 15,
|
(old.length / total) * 15,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const cls =
|
const cls =
|
||||||
@@ -1072,16 +1072,16 @@ const Vault = (() => {
|
|||||||
</div>
|
</div>
|
||||||
<ul class="sec-item-list">
|
<ul class="sec-item-list">
|
||||||
${breached
|
${breached
|
||||||
.map((i) => {
|
.map((i) => {
|
||||||
const count =
|
const count =
|
||||||
hibpResults.find((r) => r.item.id === i.id)?.count || 0;
|
hibpResults.find((r) => r.item.id === i.id)?.count || 0;
|
||||||
return `<li class="sec-item">
|
return `<li class="sec-item">
|
||||||
<span class="sec-item-name">${escHtml(i.name)}</span>
|
<span class="sec-item-name">${escHtml(i.name)}</span>
|
||||||
<span class="sec-item-sub">${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches</span>
|
<span class="sec-item-sub">${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches</span>
|
||||||
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Change</button>
|
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Change</button>
|
||||||
</li>`;
|
</li>`;
|
||||||
})
|
})
|
||||||
.join("")}
|
.join("")}
|
||||||
</ul>`;
|
</ul>`;
|
||||||
hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => {
|
hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
@@ -1577,12 +1577,21 @@ const Vault = (() => {
|
|||||||
// name for shares created before the enc_name migration.
|
// name for shares created before the enc_name migration.
|
||||||
const vaultItem = _items.find((i) => i.id === s.item_id);
|
const vaultItem = _items.find((i) => i.id === s.item_id);
|
||||||
const displayName = vaultItem?.name || s.item_name;
|
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 " · <span class=\"badge badge-danger\">Expired</span>";
|
||||||
|
if (daysLeft <= 3) return ` · <span class="badge badge-warn">Expires in ${daysLeft}d</span>`;
|
||||||
|
return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`;
|
||||||
|
})();
|
||||||
return `
|
return `
|
||||||
<li class="share-item">
|
<li class="share-item">
|
||||||
<div class="share-icon">${itemIcon(s.item_type)}</div>
|
<div class="share-icon">${itemIcon(s.item_type)}</div>
|
||||||
<div class="share-info">
|
<div class="share-info">
|
||||||
<span class="share-name">${escHtml(displayName)}</span>
|
<span class="share-name">${escHtml(displayName)}</span>
|
||||||
<span class="share-meta">Shared with ${escHtml(s.recipient_email)} · ${s.accepted ? "✅ Accepted" : "⏳ Pending"}</span>
|
<span class="share-meta">Shared with ${escHtml(s.recipient_email)} · ${s.accepted ? "✅ Accepted" : "⏳ Pending"}${expiryLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-icon" title="Revoke share" data-revoke="${s.id}">🗑️</button>
|
<button class="btn-icon" title="Revoke share" data-revoke="${s.id}">🗑️</button>
|
||||||
</li>`;
|
</li>`;
|
||||||
@@ -1616,14 +1625,26 @@ const Vault = (() => {
|
|||||||
}
|
}
|
||||||
ul.innerHTML = shares
|
ul.innerHTML = shares
|
||||||
.map(
|
.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 ` · <span class="badge badge-warn">Expires in ${daysLeft}d</span>`;
|
||||||
|
return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`;
|
||||||
|
})();
|
||||||
|
return `
|
||||||
<li class="share-item">
|
<li class="share-item">
|
||||||
<div class="share-icon">${itemIcon(s.item_type)}</div>
|
<div class="share-icon">${itemIcon(s.item_type)}</div>
|
||||||
<div class="share-info">
|
<div class="share-info">
|
||||||
<span class="share-name">${escHtml(s.item_name)}</span>
|
<span class="share-name">${escHtml(s.item_name)}</span>
|
||||||
<span class="share-meta">From ${escHtml(s.owner_email)}</span>
|
<span class="share-meta">From ${escHtml(s.owner_email)}${expiryLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
${!s.accepted
|
${
|
||||||
|
!s.accepted
|
||||||
? `<button class="btn-primary btn-sm" data-accept="${s.id}">Accept</button>`
|
? `<button class="btn-primary btn-sm" data-accept="${s.id}">Accept</button>`
|
||||||
: `<button class="btn-secondary btn-sm"
|
: `<button class="btn-secondary btn-sm"
|
||||||
data-view-share="${s.id}"
|
data-view-share="${s.id}"
|
||||||
@@ -1634,8 +1655,9 @@ const Vault = (() => {
|
|||||||
data-iv-name="${escHtml(s.iv_name || "")}"
|
data-iv-name="${escHtml(s.iv_name || "")}"
|
||||||
data-name="${escHtml(s.item_name)}"
|
data-name="${escHtml(s.item_name)}"
|
||||||
data-type="${escHtml(s.item_type || "")}">View</button>`
|
data-type="${escHtml(s.item_type || "")}">View</button>`
|
||||||
}
|
}
|
||||||
</li>`,
|
</li>`;
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
@@ -1814,6 +1836,11 @@ const Vault = (() => {
|
|||||||
item_type: item.item_type,
|
item_type: item.item_type,
|
||||||
enc_name,
|
enc_name,
|
||||||
iv_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;
|
if (!res) return;
|
||||||
@@ -2170,9 +2197,10 @@ const Vault = (() => {
|
|||||||
<span class="em-vault-chevron">▸</span>
|
<span class="em-vault-chevron">▸</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="em-vault-item-body hidden" id="em-vault-body-${idx}">
|
<div class="em-vault-item-body hidden" id="em-vault-body-${idx}">
|
||||||
${hasFields
|
${
|
||||||
? '<div class="detail-body em-detail-body"></div>'
|
hasFields
|
||||||
: '<p class="vault-empty">Could not decrypt this item.</p>'
|
? '<div class="detail-body em-detail-body"></div>'
|
||||||
|
: '<p class="vault-empty">Could not decrypt this item.</p>'
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</li>`;
|
</li>`;
|
||||||
@@ -2501,8 +2529,8 @@ const Vault = (() => {
|
|||||||
const typeLabel = transports.includes("internal")
|
const typeLabel = transports.includes("internal")
|
||||||
? "📱 Device"
|
? "📱 Device"
|
||||||
: transports.some((t) => ["usb", "nfc", "ble", "smart-card"].includes(t))
|
: transports.some((t) => ["usb", "nfc", "ble", "smart-card"].includes(t))
|
||||||
? "🔑 Security key"
|
? "🔑 Security key"
|
||||||
: "🔑 Passkey";
|
: "🔑 Passkey";
|
||||||
return `<div class="passkey-item" data-cred-id="${c.id}">
|
return `<div class="passkey-item" data-cred-id="${c.id}">
|
||||||
<div class="passkey-info">
|
<div class="passkey-info">
|
||||||
<span class="passkey-name">${escHtml(c.name)}</span>
|
<span class="passkey-name">${escHtml(c.name)}</span>
|
||||||
@@ -2765,15 +2793,16 @@ const Vault = (() => {
|
|||||||
<h3 style="margin:0 0 8px;font-size:16px;color:#111827;">
|
<h3 style="margin:0 0 8px;font-size:16px;color:#111827;">
|
||||||
${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"}
|
${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"}
|
||||||
</h3>
|
</h3>
|
||||||
${isFirstTime
|
${
|
||||||
? `<p style="font-size:13px;color:#374151;margin-bottom:12px;">
|
isFirstTime
|
||||||
|
? `<p style="font-size:13px;color:#374151;margin-bottom:12px;">
|
||||||
These codes let you sign in if you lose access to your authenticator app.
|
These codes let you sign in if you lose access to your authenticator app.
|
||||||
<strong>Save them now — they will not be shown again.</strong>
|
<strong>Save them now — they will not be shown again.</strong>
|
||||||
</p>`
|
</p>`
|
||||||
: `<p style="font-size:13px;color:#374151;margin-bottom:12px;">
|
: `<p style="font-size:13px;color:#374151;margin-bottom:12px;">
|
||||||
Your previous codes have been invalidated. Save these new codes securely.
|
Your previous codes have been invalidated. Save these new codes securely.
|
||||||
</p>`
|
</p>`
|
||||||
}
|
}
|
||||||
<div style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;display:flex;flex-wrap:wrap;gap:6px;margin-bottom:16px;">
|
<div style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;display:flex;flex-wrap:wrap;gap:6px;margin-bottom:16px;">
|
||||||
${codesHtml}
|
${codesHtml}
|
||||||
</div>
|
</div>
|
||||||
@@ -3282,23 +3311,29 @@ const Vault = (() => {
|
|||||||
const pool = !_activeFilter
|
const pool = !_activeFilter
|
||||||
? _items
|
? _items
|
||||||
: _activeFilter.type === "itemType"
|
: _activeFilter.type === "itemType"
|
||||||
? _items.filter((i) => i.item_type === _activeFilter.value)
|
? _items.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) =>
|
? _items.filter((i) =>
|
||||||
(i.plain?.tags || []).includes(_activeFilter.value),
|
(i.plain?.tags || []).includes(_activeFilter.value),
|
||||||
)
|
)
|
||||||
: _items;
|
: _items;
|
||||||
renderItemList(
|
renderItemList(
|
||||||
pool.filter(
|
pool.filter((item) => {
|
||||||
(item) =>
|
const p = item.plain || {};
|
||||||
|
return (
|
||||||
item.name.toLowerCase().includes(q) ||
|
item.name.toLowerCase().includes(q) ||
|
||||||
(item.plain?.username || "").toLowerCase().includes(q) ||
|
(p.username || "").toLowerCase().includes(q) ||
|
||||||
(item.plain?.url || "").toLowerCase().includes(q) ||
|
(p.url || "").toLowerCase().includes(q) ||
|
||||||
(item.plain?.note_body || "").toLowerCase().includes(q) ||
|
(p.note_body || "").toLowerCase().includes(q) ||
|
||||||
(item.plain?.rp_id || "").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 (
|
if (
|
||||||
(mode === "add" &&
|
(mode === "add" &&
|
||||||
(document.getElementById("field-type").value || "password") ===
|
(document.getElementById("field-type").value || "password") ===
|
||||||
"password") ||
|
"password") ||
|
||||||
(mode === "edit" && (item?.item_type || "password") === "password")
|
(mode === "edit" && (item?.item_type || "password") === "password")
|
||||||
) {
|
) {
|
||||||
initPasswordFieldEnhancements();
|
initPasswordFieldEnhancements();
|
||||||
@@ -3697,7 +3732,7 @@ const Vault = (() => {
|
|||||||
"X-CSRFToken": csrfToken(),
|
"X-CSRFToken": csrfToken(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
}).catch(() => { });
|
}).catch(() => {});
|
||||||
VaultSession.clear();
|
VaultSession.clear();
|
||||||
SharingSession.clear();
|
SharingSession.clear();
|
||||||
sessionStorage.removeItem("access_token");
|
sessionStorage.removeItem("access_token");
|
||||||
@@ -3802,11 +3837,11 @@ const Vault = (() => {
|
|||||||
// Auto-clear clipboard after 30 seconds — industry-standard hygiene.
|
// Auto-clear clipboard after 30 seconds — industry-standard hygiene.
|
||||||
if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer);
|
if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer);
|
||||||
_clipboardClearTimer = setTimeout(() => {
|
_clipboardClearTimer = setTimeout(() => {
|
||||||
navigator.clipboard.writeText("").catch(() => { });
|
navigator.clipboard.writeText("").catch(() => {});
|
||||||
_clipboardClearTimer = null;
|
_clipboardClearTimer = null;
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
})
|
})
|
||||||
.catch(() => { });
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(str) {
|
function escHtml(str) {
|
||||||
|
|||||||
@@ -1156,6 +1156,16 @@
|
|||||||
placeholder="recipient@example.com"
|
placeholder="recipient@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="share-expiry-select">Expires</label>
|
||||||
|
<select id="share-expiry-select">
|
||||||
|
<option value="0">Never</option>
|
||||||
|
<option value="1">1 day</option>
|
||||||
|
<option value="7" selected>7 days</option>
|
||||||
|
<option value="30">30 days</option>
|
||||||
|
<option value="90">90 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn-secondary" id="btn-cancel-share">
|
<button type="button" class="btn-secondary" id="btn-cancel-share">
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add expires_at to shared_items
|
||||||
|
|
||||||
|
Revision ID: i9j0k1l2m3n4
|
||||||
|
Revises: h8i9j0k1l2m3
|
||||||
|
Create Date: 2026-05-19 00:00:00.000000
|
||||||
|
|
||||||
|
Adds an optional expiry timestamp to shared items.
|
||||||
|
- NULL = share never expires (backwards compatible with existing rows)
|
||||||
|
- value = share is considered expired after this datetime
|
||||||
|
|
||||||
|
Expired unaccepted shares are filtered from the recipient's inbox.
|
||||||
|
Expired accepted shares remain visible (data already accepted into vault).
|
||||||
|
The APScheduler cleanup job deletes expired unaccepted shares nightly.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = 'i9j0k1l2m3n4'
|
||||||
|
down_revision = 'h8i9j0k1l2m3'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column(
|
||||||
|
'shared_items',
|
||||||
|
sa.Column('expires_at', sa.DateTime, nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('shared_items', 'expires_at')
|
||||||
Reference in New Issue
Block a user