diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7ddfdef..cb1aa90 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -133,6 +133,16 @@ const loading = ref(false); const error = ref(""); const toastProvider = ref(null); + +// one toast shape for every caught failure; mutations in particular must +// speak up — a silent rejection reads as "the button does nothing" +function showError(err) { + toastProvider.value?.error({ + title: t("common.error"), + text: err?.message || String(err), + lifetime: 3000 + }); +} const secrets = ref([]); const total = ref(0); const query = ref(""); @@ -709,18 +719,24 @@ async function createSecret() { const fields = parseCreateFields(); - await api.createSecret({ - title: form.title, - purpose: form.purpose || null, - category: form.category || null, - source: form.source || null, - notes: form.notes || null, - tags: form.tags, - allow_ui: form.allow_ui, - allow_rest_api: form.allow_rest_api, - allow_mcp: form.allow_mcp, - fields - }); + try { + await api.createSecret({ + title: form.title, + purpose: form.purpose || null, + category: form.category || null, + source: form.source || null, + notes: form.notes || null, + tags: form.tags, + allow_ui: form.allow_ui, + allow_rest_api: form.allow_rest_api, + allow_mcp: form.allow_mcp, + fields + }); + } catch (err) { + // the modal stays open with everything the user typed + showError(err); + return; + } resetCreateForm(); showCreateModal.value = false; await loadSecrets(); @@ -728,19 +744,25 @@ async function saveSecretMetadata() { if (!selected.value) return; - const updated = await api.updateSecret(selected.value.id, { - title: editForm.title, - purpose: editForm.purpose || null, - category: editForm.category || null, - source: editForm.source || null, - notes: editForm.notes || null, - tags: editForm.tags, - status: editForm.status, - archived: editForm.archived, - allow_ui: editForm.allow_ui, - allow_rest_api: editForm.allow_rest_api, - allow_mcp: editForm.allow_mcp - }); + let updated; + try { + updated = await api.updateSecret(selected.value.id, { + title: editForm.title, + purpose: editForm.purpose || null, + category: editForm.category || null, + source: editForm.source || null, + notes: editForm.notes || null, + tags: editForm.tags, + status: editForm.status, + archived: editForm.archived, + allow_ui: editForm.allow_ui, + allow_rest_api: editForm.allow_rest_api, + allow_mcp: editForm.allow_mcp + }); + } catch (err) { + showError(err); + return; + } selected.value = updated; editing.value = false; await loadSecrets(); @@ -748,9 +770,15 @@ async function saveSecretFields() { if (!selected.value) return; - const updated = await api.updateSecret(selected.value.id, { - fields: parseFieldsText(editForm.fieldsText) - }); + let updated; + try { + updated = await api.updateSecret(selected.value.id, { + fields: parseFieldsText(editForm.fieldsText) + }); + } catch (err) { + showError(err); + return; + } selected.value = updated; revealed.value = null; editFields.value = false; @@ -759,13 +787,23 @@ async function setStatus(status) { if (!selected.value) return; - selected.value = await api.updateSecret(selected.value.id, { status }); + try { + selected.value = await api.updateSecret(selected.value.id, { status }); + } catch (err) { + showError(err); + return; + } await loadSecrets(); } async function archiveSelected() { if (!selected.value) return; - selected.value = await api.updateSecret(selected.value.id, { archived: true }); + try { + selected.value = await api.updateSecret(selected.value.id, { archived: true }); + } catch (err) { + showError(err); + return; + } await loadSecrets(); } @@ -775,7 +813,13 @@ async function deleteSelectedSecret() { if (!pendingDeleteSecret.value) return; - await api.deleteSecret(pendingDeleteSecret.value.id); + try { + await api.deleteSecret(pendingDeleteSecret.value.id); + } catch (err) { + pendingDeleteSecret.value = null; + showError(err); + return; + } pendingDeleteSecret.value = null; selected.value = null; secretsView.value = "list"; @@ -785,8 +829,12 @@ async function reveal(secret) { if (!(await biometryAllowed())) return; - revealed.value = await api.revealSecret(secret.id); - revealedVisibleFields.value = new Set(); + try { + revealed.value = await api.revealSecret(secret.id); + revealedVisibleFields.value = new Set(); + } catch (err) { + showError(err); + } } function hideRevealed() { @@ -813,13 +861,20 @@ async function loadVersions(secretId) { revealedVersion.value = null; - versions.value = await api.versions(secretId); + versions.value = await api.versions(secretId).catch((err) => { + showError(err); + return []; + }); } async function revealVersion(version) { if (!selected.value) return; if (!(await biometryAllowed())) return; - revealedVersion.value = await api.revealVersion(selected.value.id, version.id); + try { + revealedVersion.value = await api.revealVersion(selected.value.id, version.id); + } catch (err) { + showError(err); + } } function bioGateUserLabel() { @@ -883,7 +938,9 @@ text: result.code === "cancelled" ? t("bio.enrollCanceled") - : result.message || t("bio.enrollFailed"), + : result.message + ? t(result.message) + : t("bio.enrollFailed"), lifetime: 3500 }); await refreshGateState(); @@ -932,10 +989,15 @@ async function createApiToken() { const scopes = ["read", "reveal", "write", "admin", "mcp"].filter((scope) => tokenForm[scope]); - newToken.value = await api.createToken({ - name: tokenForm.name || "API token", - scopes - }); + try { + newToken.value = await api.createToken({ + name: tokenForm.name || "API token", + scopes + }); + } catch (err) { + showError(err); + return; + } await loadTokens(); } @@ -965,14 +1027,27 @@ async function doRevokeToken() { if (!pendingRevokeToken.value) return; - await api.revokeToken(pendingRevokeToken.value.id); + try { + await api.revokeToken(pendingRevokeToken.value.id); + } catch (err) { + pendingRevokeToken.value = null; + showRevokeConfirm.value = false; + showError(err); + return; + } pendingRevokeToken.value = null; showRevokeConfirm.value = false; await loadTokens(); } async function exportData() { - const payload = await api.exportData(); + let payload; + try { + payload = await api.exportData(); + } catch (err) { + showError(err); + return; + } const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -1002,7 +1077,12 @@ async function importData() { if (!importPreview.value) return; - await api.importData(importPreview.value); + try { + await api.importData(importPreview.value); + } catch (err) { + showError(err); + return; + } importFiles.value = []; importFile.value = null; importPreview.value = null; @@ -1022,7 +1102,6 @@ async function loadAdminUsers() { try { - activeTab.value = "admin"; const payload = await api.adminUsers({ offset: (adminUsersPage.value - 1) * adminUsersLimit, limit: adminUsersLimit @@ -1030,7 +1109,7 @@ adminUsers.value = payload.items; adminUsersTotal.value = payload.total; } catch (err) { - toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 }); + showError(err); } } @@ -1084,10 +1163,14 @@ } async function loadStats() { - const payload = await api.stats(); - stats.total_secrets = payload.total_secrets; - stats.active_secrets = payload.active_secrets; - stats.mcp_enabled_secrets = payload.mcp_enabled_secrets; + try { + const payload = await api.stats(); + stats.total_secrets = payload.total_secrets; + stats.active_secrets = payload.active_secrets; + stats.mcp_enabled_secrets = payload.mcp_enabled_secrets; + } catch (err) { + showError(err); + } } async function loadExtension() { @@ -1206,6 +1289,9 @@ clearTimeout(searchDebounce); searchDebounce = setTimeout(() => { if (activeTab.value === "secrets") { + // a fresh query starts from page 1: keeping the old page can land + // beyond the new total and render an empty "no secrets" table + secretsPage.value = 1; loadSecrets(); } }, 250); @@ -1281,7 +1367,7 @@ - +
@@ -160,7 +167,7 @@ @click="emit('toggle-field-visibility', item.key)" /> - +
diff --git a/frontend/src/i18n/messages/en.js b/frontend/src/i18n/messages/en.js index 194ff19..93fcb40 100644 --- a/frontend/src/i18n/messages/en.js +++ b/frontend/src/i18n/messages/en.js @@ -22,14 +22,13 @@ "common.download": "Download", "common.restore": "Restore", "common.revoke": "Revoke", - "common.save": "Save", - "common.refresh": "Refresh", "common.notSet": "Not set", "common.uncategorized": "uncategorized", "common.user": "User", "common.hide": "Hide", "common.reveal": "Reveal", "common.records": "{n} records", + "common.copy": "Copy", // profile "profile.logout": "Logout", @@ -76,6 +75,7 @@ // detail panel (SecretDetailPanel.vue) "detail.purpose": "Purpose", "detail.category": "Category", + "detail.notes": "Notes", "detail.tags": "Tags", "detail.noTags": "No tags", "detail.access": "Access", @@ -181,6 +181,12 @@ "bio.enabled": "Biometric gate enabled — every reveal will ask for the sensor.", "bio.enrollCanceled": "Enrollment canceled — the gate stays off.", "bio.enrollFailed": "Enrollment failed.", + "bio.enrollRefused": "Enrollment failed — the browser refused registration (check the site's origin).", + "bio.exportUnsupported": "This browser cannot export the passkey's public key, so client-side verification is impossible.", + "bio.persistFailed": "Could not persist the gate in this browser.", + "bio.unavailableWebauthn": "Biometric authentication is not available in this browser.", + "bio.unavailableSensor": "A fingerprint / screen-lock biometric sensor is not available on this device.", + "bio.unavailableCheck": "The browser could not check for a biometric sensor.", "bio.saved": "Saved", // admin @@ -218,7 +224,7 @@ "create.categoryHelp": "Single grouping value for browsing, for example work or banking.", "create.sourceHelp": "Where this secret came from: person, import, ticket, or note.", "create.tagsHelp": "Keywords used by search.", - "create.notesHelp": "Short context for the secret. Limited to 140 characters.", + "create.notesHelp": "Short context for the secret. Up to 255 characters.", "create.fieldsTitle": "Fields", "create.fieldsMeta": "One row per secret value", "create.addField": "Add field", diff --git a/frontend/src/i18n/messages/ru.js b/frontend/src/i18n/messages/ru.js index 60de70a..69c8a05 100644 --- a/frontend/src/i18n/messages/ru.js +++ b/frontend/src/i18n/messages/ru.js @@ -19,13 +19,12 @@ "common.download": "Скачать", "common.restore": "Восстановить", "common.revoke": "Отозвать", - "common.save": "Сохранить", - "common.refresh": "Обновить", "common.notSet": "Не задано", "common.uncategorized": "без категории", "common.user": "Пользователь", "common.hide": "Скрыть", "common.reveal": "Раскрыть", + "common.copy": "Копировать", "common.records": "записей: {n}", // «{n} записей» ломает плюрализацию, эта форма короче и безопасна "profile.logout": "Выйти", "profile.languageAuto": "Авто (из аккаунта)", @@ -68,6 +67,7 @@ "detail.purpose": "Назначение", "detail.category": "Категория", + "detail.notes": "Заметки", "detail.tags": "Теги", "detail.noTags": "Нет тегов", "detail.access": "Доступ", @@ -167,6 +167,12 @@ "bio.enabled": "Биометрическая защита включена — каждое раскрытие спросит сенсор.", "bio.enrollCanceled": "Регистрация отменена — защита остаётся выключенной.", "bio.enrollFailed": "Регистрация не удалась.", + "bio.enrollRefused": "Регистрация не удалась — браузер отказал в регистрации (проверьте origin сайта).", + "bio.exportUnsupported": "Этот браузер не экспортирует публичный ключ passkey, поэтому клиентская проверка невозможна.", + "bio.persistFailed": "Не удалось сохранить gate в этом браузере.", + "bio.unavailableWebauthn": "Биометрическая аутентификация недоступна в этом браузере.", + "bio.unavailableSensor": "На этом устройстве нет биометрического датчика (отпечаток / блокировка экрана).", + "bio.unavailableCheck": "Браузер не смог проверить наличие биометрического датчика.", "bio.saved": "Сохранено", "admin.usersToolbar": "Пользователи", @@ -200,7 +206,7 @@ "create.categoryHelp": "Одна группа для навигации, например work или banking.", "create.sourceHelp": "Откуда секрет: человек, импорт, задача или заметка.", "create.tagsHelp": "Ключевые слова для поиска.", - "create.notesHelp": "Короткий контекст к секрету. Ограничено 140 символами.", + "create.notesHelp": "Короткий контекст к секрету. До 255 символов.", "create.fieldsTitle": "Поля", "create.fieldsMeta": "Одна строка на значение секрета", "create.addField": "Добавить поле", diff --git a/frontend/src/i18n/messages/uk.js b/frontend/src/i18n/messages/uk.js index dc3e365..b329e43 100644 --- a/frontend/src/i18n/messages/uk.js +++ b/frontend/src/i18n/messages/uk.js @@ -19,13 +19,12 @@ "common.download": "Завантажити", "common.restore": "Відновити", "common.revoke": "Відкликати", - "common.save": "Зберегти", - "common.refresh": "Оновити", "common.notSet": "Не задано", "common.uncategorized": "без категорії", "common.user": "Користувач", "common.hide": "Приховати", "common.reveal": "Розкрити", + "common.copy": "Копіювати", "common.records": "записів: {n}", // uk has 3 plural forms, but "записів: n" fits all "profile.logout": "Вийти", "profile.languageAuto": "Авто (з акаунта)", @@ -68,6 +67,7 @@ "detail.purpose": "Призначення", "detail.category": "Категорія", + "detail.notes": "Нотатки", "detail.tags": "Теги", "detail.noTags": "Немає тегів", "detail.access": "Доступ", @@ -167,6 +167,12 @@ "bio.enabled": "Біометричний захист увімкнено — кожне розкриття питатиме сенсор.", "bio.enrollCanceled": "Реєстрацію скасовано — захист залишається вимкненим.", "bio.enrollFailed": "Реєстрація не вдалася.", + "bio.enrollRefused": "Реєстрація не вдалася — браузер відмовив у реєстрації (перевірте origin сайту).", + "bio.exportUnsupported": "Цей браузер не експортує публічний ключ passkey, тож клієнтська перевірка неможлива.", + "bio.persistFailed": "Не вдалося зберегти gate в цьому браузері.", + "bio.unavailableWebauthn": "Біометрична автентифікація недоступна в цьому браузері.", + "bio.unavailableSensor": "На цьому пристрої немає біометричного датчика (відбиток / блокування екрана).", + "bio.unavailableCheck": "Браузер не зміг перевірити наявність біометричного датчика.", "bio.saved": "Збережено", "admin.usersToolbar": "Користувачі", @@ -200,7 +206,7 @@ "create.categoryHelp": "Одна група для навігації, наприклад work або banking.", "create.sourceHelp": "Звідки секрет: людина, імпорт, задача чи замітка.", "create.tagsHelp": "Ключові слова для пошуку.", - "create.notesHelp": "Короткий контекст до секрету. Обмежено 140 символами.", + "create.notesHelp": "Короткий контекст до секрету. До 255 символів.", "create.fieldsTitle": "Поля", "create.fieldsMeta": "Один рядок на значення секрету", "create.addField": "Додати поле", diff --git a/frontend/vite.config.js b/frontend/vite.config.js index beeaf94..187a475 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -5,9 +5,10 @@ plugins: [vue()], server: { proxy: { - "/api": "http://localhost:8000", - "/auth": "http://localhost:8000", - "/mcp": "http://localhost:8000" + // dev backend runs on 8018 (uvicorn var/uvicorn-8018.log) + "/api": "http://localhost:8018", + "/auth": "http://localhost:8018", + "/mcp": "http://localhost:8018" } } });