Добавить статистику и очистку аудита; доработать UI расшифровки.

- Страница /admin/stats: MinIO, pending/consumed, загрузки, audit-метрики
- Кнопка «Очистить» в аудите с подтверждением
- Красивые callout’ы и ошибки пароля; пустой пароль не тратит попытку
- Убрать автоопределение языка; «Расшифруй» в RU UI
This commit is contained in:
Sergey Antropoff
2026-07-18 23:50:44 +03:00
parent 91719740b9
commit 8361b8e3f2
17 changed files with 1007 additions and 258 deletions
+17
View File
@@ -2,6 +2,7 @@
const form = document.getElementById("audit-filter-form");
const typeSelect = document.getElementById("audit-event-type");
const perPage = document.getElementById("audit-per-page");
const clearForm = document.getElementById("audit-clear-form");
function resetToFirstPageAndSubmit() {
if (!form) return;
@@ -13,6 +14,22 @@
typeSelect?.addEventListener("change", resetToFirstPageAndSubmit);
perPage?.addEventListener("change", resetToFirstPageAndSubmit);
clearForm?.addEventListener("submit", async (e) => {
e.preventDefault();
const t = (k, fallback) => window.WrappedI18n?.t(k) || fallback;
const ok = await window.WrappedModal.confirm({
title: t("admin.audit.clearConfirmTitle", "Clear audit"),
message: t(
"admin.audit.clearConfirm",
"Delete all audit events? This cannot be undone."
),
confirmLabel: t("admin.audit.clearConfirmOk", "Clear all"),
cancelLabel: t("modal.confirm.cancel", "Cancel"),
danger: true,
});
if (ok) clearForm.submit();
});
document.querySelectorAll(".audit-time[data-ts]").forEach((el) => {
const raw = el.getAttribute("data-ts");
if (!raw) return;
+2 -57
View File
@@ -3,16 +3,12 @@
const files = [];
let settings = null;
let captchaWidgetId = null;
let langManual = false;
let detectTimer = null;
const els = {
text: document.getElementById("payload-text"),
lang: document.getElementById("text-language"),
langChip: document.getElementById("lang-chip"),
langChipText: document.getElementById("lang-chip-text"),
langChange: document.getElementById("lang-change"),
langHint: document.getElementById("lang-hint"),
langModal: document.getElementById("lang-modal"),
langGrid: document.getElementById("lang-grid"),
highlight: document.getElementById("code-highlight"),
@@ -57,11 +53,10 @@
return t(`lang.${id}`) || id;
}
function setLanguage(id, { manual = false, silent = false } = {}) {
function setLanguage(id, { silent = false } = {}) {
const lang = window.WrappedHighlight.languages().includes(id) ? id : "plaintext";
els.lang.value = lang;
if (els.langChipText) els.langChipText.textContent = langLabel(lang);
if (manual) langManual = true;
if (!silent) refreshHighlight();
}
@@ -77,49 +72,10 @@
function syncEditorHeight() {
if (!els.text || !els.highlight) return;
els.highlight.parentElement.style.height = "auto";
// Keep highlight pre matching textarea scroll height
const pre = els.highlight.parentElement;
pre.style.minHeight = `${Math.max(220, els.text.scrollHeight)}px`;
}
function updateLangHint(detection) {
if (!els.langHint) return;
if (langManual || !els.text.value.trim()) {
els.langHint.classList.add("hidden");
els.langHint.textContent = "";
return;
}
if (detection.confidence >= 0.6) {
els.langHint.classList.add("hidden");
els.langHint.textContent = "";
return;
}
const suggestions = (detection.suggestions || [])
.filter((l) => l !== detection.lang)
.slice(0, 3)
.map(langLabel);
els.langHint.textContent = suggestions.length
? t("create.languageUnsure", { suggestions: suggestions.join(", ") })
: t("create.languageUnsurePlain");
els.langHint.classList.remove("hidden");
}
function autoDetectLanguage() {
if (langManual) {
refreshHighlight();
return;
}
const detection = window.WrappedHighlight.detectLanguage(els.text.value);
setLanguage(detection.lang, { silent: true });
refreshHighlight();
updateLangHint(detection);
}
function scheduleDetect() {
clearTimeout(detectTimer);
detectTimer = setTimeout(autoDetectLanguage, 220);
}
function openLangModal() {
if (!els.langModal || !els.langGrid) return;
els.langGrid.innerHTML = "";
@@ -129,8 +85,7 @@
btn.className = "lang-option" + (id === els.lang.value ? " active" : "");
btn.textContent = langLabel(id);
btn.addEventListener("click", () => {
setLanguage(id, { manual: true });
updateLangHint({ confidence: 1, suggestions: [] });
setLanguage(id);
closeLangModal();
});
els.langGrid.appendChild(btn);
@@ -283,20 +238,11 @@
if (pasted.length) {
e.preventDefault();
addFiles(pasted);
return;
}
// Text paste into textarea triggers input; detect after paste event
if (document.activeElement === els.text) {
setTimeout(() => {
langManual = false;
autoDetectLanguage();
}, 0);
}
});
els.text.addEventListener("input", () => {
refreshHighlight();
scheduleDetect();
});
els.text.addEventListener("scroll", () => {
const pre = els.highlight.parentElement;
@@ -305,7 +251,6 @@
});
els.langChip?.addEventListener("click", openLangModal);
els.langChange?.addEventListener("click", openLangModal);
els.langModal?.addEventListener("click", (e) => {
if (e.target.closest("[data-lang-close]")) closeLangModal();
});
-115
View File
@@ -15,120 +15,6 @@
"css",
];
function tryParseJson(text) {
try {
JSON.parse(text);
return true;
} catch {
return false;
}
}
/**
* @returns {{ lang: string, confidence: number, suggestions: string[] }}
*/
function detectLanguage(text) {
const raw = (text || "").trim();
if (!raw) {
return { lang: "plaintext", confidence: 0, suggestions: LANGUAGES };
}
const scores = Object.fromEntries(LANGUAGES.map((l) => [l, 0]));
if (
(raw.startsWith("{") || raw.startsWith("[")) &&
tryParseJson(raw)
) {
scores.json += 10;
} else if (/^\s*[{[]/.test(raw) && /["']?\w+["']?\s*:/.test(raw)) {
scores.json += 4;
}
if (
/^---\s*$/m.test(raw) ||
(/^[\w.-]+\s*:\s+\S+/m.test(raw) &&
!tryParseJson(raw) &&
!raw.startsWith("<"))
) {
scores.yaml += 5;
}
if (/^\s*-\s+\w+/m.test(raw) && /:\s/.test(raw)) scores.yaml += 2;
if (
/\b(def|class|import|from|async def)\b/.test(raw) ||
/^\s*@\w+/m.test(raw)
) {
scores.python += 5;
}
if (/print\(|__name__|self\./.test(raw)) scores.python += 2;
if (
/\b(function|const|let|var|=>|console\.)\b/.test(raw) ||
/\brequire\s*\(/.test(raw)
) {
scores.javascript += 4;
}
if (/\b(interface|type)\s+\w+|:\s*\w+(\[\])?\s*[=;]/.test(raw)) {
scores.typescript += 5;
}
if (/\bimport\s+type\b|\bas\s+const\b/.test(raw)) scores.typescript += 2;
if (/\b(package|func|fmt\.|:=)\b/.test(raw)) scores.go += 5;
if (/\b(fn |let mut|impl |pub fn|cargo)\b/.test(raw)) scores.rust += 5;
if (
/\b(SELECT|INSERT|UPDATE|DELETE|CREATE TABLE|FROM|WHERE)\b/i.test(raw)
) {
scores.sql += 6;
}
if (/^\s*#!.*\b(bash|sh|zsh)\b/m.test(raw) || /^\s*(sudo |export |echo )/m.test(raw)) {
scores.bash += 4;
}
if (/<\/?[a-zA-Z][\w:-]*[\s>]/.test(raw) || raw.startsWith("<!DOCTYPE")) {
scores.html += 6;
}
if (/[{;]\s*$/m.test(raw) && /[.#]?[\w-]+\s*\{/.test(raw) && !tryParseJson(raw)) {
scores.css += 5;
}
if (/^#{1,6}\s+\S+/m.test(raw) || /\[[^\]]+\]\([^)]+\)/.test(raw)) {
scores.markdown += 4;
}
const ranked = Object.entries(scores)
.filter(([lang]) => lang !== "plaintext")
.sort((a, b) => b[1] - a[1]);
const best = ranked[0];
const second = ranked[1];
if (!best || best[1] < 3) {
return {
lang: "plaintext",
confidence: 0.2,
suggestions: ranked
.filter(([, s]) => s > 0)
.slice(0, 4)
.map(([l]) => l)
.concat(["plaintext"]),
};
}
const confidence =
best[1] >= 8
? 0.95
: best[1] >= 5
? 0.75
: second && best[1] - second[1] <= 1
? 0.45
: 0.6;
const suggestions = ranked
.filter(([, s]) => s > 0)
.slice(0, 5)
.map(([l]) => l);
if (!suggestions.includes("plaintext")) suggestions.push("plaintext");
return { lang: best[0], confidence, suggestions };
}
function hljsLang(lang) {
const map = {
plaintext: "plaintext",
@@ -182,7 +68,6 @@
}
window.WrappedHighlight = {
detectLanguage,
highlightElement,
syncThemeStylesheet,
languages,
+94 -20
View File
@@ -22,11 +22,8 @@
"create.title": "Wrap. Send. Vanish.",
"create.lede": "Wrap text, images or files into a one-time token. Encryption happens in your browser — the server never sees plaintext.",
"create.language": "Highlight language",
"create.languageChange": "Change type",
"create.languagePickTitle": "Text type",
"create.languagePickHint": "Choose how the text should be highlighted.",
"create.languageUnsure": "Not sure — maybe: {suggestions}. Or change type.",
"create.languageUnsurePlain": "Could not detect type — change type if needed.",
"create.text": "Text",
"create.textPlaceholder": "Paste secrets, configs, notes…",
"create.drop": "Drop files here, click to browse, or paste a screenshot (⌘V / Ctrl+V)",
@@ -39,7 +36,8 @@
"create.working": "Encrypting and uploading…",
"create.openToken": "Open a token",
"create.successTitle": "Token issued",
"create.successWarn": "One-time unwrap. After someone opens it, ciphertext is destroyed on the server.",
"create.successWarnTitle": "One-time unwrap",
"create.successWarnHint": "After opening, ciphertext is deleted on the server.",
"create.shareLink": "Share link",
"create.token": "Wrapped token",
"create.another": "Create another",
@@ -79,17 +77,23 @@
"unwrap.password": "Password (if set)",
"unwrap.submit": "Unwrap",
"unwrap.working": "Decrypting…",
"unwrap.destroyed": "Server copy destroyed. Preview lives only in this browser session.",
"unwrap.destroyedTitle": "Server copy destroyed",
"unwrap.destroyedHint": "Preview lives only in this browser session.",
"unwrap.needKey": "Missing encryption key. Open the full share link (with #key) or paste the full wrapped token.",
"unwrap.badPassword": "Wrong password.",
"unwrap.badPasswordRemaining": "Wrong password. Attempts left: {n} of {max}.",
"unwrap.passwordLocked": "Too many wrong passwords. This wrap has been destroyed.",
"unwrap.badPassword": "Wrong password",
"unwrap.badPasswordHint": "Check the password and try again.",
"unwrap.passwordRequired": "Password required",
"unwrap.passwordRequiredHint": "This wrap is password-protected.",
"unwrap.attemptsLeft": "{n} of {max} attempts left",
"unwrap.passwordLocked": "Too many wrong passwords",
"unwrap.passwordLockedHint": "This wrap has been destroyed.",
"unwrap.unavailable": "Unavailable (already used, expired, or invalid).",
"common.copy": "Copy",
"common.copied": "Copied",
"common.download": "Download",
"common.error": "Something went wrong.",
"common.working": "Working…",
"admin.nav.stats": "Stats",
"admin.nav.settings": "Settings",
"admin.nav.audit": "Audit",
"admin.nav.danger": "Danger",
@@ -97,6 +101,33 @@
"admin.nav.logout": "Logout",
"admin.brand.tagline": "Admin console",
"admin.settings.title": "Settings",
"admin.stats.title": "Statistics",
"admin.stats.updated": "Updated",
"admin.stats.minioTitle": "MinIO (pending ciphertext)",
"admin.stats.objects": "object(s)",
"admin.stats.dbPending": "DB pending",
"admin.stats.unwrappedTitle": "Unwrapped (all time)",
"admin.stats.auditOk": "audit ok",
"admin.stats.last24h": "24h",
"admin.stats.pendingTitle": "Not unwrapped",
"admin.stats.items": "item(s)",
"admin.stats.uploadedTitle": "Uploaded (all time)",
"admin.stats.wrapsSection": "Wraps by status",
"admin.stats.extraSection": "More",
"admin.stats.col.status": "Status",
"admin.stats.col.count": "Count",
"admin.stats.col.size": "Size",
"admin.stats.col.items": "Items",
"admin.stats.status.pending": "pending",
"admin.stats.status.consumed": "consumed",
"admin.stats.status.expired": "expired",
"admin.stats.total": "Total",
"admin.stats.withPassword": "With password",
"admin.stats.created24h": "Created (24h)",
"admin.stats.created7d": "Created (7d)",
"admin.stats.createsOk": "Creates ok (audit)",
"admin.stats.createsFail": "Creates fail (audit)",
"admin.stats.unwrapsFail": "Unwrap fails (audit)",
"admin.audit.title": "Audit",
"admin.tab.limits": "Limits",
"admin.tab.rate": "Rate limits",
@@ -166,6 +197,12 @@
"admin.audit.filter": "Filter",
"admin.audit.allEvents": "All events",
"admin.audit.perPage": "Per page",
"admin.audit.clear": "Clear",
"admin.audit.clearConfirmTitle": "Clear audit",
"admin.audit.clearConfirm": "Delete all audit events? This cannot be undone.",
"admin.audit.clearConfirmOk": "Clear all",
"admin.audit.clearedPrefix": "Cleared",
"admin.audit.clearedSuffix": "audit event(s).",
"admin.audit.shown": "events shown",
"admin.audit.of": "of",
"admin.audit.events": "events",
@@ -202,11 +239,8 @@
"create.title": "Упакуй. Отправь. Исчезни.",
"create.lede": "Упакуй текст, картинки или файлы в одноразовый токен. Шифрование в браузере — сервер не видит plaintext.",
"create.language": "Язык подсветки",
"create.languageChange": "Изменить тип",
"create.languagePickTitle": "Тип текста",
"create.languagePickHint": "Выберите, как подсвечивать текст.",
"create.languageUnsure": "Не уверен — возможно: {suggestions}. Или измените тип.",
"create.languageUnsurePlain": "Не удалось определить тип — при необходимости измените вручную.",
"create.text": "Текст",
"create.textPlaceholder": "Вставь секреты, конфиги, заметки…",
"create.drop": "Перетащи файлы, выбери или вставь скриншот из буфера (⌘V / Ctrl+V)",
@@ -219,7 +253,8 @@
"create.working": "Шифрование и загрузка…",
"create.openToken": "Открыть токен",
"create.successTitle": "Токен выдан",
"create.successWarn": "Unwrap один раз. После открытия ciphertext удаляется на сервере.",
"create.successWarnTitle": "Расшифруй один раз",
"create.successWarnHint": "После открытия ciphertext удаляется на сервере.",
"create.shareLink": "Ссылка",
"create.token": "Wrapped-токен",
"create.another": "Создать ещё",
@@ -251,7 +286,7 @@
"relative.inHours": "через {n} ч",
"relative.inDays": "через {n} дн",
"relative.soon": "скоро",
"unwrap.eyebrow": "Unwrap",
"unwrap.eyebrow": "Расшифруй",
"unwrap.title": "Открыть один раз",
"unwrap.lede": "Вставь wrapped-токен или открой ссылку. Ciphertext скачивается один раз и удаляется на сервере.",
"unwrap.token": "Wrapped-токен или ID",
@@ -259,17 +294,23 @@
"unwrap.password": "Пароль (если задан)",
"unwrap.submit": "Расшифровать",
"unwrap.working": "Расшифровка…",
"unwrap.destroyed": "Копия на сервере уничтожена. Превью только в этой сессии браузера.",
"unwrap.destroyedTitle": "Копия на сервере уничтожена",
"unwrap.destroyedHint": "Превью только в этой сессии браузера.",
"unwrap.needKey": "Нет ключа шифрования. Открой полную ссылку (с #key) или вставь полный wrapped-токен.",
"unwrap.badPassword": "Неверный пароль.",
"unwrap.badPasswordRemaining": "Неверный пароль. Осталось попыток: {n} из {max}.",
"unwrap.passwordLocked": "Слишком много неверных паролей. Этот wrap уничтожен.",
"unwrap.badPassword": "Неверный пароль",
"unwrap.badPasswordHint": "Проверьте пароль и попробуйте снова.",
"unwrap.passwordRequired": "Нужен пароль",
"unwrap.passwordRequiredHint": "Этот wrap защищён паролем.",
"unwrap.attemptsLeft": "Осталось попыток: {n} из {max}",
"unwrap.passwordLocked": "Слишком много неверных паролей",
"unwrap.passwordLockedHint": "Этот wrap уничтожен.",
"unwrap.unavailable": "Недоступно (уже использовано, истекло или неверно).",
"common.copy": "Копировать",
"common.copied": "Скопировано",
"common.download": "Скачать",
"common.error": "Что-то пошло не так.",
"common.working": "Подождите…",
"admin.nav.stats": "Статистика",
"admin.nav.settings": "Настройки",
"admin.nav.audit": "Аудит",
"admin.nav.danger": "Опасная зона",
@@ -277,6 +318,33 @@
"admin.nav.logout": "Выйти",
"admin.brand.tagline": "Консоль администратора",
"admin.settings.title": "Настройки",
"admin.stats.title": "Статистика",
"admin.stats.updated": "Обновлено",
"admin.stats.minioTitle": "MinIO (нерасшифрованный ciphertext)",
"admin.stats.objects": "объект(ов)",
"admin.stats.dbPending": "в БД pending",
"admin.stats.unwrappedTitle": "Расшифровано (за всё время)",
"admin.stats.auditOk": "audit ok",
"admin.stats.last24h": "за 24ч",
"admin.stats.pendingTitle": "Не расшифровано",
"admin.stats.items": "элемент(ов)",
"admin.stats.uploadedTitle": "Загружено (за всё время)",
"admin.stats.wrapsSection": "Wraps по статусу",
"admin.stats.extraSection": "Ещё",
"admin.stats.col.status": "Статус",
"admin.stats.col.count": "Кол-во",
"admin.stats.col.size": "Размер",
"admin.stats.col.items": "Элементы",
"admin.stats.status.pending": "pending",
"admin.stats.status.consumed": "consumed",
"admin.stats.status.expired": "expired",
"admin.stats.total": "Всего",
"admin.stats.withPassword": "С паролем",
"admin.stats.created24h": "Создано (24ч)",
"admin.stats.created7d": "Создано (7д)",
"admin.stats.createsOk": "Создания ok (audit)",
"admin.stats.createsFail": "Создания fail (audit)",
"admin.stats.unwrapsFail": "Ошибки расшифровки (audit)",
"admin.audit.title": "Аудит",
"admin.tab.limits": "Лимиты",
"admin.tab.rate": "Rate limits",
@@ -302,15 +370,15 @@
"admin.limits.auditRetention": "Хранение аудита (дни)",
"admin.rate.title": "Rate limits (на IP / минуту)",
"admin.rate.create": "Создание",
"admin.rate.unwrap": "Unwrap",
"admin.rate.unwrap": "Расшифровка",
"admin.rate.adminLogin": "Вход в админку",
"admin.mime.title": "MIME allowlist",
"admin.mime.hint": "Один шаблон на строку. Точные типы и wildcards вроде image/*.",
"admin.mime.examples": "Примеры / рекомендуемые значения",
"admin.passwordMode.title": "Режим пароля",
"admin.passwordMode.client_only": "Пароль также оборачивает ciphertext в браузере. Сервер хранит Argon2id-хеш и отклоняет неверный пароль до одноразового unwrap (опечатка не сжигает пакет).",
"admin.passwordMode.client_only": "Пароль также оборачивает ciphertext в браузере. Сервер хранит Argon2id-хеш и отклоняет неверный пароль до одноразовой расшифровки (опечатка не сжигает пакет).",
"admin.passwordMode.server_gate": "Сервер хранит Argon2id-хеш и отдаёт ciphertext только после верного пароля. Ciphertext по-прежнему шифруется на клиенте тем же паролем.",
"admin.password.maxAttempts": "Неверных вводов пароля (unwrap)",
"admin.password.maxAttempts": "Неверных вводов пароля (расшифровка)",
"admin.password.maxAttemptsHint": "После стольких неверных паролей wrap уничтожается. По умолчанию: 3.",
"admin.captcha.title": "CAPTCHA",
"admin.captcha.provider": "Провайдер",
@@ -346,6 +414,12 @@
"admin.audit.filter": "Фильтр",
"admin.audit.allEvents": "Все события",
"admin.audit.perPage": "На странице",
"admin.audit.clear": "Очистить",
"admin.audit.clearConfirmTitle": "Очистить аудит",
"admin.audit.clearConfirm": "Удалить все события аудита? Это нельзя отменить.",
"admin.audit.clearConfirmOk": "Очистить всё",
"admin.audit.clearedPrefix": "Удалено",
"admin.audit.clearedSuffix": "событий аудита.",
"admin.audit.shown": "событий показано",
"admin.audit.of": "из",
"admin.audit.events": "событий",
+73 -19
View File
@@ -1,5 +1,5 @@
(() => {
const t = (k) => window.WrappedI18n.t(k);
const t = (k, vars) => window.WrappedI18n.t(k, vars);
let settings = null;
const els = {
@@ -7,19 +7,60 @@
password: document.getElementById("unwrap-password"),
btn: document.getElementById("unwrap-btn"),
error: document.getElementById("form-error"),
errorTitle: document.getElementById("form-error-title"),
errorHint: document.getElementById("form-error-hint"),
errorAttempts: document.getElementById("form-error-attempts"),
form: document.getElementById("unwrap-form"),
result: document.getElementById("result-panel"),
items: document.getElementById("items"),
captchaSlot: document.getElementById("captcha-slot"),
};
function showError(msg) {
els.error.textContent = msg;
function showError(title, hint, attempts) {
if (!els.error) return;
if (els.errorTitle) els.errorTitle.textContent = title || "";
if (els.errorHint) {
if (hint) {
els.errorHint.textContent = hint;
els.errorHint.classList.remove("hidden");
} else {
els.errorHint.textContent = "";
els.errorHint.classList.add("hidden");
}
}
if (els.errorAttempts) {
if (attempts) {
els.errorAttempts.textContent = attempts;
els.errorAttempts.classList.remove("hidden");
} else {
els.errorAttempts.textContent = "";
els.errorAttempts.classList.add("hidden");
}
}
// Fallback if structured nodes missing
if (!els.errorTitle) els.error.textContent = [title, hint, attempts].filter(Boolean).join(" ");
els.error.classList.remove("hidden");
}
function clearError() {
if (!els.error) return;
els.error.classList.add("hidden");
if (els.errorTitle) els.errorTitle.textContent = "";
if (els.errorHint) {
els.errorHint.textContent = "";
els.errorHint.classList.add("hidden");
}
if (els.errorAttempts) {
els.errorAttempts.textContent = "";
els.errorAttempts.classList.add("hidden");
}
}
function attemptsLabel(detail) {
const n = Number(detail?.attempts_remaining);
const max = Number(detail?.attempts_max);
if (!Number.isFinite(n) || !Number.isFinite(max)) return "";
return t("unwrap.attemptsLeft", { n, max });
}
function loadCaptcha() {
@@ -151,24 +192,33 @@
const detail = err.detail;
if (detail && typeof detail === "object") {
if (detail.code === "password_locked") {
showError(t("unwrap.passwordLocked"));
showError(
t("unwrap.passwordLocked"),
t("unwrap.passwordLockedHint")
);
return;
}
if (detail.code === "password_required") {
showError(
t("unwrap.passwordRequired"),
t("unwrap.passwordRequiredHint"),
attemptsLabel(detail)
);
els.password?.focus();
return;
}
if (detail.code === "bad_password") {
const n = Number(detail.attempts_remaining);
const max = Number(detail.attempts_max);
if (Number.isFinite(n)) {
showError(
t("unwrap.badPasswordRemaining", {
n,
max: Number.isFinite(max) ? max : n,
})
);
return;
}
showError(
t("unwrap.badPassword"),
t("unwrap.badPasswordHint"),
attemptsLabel(detail)
);
els.password?.focus();
els.password?.select?.();
return;
}
}
showError(t("unwrap.badPassword"));
showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint"));
return;
}
if (!resp.ok) {
@@ -185,9 +235,12 @@
els.password.value || null
);
} catch (err) {
if (err.message === "bad_password" || err.message === "password_required") {
// Should be rare now (server gates first); keep UX clear.
showError(t("unwrap.badPassword"));
if (err.message === "password_required") {
showError(t("unwrap.passwordRequired"), t("unwrap.passwordRequiredHint"));
return;
}
if (err.message === "bad_password") {
showError(t("unwrap.badPassword"), t("unwrap.badPasswordHint"));
return;
}
showError(t("common.error"));
@@ -218,3 +271,4 @@
init().catch(() => showError(t("common.error")));
})();