diff --git a/extensions/extension/manifest.json b/extensions/extension/manifest.json
index c771e04..c2271a5 100644
--- a/extensions/extension/manifest.json
+++ b/extensions/extension/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "gnexus-creds",
- "version": "0.1.1",
+ "version": "0.2.0",
"description": "Browser extension for gnexus-creds secret manager.",
"permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["https://*/*", "http://*/*"],
diff --git a/extensions/extension/pack-src/config.json b/extensions/extension/pack-src/config.json
index 9bd18d3..0a8d3e0 100644
--- a/extensions/extension/pack-src/config.json
+++ b/extensions/extension/pack-src/config.json
@@ -7,5 +7,76 @@
"use": "Use",
"dismiss": "Dismiss",
"selectAccount": "Select account"
+ },
+ "stringsByLocale": {
+ "en": {
+ "use": "Use",
+ "dismiss": "Dismiss",
+ "selectAccount": "Select account",
+ "drawerTitle": "Settings",
+ "serverUrl": "Server URL",
+ "apiToken": "API token",
+ "tokenHint": "Create a token with read, reveal, write scopes on the website.",
+ "save": "Save",
+ "openApp": "Open gnexus-creds",
+ "openAppTitle": "Open gnexus-creds",
+ "refresh": "Refresh",
+ "settingsTitle": "Settings",
+ "searchPlaceholder": "Search secrets...",
+ "noSecrets": "No secrets",
+ "saveCredentials": "Save credentials for {source}?",
+ "account": "Account:",
+ "credentialsSaved": "Credentials saved",
+ "enterToken": "Enter API token",
+ "settingsSaved": "Settings saved",
+ "error": "Error",
+ "openOnSite": "Open on gnexus-creds"
+ },
+ "uk": {
+ "use": "Використати",
+ "dismiss": "Приховати",
+ "selectAccount": "Виберіть акаунт",
+ "drawerTitle": "Налаштування",
+ "serverUrl": "Адреса сервера",
+ "apiToken": "API-токен",
+ "tokenHint": "Створіть токен зі scopes read, reveal, write на сайті.",
+ "save": "Зберегти",
+ "openApp": "Відкрити gnexus-creds",
+ "openAppTitle": "Відкрити gnexus-creds",
+ "refresh": "Оновити",
+ "settingsTitle": "Налаштування",
+ "searchPlaceholder": "Пошук секретів...",
+ "noSecrets": "Немає секретів",
+ "saveCredentials": "Зберегти дані для {source}?",
+ "account": "Акаунт:",
+ "credentialsSaved": "Дані збережено",
+ "enterToken": "Вкажіть API-токен",
+ "settingsSaved": "Налаштування збережено",
+ "error": "Помилка",
+ "openOnSite": "Відкрити на gnexus-creds"
+ },
+ "ru": {
+ "use": "Использовать",
+ "dismiss": "Скрыть",
+ "selectAccount": "Выберите аккаунт",
+ "drawerTitle": "Настройки",
+ "serverUrl": "URL сервера",
+ "apiToken": "API-токен",
+ "tokenHint": "Создайте токен со scopes read, reveal, write на сайте.",
+ "save": "Сохранить",
+ "openApp": "Открыть gnexus-creds",
+ "openAppTitle": "Открыть gnexus-creds",
+ "refresh": "Обновить",
+ "settingsTitle": "Настройки",
+ "searchPlaceholder": "Поиск секретов...",
+ "noSecrets": "Нет секретов",
+ "saveCredentials": "Сохранить данные для {source}?",
+ "account": "Аккаунт:",
+ "credentialsSaved": "Данные сохранены",
+ "enterToken": "Введите API-токен",
+ "settingsSaved": "Настройки сохранены",
+ "error": "Ошибка",
+ "openOnSite": "Открыть на gnexus-creds"
+ }
}
-}
+}
\ No newline at end of file
diff --git a/extensions/extension/src/background.js b/extensions/extension/src/background.js
index 64308c2..99d4472 100644
--- a/extensions/extension/src/background.js
+++ b/extensions/extension/src/background.js
@@ -52,6 +52,27 @@
});
}
+// --- Shared UI language -------------------------------------------------------
+// The service backend decides the UI language (locale_effective on GET
+// /api/v1/me = settings override, else the auth-account locale). The
+// background caches it in chrome.storage.local.uiLocale for the popup and
+// the autofill card; strings themselves ship in the design pack.
+async function syncUiLocale(reason) {
+ const { token, baseUrl } = await getSettings();
+ if (!token) {
+ await chrome.storage.local.remove("uiLocale");
+ return;
+ }
+ try {
+ const me = await getMe(token, baseUrl);
+ const locale = String(me?.locale_effective || me?.locale || "en").trim().toLowerCase();
+ await chrome.storage.local.set({ uiLocale: locale || "en" });
+ if (reason) console.debug(`[gnexus-creds] ui locale (${reason}):`, locale);
+ } catch {
+ // keep the previously cached locale
+ }
+}
+
chrome.alarms.create("refresh-secrets-cache", { periodInMinutes: 15 });
// --- Design-resource pack sync ----------------------------------------------
@@ -147,9 +168,11 @@
// These top-level registrations survive the MV3 service worker sleeping.
chrome.runtime.onInstalled.addListener((details) => {
refreshDesignPack(details.reason === "update" ? "update" : "install");
+ syncUiLocale("install");
});
chrome.runtime.onStartup.addListener(() => {
refreshDesignPack("startup");
+ syncUiLocale("startup");
});
chrome.alarms.onAlarm.addListener(async (alarm) => {
@@ -159,6 +182,7 @@
} catch {
// ignore — no token or network error
}
+ await syncUiLocale("alarm");
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
@@ -167,7 +191,11 @@
const { token, baseUrl } = await getSettings();
switch (message.type) {
case "GET_SETTINGS":
- sendResponse({ ok: true, data: await getSettings() });
+ {
+ sendResponse({ ok: true, data: await getSettings() });
+ // every popup open re-syncs the cached UI language (cheap /me)
+ syncUiLocale("popup");
+ }
break;
case "SAVE_SETTINGS":
await setSettings(message.payload);
@@ -177,6 +205,13 @@
case "VERIFY_TOKEN":
{
const me = await getMe(message.payload.token, message.payload.baseUrl);
+ // cache the per-user UI language while we hold a fresh /me
+ try {
+ const locale = String(me?.locale_effective || me?.locale || "en").trim().toLowerCase();
+ await chrome.storage.local.set({ uiLocale: locale || "en" });
+ } catch {
+ // ignore
+ }
sendResponse({ ok: true, data: me });
}
break;
diff --git a/extensions/extension/src/content.js b/extensions/extension/src/content.js
index 1d17197..84a1361 100644
--- a/extensions/extension/src/content.js
+++ b/extensions/extension/src/content.js
@@ -17,8 +17,16 @@
pack: null,
config: GnPack.DEFAULT_CONFIG,
tables: GnCredsDetect.keywordTables,
+ uiLocale: "", // resolved from the backend by the background worker
};
+// Localized strings for the card: pack stringsByLocale[uiLocale] over the
+// legacy/bundled defaults. Read per card build so a locale change on the
+// backend shows up without reloading the page.
+function cardStrings() {
+ return GnPack.resolveLocalized(packState.config, packState.uiLocale);
+}
+
// --- Messaging helper ---
function send(type, payload) {
@@ -189,25 +197,27 @@
if (secrets.length === 1) {
const secret = secrets[0];
+ const strings = cardStrings();
card.innerHTML = `
${escapeHtml(secret.title)}
-
-
+
+
`;
} else {
+ const strings = cardStrings();
const rows = secrets.map((s) => `
${escapeHtml(s.title)}
-
+
`).join("");
card.innerHTML = `
- ${escapeHtml(packState.config.strings.selectAccount)}
+ ${escapeHtml(strings.selectAccount)}
${rows}
-
+
`;
}
@@ -385,6 +395,13 @@
} catch (err) {
console.debug("[gnexus-creds] design pack unavailable, using bundled defaults:", err?.message);
}
+ try {
+ packState.uiLocale =
+ String((await chrome.storage.local.get("uiLocale")).uiLocale || "").trim().toLowerCase();
+ } catch {
+ // no storage access — English bundled strings
+ packState.uiLocale = "";
+ }
if (document.readyState === "loading") {
await new Promise((resolve) =>
document.addEventListener("DOMContentLoaded", resolve, { once: true })
diff --git a/extensions/extension/src/pack-shared.js b/extensions/extension/src/pack-shared.js
index b11aee9..cac9410 100644
--- a/extensions/extension/src/pack-shared.js
+++ b/extensions/extension/src/pack-shared.js
@@ -164,24 +164,66 @@
}
const TIMING_LIMITS = { scanDebounceMs: [30, 2000], cardExitMs: [50, 2000] };
- const STRING_KEYS = ["use", "dismiss", "selectAccount"];
+ const STRING_KEYS = [
+ "use", "dismiss", "selectAccount",
+ // popup UI
+ "drawerTitle", "serverUrl", "apiToken", "tokenHint", "save", "openApp",
+ "openAppTitle", "refresh", "settingsTitle", "searchPlaceholder",
+ "noSecrets", "saveCredentials", "account", "credentialsSaved",
+ "enterToken", "settingsSaved", "error", "openOnSite",
+ ];
+ const LOCALIZED_LOCALES = ["en", "uk", "ru"];
+
+ // English strings as shipped pre-localization; every other locale in
+ // stringsByLocale must cover the same keys (falls back to these).
+ const DEFAULT_STRINGS = {
+ use: "Use",
+ dismiss: "Dismiss",
+ selectAccount: "Select account",
+ drawerTitle: "Settings",
+ serverUrl: "Server URL",
+ apiToken: "API token",
+ tokenHint: "Create a token with read, reveal, write scopes on the website.",
+ save: "Save",
+ openApp: "Open gnexus-creds",
+ openAppTitle: "Open gnexus-creds",
+ refresh: "Refresh",
+ settingsTitle: "Settings",
+ searchPlaceholder: "Search secrets...",
+ noSecrets: "No secrets",
+ saveCredentials: "Save credentials for {source}?",
+ account: "Account:",
+ credentialsSaved: "Credentials saved",
+ enterToken: "Enter API token",
+ settingsSaved: "Settings saved",
+ error: "Error",
+ openOnSite: "Open on gnexus-creds",
+ };
+
+ const DEFAULT_STRINGS_BY_LOCALE = {
+ en: DEFAULT_STRINGS,
+ uk: {},
+ ru: {},
+ };
function resolveString(value, fallback) {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
- if (!trimmed || trimmed.length > 60) return fallback;
+ if (!trimmed || trimmed.length > 200) return fallback;
return trimmed;
}
/**
* A pack config object may only carry keys the code already consumes:
- * two timings and the three card strings. Anything else is ignored;
- * out-of-range timings and empty/oversized strings fall back to defaults.
+ * two timings, legacy card strings, and per-locale strings. Anything
+ * else is ignored; out-of-range timings and empty/oversized strings
+ * fall back to defaults.
*/
function resolveConfig(raw) {
const resolved = {
timings: Object.assign({}, DEFAULT_CONFIG.timings),
strings: Object.assign({}, DEFAULT_CONFIG.strings),
+ stringsByLocale: Object.assign({}, DEFAULT_STRINGS_BY_LOCALE),
};
if (!isPlainObject(raw)) return resolved;
if (isPlainObject(raw.timings)) {
@@ -194,12 +236,43 @@
}
if (isPlainObject(raw.strings)) {
for (const key of STRING_KEYS) {
- resolved.strings[key] = resolveString(raw.strings[key], DEFAULT_CONFIG.strings[key]);
+ // never materialize keys the pack didn't send — undefined values
+ // would leak into consumers that spread this over the defaults
+ if (raw.strings[key] === undefined) continue;
+ // ultimate fallback is the bundled English default
+ const prior = resolved.strings[key] ?? DEFAULT_STRINGS[key];
+ resolved.strings[key] = resolveString(raw.strings[key], prior);
+ }
+ }
+ if (isPlainObject(raw.stringsByLocale)) {
+ for (const locale of LOCALIZED_LOCALES) {
+ const rawStrings = raw.stringsByLocale[locale];
+ // per-key fallback is the bundled English default (DEFAULT_STRINGS)
+ // — the merge may only override known keys with valid strings.
+ const merged = Object.assign({}, DEFAULT_STRINGS_BY_LOCALE[locale]);
+ if (isPlainObject(rawStrings)) {
+ for (const key of STRING_KEYS) {
+ merged[key] = resolveString(rawStrings[key], DEFAULT_STRINGS[key]);
+ }
+ }
+ resolved.stringsByLocale[locale] = merged;
}
}
return resolved;
}
+ /**
+ * Strings for one UI language: the per-locale pack dictionary (already
+ * resolved and clamped) over the legacy/bundled defaults. Unknown or
+ * empty lang falls back to English via DEFAULT_STRINGS.
+ */
+ function resolveLocalized(config, lang) {
+ const source = config || DEFAULT_CONFIG;
+ const localized = source.stringsByLocale instanceof Object ? source.stringsByLocale : {};
+ const byLocale = LOCALIZED_LOCALES.includes(lang) ? localized[lang] : null;
+ return Object.assign({}, DEFAULT_STRINGS, source.strings || {}, byLocale || {});
+ }
+
// Shape validation with no clamping here — values were verified against
// pack.json hashes in the background before this was stored.
function validateStoredPack(stored, ownVersion) {
@@ -262,6 +335,7 @@
EMPTY_TABLES,
isCompatible,
resolveConfig,
+ resolveLocalized,
resolveTables,
validateStoredPack,
load,
diff --git a/extensions/extension/src/popup/popup.js b/extensions/extension/src/popup/popup.js
index ca7267c..f665a37 100644
--- a/extensions/extension/src/popup/popup.js
+++ b/extensions/extension/src/popup/popup.js
@@ -31,23 +31,58 @@
const openSiteBtn = document.getElementById("open-site");
const openSettingsBtn = document.getElementById("open-settings");
+// UI language: the backend-decided locale (cached by the background) picks
+// the dictionary from the design pack; unknown locale falls back to English.
+// popup.js is a module, so the top-level await completes before the drawer
+// below is created or any UI renders.
+async function loadStrings() {
+ try {
+ const uiLocale = String(
+ (await chrome.storage.local.get("uiLocale")).uiLocale || "",
+ ).trim().toLowerCase();
+ const stored = (await chrome.storage.local.get("designPack")).designPack;
+ const pack = window.GnPack
+ ? GnPack.validateStoredPack(stored, chrome.runtime.getManifest().version)
+ : null;
+ const config = pack?.texts?.["config.json"]
+ ? GnPack.resolveConfig(JSON.parse(pack.texts["config.json"]))
+ : GnPack.DEFAULT_CONFIG;
+ return { uiLocale, strings: GnPack.resolveLocalized(config, uiLocale) };
+ } catch {
+ return { uiLocale: "", strings: GnPack.resolveLocalized(GnPack.DEFAULT_CONFIG, "") };
+ }
+}
+
+const applyStaticStrings = (loc) => {
+ const byId = (id) => document.getElementById(id);
+ if (byId("refresh")) byId("refresh").title = loc.refresh;
+ if (byId("open-site")) byId("open-site").title = loc.openAppTitle;
+ if (byId("open-settings")) byId("open-settings").title = loc.settingsTitle;
+ if (byId("search")) byId("search").placeholder = loc.searchPlaceholder;
+ if (document.querySelector("#empty-state .empty-state-text")) {
+ document.querySelector("#empty-state .empty-state-text").textContent = loc.noSecrets;
+ }
+};
+
+const { uiLocale, strings: loc } = await loadStrings();
+document.documentElement.lang = uiLocale || "en";
+applyStaticStrings(loc);
+
const settingsDrawer = Drawer.create("settings-drawer", {
- title: "Settings",
+ title: loc.drawerTitle,
body: () => {
const div = document.createElement("div");
div.innerHTML = `
-
@@ -56,9 +91,9 @@
return div;
},
actions: () => {
- const saveBtn = Helper.template.createElement("button", { class: "btn btn-primary" }, "Save");
+ const saveBtn = Helper.template.createElement("button", { class: "btn btn-primary" }, loc.save);
saveBtn.id = "save-settings";
- const openBtn = Helper.template.createElement("button", { class: "btn btn-secondary" }, "Open gnexus-creds");
+ const openBtn = Helper.template.createElement("button", { class: "btn btn-secondary" }, loc.openApp);
openBtn.id = "open-site-settings";
return [saveBtn, openBtn];
},
@@ -111,13 +146,13 @@
const username = usernameField?.value || "";
card.innerHTML = `
- Save credentials for ${escapeHtml(pending.source)}?
+ ${escapeHtml(loc.saveCredentials.replace("{source}", pending.source || ""))}
-
Account: ${escapeHtml(username)}
+
${escapeHtml(loc.account)} ${escapeHtml(username)}
`;
@@ -139,7 +174,7 @@
pendingSaveWrap.classList.add("d-none");
pendingSaveWrap.innerHTML = "";
mainErrorWrap.classList.remove("d-none");
- showWrapAlert(mainErrorWrap, "success", "Credentials saved");
+ showWrapAlert(mainErrorWrap, "success", loc.credentialsSaved);
loadSecrets();
} catch (err) {
mainErrorWrap.classList.remove("d-none");
@@ -234,7 +269,7 @@
} catch (err) {
secretsList.innerHTML = "";
emptyState.classList.remove("d-none");
- emptyState.querySelector(".empty-state-text").textContent = `Error: ${err.message}`;
+ emptyState.querySelector(".empty-state-text").textContent = `${loc.error}: ${err.message}`;
} finally {
loaderWrap.classList.add("d-none");
}
@@ -267,7 +302,7 @@
${escapeHtml(secret.title)}
${meta ? `${meta}
` : ""}
-