feat(frontend): i18n expansion, admin/profile i18n, grid/list view fix, empty state

- Add 5 new locales (hi, ar, ru, ja, ko) — now 14 total
- Admin panel: 117 i18n keys, confirm modal, animated tabs, no inline handlers
- Profile page: 58 i18n keys with data-i18n attributes
- Fix i18n safeT() shadowing bug and translationsLoaded timing
- Fix grid/list view: list header no longer shows in grid mode on login
- Fix classList.toggle hidden sync for view switching across all nav functions
- Revert .hidden important that broke login page rendering
- Add files empty state (no_files + empty_hint) with translations
- Fix language selector dropdown scroll and styling
- Fix admin panel scroll with sticky tabs
This commit is contained in:
Diocrafts
2026-03-09 00:08:34 +01:00
parent f409a9edd7
commit df336da679
43 changed files with 6645 additions and 2043 deletions
+13 -2
View File
@@ -110,8 +110,19 @@ async function loadFiles(options = {}) {
const folderList = Array.isArray(listing.folders) ? listing.folders : [];
const fileList = Array.isArray(listing.files) ? listing.files : [];
window.ui.renderFolders(folderList);
window.ui.renderFiles(fileList);
if (folderList.length === 0 && fileList.length === 0) {
const emptyState = document.createElement('div');
emptyState.className = 'empty-state';
emptyState.innerHTML = `
<i class="fas fa-folder-open empty-state-icon"></i>
<p>${_t('files.no_files')}</p>
<p>${_t('files.empty_hint')}</p>
`;
elements.filesGrid.appendChild(emptyState);
} else {
window.ui.renderFolders(folderList);
window.ui.renderFiles(fileList);
}
console.log(`Loaded ${folderList.length} folders and ${fileList.length} files`);
} catch (error) {
+4 -2
View File
@@ -473,8 +473,8 @@ function setupEventListeners() {
// Show files containers (to be filled with trash)
const filesGrid = document.getElementById('files-grid');
const filesListView = document.getElementById('files-list-view');
if (filesGrid) filesGrid.style.display = app.currentView === 'grid' ? 'grid' : 'none';
if (filesListView) filesListView.style.display = app.currentView === 'list' ? 'block' : 'none';
if (filesGrid) { filesGrid.style.display = app.currentView === 'grid' ? 'grid' : 'none'; filesGrid.classList.toggle('hidden', app.currentView !== 'grid'); }
if (filesListView) { filesListView.style.display = app.currentView === 'list' ? 'flex' : 'none'; filesListView.classList.toggle('hidden', app.currentView !== 'list'); }
// Update UI
elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.trash') : 'Trash';
@@ -494,6 +494,8 @@ function setupEventListeners() {
const savedView = localStorage.getItem('oxicloud-view');
if (savedView === 'list') {
ui.switchToListView();
} else {
ui.switchToGridView();
}
// User menu
+41 -5
View File
@@ -3,6 +3,34 @@
* Extracted from main.js to keep navigation concerns isolated.
*/
/**
* Sync the hidden class and inline display for the grid/list containers
* based on the current view preference.
*/
function syncViewContainers() {
const filesGrid = document.getElementById('files-grid');
const filesListView = document.getElementById('files-list-view');
const isGrid = window.app.currentView === 'grid';
if (filesGrid) {
filesGrid.style.display = isGrid ? 'grid' : 'none';
filesGrid.classList.toggle('hidden', !isGrid);
}
if (filesListView) {
filesListView.style.display = isGrid ? 'none' : 'flex';
filesListView.classList.toggle('hidden', isGrid);
}
}
/**
* Hide both grid and list containers (used when switching to non-file views).
*/
function hideFileContainers() {
const filesGrid = document.getElementById('files-grid');
const filesListView = document.getElementById('files-list-view');
if (filesGrid) { filesGrid.style.display = 'none'; filesGrid.classList.add('hidden'); }
if (filesListView) { filesListView.style.display = 'none'; filesListView.classList.add('hidden'); }
}
/**
* Mobile sidebar toggle functionality
*/
@@ -136,6 +164,8 @@ function switchToSharedView() {
const filesListView = document.getElementById('files-list-view');
if (filesGrid) filesGrid.style.display = 'none';
if (filesListView) filesListView.style.display = 'none';
if (filesGrid) filesGrid.classList.add('hidden');
if (filesListView) filesListView.classList.add('hidden');
// Show shared view
if (window.sharedView) {
@@ -157,7 +187,9 @@ function switchToFilesView() {
const filesGrid = document.getElementById('files-grid');
const filesListView = document.getElementById('files-list-view');
if (filesGrid) filesGrid.style.display = window.app.currentView === 'grid' ? 'grid' : 'none';
if (filesListView) filesListView.style.display = window.app.currentView === 'list' ? 'block' : 'none';
if (filesGrid) filesGrid.classList.toggle('hidden', window.app.currentView !== 'grid');
if (filesListView) filesListView.style.display = window.app.currentView === 'list' ? 'flex' : 'none';
if (filesListView) filesListView.classList.toggle('hidden', window.app.currentView !== 'list');
// Reset to home folder and update breadcrumb
window.app.currentPath = window.app.userHomeFolderId || '';
@@ -180,7 +212,9 @@ function switchToFavoritesView() {
const filesGrid = document.getElementById('files-grid');
const filesListView = document.getElementById('files-list-view');
if (filesGrid) filesGrid.style.display = window.app.currentView === 'grid' ? 'grid' : 'none';
if (filesListView) filesListView.style.display = window.app.currentView === 'list' ? 'block' : 'none';
if (filesGrid) filesGrid.classList.toggle('hidden', window.app.currentView !== 'grid');
if (filesListView) filesListView.style.display = window.app.currentView === 'list' ? 'flex' : 'none';
if (filesListView) filesListView.classList.toggle('hidden', window.app.currentView !== 'list');
if (window.favorites) {
window.favorites.displayFavorites();
@@ -211,7 +245,9 @@ function switchToRecentFilesView() {
const filesGrid = document.getElementById('files-grid');
const filesListView = document.getElementById('files-list-view');
if (filesGrid) filesGrid.style.display = window.app.currentView === 'grid' ? 'grid' : 'none';
if (filesListView) filesListView.style.display = window.app.currentView === 'list' ? 'block' : 'none';
if (filesGrid) filesGrid.classList.toggle('hidden', window.app.currentView !== 'grid');
if (filesListView) filesListView.style.display = window.app.currentView === 'list' ? 'flex' : 'none';
if (filesListView) filesListView.classList.toggle('hidden', window.app.currentView !== 'list');
if (window.recent) {
window.recent.displayRecentFiles();
@@ -242,8 +278,8 @@ function switchToPhotosView() {
// Hide file containers
const filesGrid = document.getElementById('files-grid');
const filesListView = document.getElementById('files-list-view');
if (filesGrid) filesGrid.style.display = 'none';
if (filesListView) filesListView.style.display = 'none';
if (filesGrid) { filesGrid.style.display = 'none'; filesGrid.classList.add('hidden'); }
if (filesListView) { filesListView.style.display = 'none'; filesListView.classList.add('hidden'); }
// Show photos view
if (window.photosView) {
+4
View File
@@ -450,7 +450,9 @@ const ui = {
this._hydrateViewIfNeeded('grid');
filesGrid.style.display = 'grid';
filesGrid.classList.remove('hidden');
filesListView.style.display = 'none';
filesListView.classList.add('hidden');
gridViewBtn.classList.add('active');
listViewBtn.classList.remove('active');
window.app.currentView = 'grid';
@@ -469,7 +471,9 @@ const ui = {
this._hydrateViewIfNeeded('list');
filesGrid.style.display = 'none';
filesGrid.classList.add('hidden');
filesListView.style.display = 'flex';
filesListView.classList.remove('hidden');
gridViewBtn.classList.remove('active');
listViewBtn.classList.add('active');
window.app.currentView = 'list';
+26 -10
View File
@@ -13,7 +13,7 @@ let currentLocale =
// Supported locales (languages that have locale files on the server)
// When a locale file is not found, the system gracefully falls back to English
const supportedLocales = ['en', 'es', 'zh', 'fa', 'fr', 'de', 'pt', 'nl'];
const supportedLocales = ['en', 'es', 'zh', 'fa', 'fr', 'de', 'pt', 'nl', 'it', 'hi', 'ar', 'ru', 'ja', 'ko'];
// Fallback to English if locale is not supported
if (!supportedLocales.includes(currentLocale)) {
@@ -225,6 +225,9 @@ async function initI18n() {
if (currentLocale !== 'en') {
await loadTranslations('en');
}
// Mark loaded BEFORE translatePage so safeT resolves properly
translationsLoaded = true;
// Translate the page
translatePage();
@@ -245,20 +248,23 @@ function translatePage() {
* @param {Element|Document} root - The root element to search within
*/
function translateElement(root) {
// Use safeT instead of bare t() to avoid issues when other scripts
// (e.g. admin.js) shadow the global t() function.
const resolve = safeT;
const el = root || document;
el.querySelectorAll('[data-i18n]').forEach(element => {
const key = element.getAttribute('data-i18n');
element.textContent = t(key);
element.textContent = resolve(key);
});
el.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
const key = element.getAttribute('data-i18n-placeholder');
element.placeholder = t(key);
element.placeholder = resolve(key);
});
el.querySelectorAll('[data-i18n-title]').forEach(element => {
const key = element.getAttribute('data-i18n-title');
element.title = t(key);
element.title = resolve(key);
});
}
@@ -284,19 +290,29 @@ let translationsLoaded = false;
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', async () => {
await initI18n();
translationsLoaded = true;
// translationsLoaded already set inside initI18n
// Dispatch an event when translations are fully loaded
window.dispatchEvent(new Event('translationsLoaded'));
});
// Improved t function with fallback for early calls
// Self-contained t wrapper — does NOT call the global t() because other
// scripts (e.g. admin.js) may shadow it, which would cause infinite recursion.
function safeT(key, params = {}) {
if (!translationsLoaded) {
console.warn(`Translations for ${currentLocale} not loaded yet`);
// Return a default value or the key depending on context
const localeData = translations[currentLocale];
if (!localeData) {
// Translations not loaded yet — return humanised key suffix
return key.split('.').pop() || key;
}
return t(key, params);
let value = getNestedValue(localeData, key);
// Fallback to English
if (!value && currentLocale !== 'en' && translations['en']) {
value = getNestedValue(translations['en'], key);
}
if (!value) return key;
return interpolate(value, params);
}
// Export functions for use in other modules
+12 -3
View File
@@ -3,11 +3,20 @@
* Custom styled dropdown with flags
*/
// Locale files that actually exist (have full translations)
// Keep this list in sync when adding new locale JSON files
const AVAILABLE_LOCALES = new Set([
'en', 'es', 'zh', 'fa', 'fr', 'de', 'pt', 'it', 'nl',
'hi', 'ar', 'ru', 'ja', 'ko'
]);
// Language codes, names, and flag emojis
// Uses ALL_LANGUAGES from auth.js if available, otherwise fallback
// Only returns languages that have a real locale file
function getAvailableLanguages() {
if (typeof ALL_LANGUAGES !== 'undefined') {
return ALL_LANGUAGES.map(l => ({ code: l.code, name: l.nativeName, flag: l.flag }));
return ALL_LANGUAGES
.filter(l => AVAILABLE_LOCALES.has(l.code))
.map(l => ({ code: l.code, name: l.nativeName, flag: l.flag }));
}
return [
{ code: 'en', name: 'English', flag: '🇬🇧' },
@@ -23,7 +32,7 @@ function getAvailableLanguages() {
}
// RTL languages
const rtlLanguages = ['fa']; // ['fa', 'ar']
const rtlLanguages = ['fa', 'ar'];
// Update HTML lang attribute and dir for RTL languages
function updateHtmlAttributes(langCode) {
+60 -15
View File
@@ -19,8 +19,8 @@ const FIRST_RUN_KEY = 'oxicloud_first_run_completed';
// Language selector texts (used before i18n is loaded)
const LANGUAGE_TEXTS = {
en: {
title: 'Welcome to OxiCloud',
subtitle: 'Please select your language',
title: 'Welcome!',
subtitle: 'Select your language to continue',
continue: 'Continue',
autodetected: 'We detected your language',
moreLanguages: 'More languages...',
@@ -28,8 +28,8 @@ const LANGUAGE_TEXTS = {
searchPlaceholder: 'Search language...'
},
es: {
title: 'Bienvenido a OxiCloud',
subtitle: 'Por favor, selecciona tu idioma',
title: '¡Bienvenido!',
subtitle: 'Selecciona tu idioma para continuar',
continue: 'Continuar',
autodetected: 'Hemos detectado tu idioma',
moreLanguages: 'More languages...',
@@ -37,8 +37,8 @@ const LANGUAGE_TEXTS = {
searchPlaceholder: 'Buscar idioma...'
},
zh: {
title: '欢迎使用 OxiCloud',
subtitle: '请选择您的语言',
title: '欢迎!',
subtitle: '选择您的语言以继续',
continue: '继续',
autodetected: '我们检测到了您的语言',
moreLanguages: '更多语言...',
@@ -46,8 +46,8 @@ const LANGUAGE_TEXTS = {
searchPlaceholder: '搜索语言...'
},
fa: {
title: 'به OxiCloud خوش آمدید',
subtitle: 'لطفا زبان خود را انتخاب کنید',
title: '!خوش آمدید',
subtitle: 'زبان خود را برای ادامه انتخاب کنید',
continue: 'ادامه',
autodetected: 'زبان شما شناسایی شد',
moreLanguages: 'زبان‌های بیشتر...',
@@ -55,14 +55,59 @@ const LANGUAGE_TEXTS = {
searchPlaceholder: 'جستجوی زبان...'
},
nl: {
title: 'Welkom bij OxiCloud',
subtitle: 'Selecteer uw taal',
title: 'Welkom!',
subtitle: 'Selecteer uw taal om door te gaan',
continue: 'Doorgaan',
autodetected: 'We hebben uw taal gedetecteerd',
moreLanguages: 'Meer talen...',
modalTitle: 'Taal selecteren',
searchPlaceholder: 'Zoek taal...'
},
hi: {
title: 'स्वागत है!',
subtitle: 'जारी रखने के लिए अपनी भाषा चुनें',
continue: 'जारी रखें',
autodetected: 'हमने आपकी भाषा पहचान ली',
moreLanguages: 'और भाषाएँ...',
modalTitle: 'भाषा चुनें',
searchPlaceholder: 'भाषा खोजें...'
},
ar: {
title: '!مرحباً',
subtitle: 'اختر لغتك للمتابعة',
continue: 'متابعة',
autodetected: 'تم اكتشاف لغتك',
moreLanguages: 'المزيد من اللغات...',
modalTitle: 'اختر اللغة',
searchPlaceholder: 'ابحث عن لغة...'
},
ru: {
title: 'Добро пожаловать!',
subtitle: 'Выберите язык для продолжения',
continue: 'Продолжить',
autodetected: 'Мы определили ваш язык',
moreLanguages: 'Больше языков...',
modalTitle: 'Выберите язык',
searchPlaceholder: 'Поиск языка...'
},
ja: {
title: 'ようこそ!',
subtitle: '続行するには言語を選択してください',
continue: '続行',
autodetected: '言語を検出しました',
moreLanguages: 'その他の言語...',
modalTitle: '言語を選択',
searchPlaceholder: '言語を検索...'
},
ko: {
title: '환영합니다!',
subtitle: '계속하려면 언어를 선택하세요',
continue: '계속',
autodetected: '언어가 감지되었습니다',
moreLanguages: '더 많은 언어...',
modalTitle: '언어 선택',
searchPlaceholder: '언어 검색...'
},
};
// Complete language registry — add new languages here, they'll appear automatically
@@ -76,11 +121,11 @@ const ALL_LANGUAGES = [
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', popular: true },
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷', popular: true },
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', popular: true },
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺', popular: false },
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵', popular: false },
{ code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷', popular: false },
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', flag: '🇸🇦', popular: false },
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', popular: false },
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺', popular: true },
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵', popular: true },
{ code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷', popular: true },
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', flag: '🇸🇦', popular: true },
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', popular: true },
{ code: 'tr', name: 'Turkish', nativeName: 'Türkçe', flag: '🇹🇷', popular: false },
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands', flag: '🇳🇱', popular: false },
{ code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱', popular: false },
+147 -61
View File
@@ -4,13 +4,19 @@ let usersPage = 0;
const PAGE_SIZE = 50;
let totalUsers = 0;
/** Escape a string for safe embedding inside a JS string literal within an HTML attribute.
* Converts all non-alphanumeric/space/dot/hyphen/underscore chars to \xHH escapes. */
/* ── i18n helper — falls back to key if i18n not ready ── */
function t(key, params) {
if (window.i18n && typeof window.i18n.t === 'function') return window.i18n.t(key, params);
// fallback: strip prefix and humanise
return key.split('.').pop().replace(/_/g, ' ');
}
/** Escape a string for safe embedding inside a JS string literal within an HTML attribute. */
function _escJs(s) {
if (typeof s !== 'string') return '';
return s.replace(/[^\w .\-]/g, function(c) {
return '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0');
});
if (typeof s !== 'string') return '';
return s.replace(/[^\w .\-]/g, function(c) {
return '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0');
});
}
function hideElement(id) {
@@ -43,22 +49,80 @@ function formatBytes(bytes) {
}
function timeAgo(dateStr) {
if (!dateStr) return 'Never';
if (!dateStr) return t('admin.never');
const d = new Date(dateStr);
const now = new Date();
const secs = Math.floor((now - d) / 1000);
if (secs < 60) return 'Just now';
if (secs < 3600) return Math.floor(secs/60) + 'm ago';
if (secs < 86400) return Math.floor(secs/3600) + 'h ago';
if (secs < 2592000) return Math.floor(secs/86400) + 'd ago';
if (secs < 60) return t('admin.just_now');
if (secs < 3600) return t('admin.minutes_ago', { n: Math.floor(secs / 60) });
if (secs < 86400) return t('admin.hours_ago', { n: Math.floor(secs / 3600) });
if (secs < 2592000) return t('admin.days_ago', { n: Math.floor(secs / 86400) });
return d.toLocaleDateString();
}
/* ── Custom confirm modal ── */
function showConfirm(message) {
return new Promise(function(resolve) {
var overlay = document.getElementById('confirm-modal');
var msgEl = document.getElementById('confirm-message');
var yesBtn = document.getElementById('confirm-yes');
var noBtn = document.getElementById('confirm-cancel');
msgEl.textContent = message;
overlay.classList.remove('hidden');
overlay.classList.add('show-flex');
function cleanup(result) {
overlay.classList.remove('show-flex');
overlay.classList.add('hidden');
yesBtn.removeEventListener('click', onYes);
noBtn.removeEventListener('click', onNo);
overlay.removeEventListener('click', onOverlay);
resolve(result);
}
function onYes() { cleanup(true); }
function onNo() { cleanup(false); }
function onOverlay(e) { if (e.target === overlay) cleanup(false); }
yesBtn.addEventListener('click', onYes);
noBtn.addEventListener('click', onNo);
overlay.addEventListener('click', onOverlay);
});
}
/* ── Tab switching with fade animation ── */
let activeTabName = 'dashboard';
function switchTab(name, el) {
document.querySelectorAll('.admin-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
if (name === activeTabName) return;
var oldTab = document.getElementById('tab-' + activeTabName);
var newTab = document.getElementById('tab-' + name);
document.querySelectorAll('.admin-tab').forEach(function(b) { b.classList.remove('active'); });
if (el) el.classList.add('active');
// Fade-out old tab
if (oldTab) {
oldTab.classList.add('tab-fade-out');
oldTab.addEventListener('animationend', function handler() {
oldTab.removeEventListener('animationend', handler);
oldTab.classList.remove('active', 'tab-fade-out');
// Fade-in new tab
if (newTab) {
newTab.classList.add('active', 'tab-fade-in');
newTab.addEventListener('animationend', function handler2() {
newTab.removeEventListener('animationend', handler2);
newTab.classList.remove('tab-fade-in');
});
}
});
} else if (newTab) {
newTab.classList.add('active', 'tab-fade-in');
newTab.addEventListener('animationend', function handler2() {
newTab.removeEventListener('animationend', handler2);
newTab.classList.remove('tab-fade-in');
});
}
activeTabName = name;
if (name === 'users') loadUsers();
if (name === 'dashboard') loadDashboard();
}
@@ -78,9 +142,9 @@ async function loadDashboard() {
const bar = document.getElementById('ds-bar');
bar.style.width = Math.min(d.storage_usage_percent, 100) + '%';
bar.className = 'progress-fill ' + (d.storage_usage_percent > 90 ? 'red' : d.storage_usage_percent > 70 ? 'orange' : 'green');
document.getElementById('ds-auth').textContent = d.auth_enabled ? 'Enabled' : 'Disabled';
document.getElementById('ds-oidc').textContent = d.oidc_configured ? 'Active' : 'Off';
document.getElementById('ds-quotas-flag').textContent = d.quotas_enabled ? 'Enabled' : 'Disabled';
document.getElementById('ds-auth').textContent = d.auth_enabled ? t('admin.enabled') : t('admin.disabled');
document.getElementById('ds-oidc').textContent = d.oidc_configured ? t('admin.active') : t('admin.off');
document.getElementById('ds-quotas-flag').textContent = d.quotas_enabled ? t('admin.enabled') : t('admin.disabled');
if (typeof d.registration_enabled !== 'undefined') {
document.getElementById('ds-registration').checked = d.registration_enabled;
@@ -101,14 +165,14 @@ async function loadDashboard() {
async function loadUsers() {
const tbody = document.getElementById('users-tbody');
tbody.innerHTML = '<tr><td colspan="7" class="table-loading-cell"><i class="fas fa-spinner fa-spin"></i> Loading…</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="table-loading-cell"><i class="fas fa-spinner fa-spin"></i> ' + escapeHtml(t('admin.loading_users')) + '</td></tr>';
try {
const resp = await fetch(API + '/admin/users?limit=' + PAGE_SIZE + '&offset=' + (usersPage * PAGE_SIZE), { headers: headers(), credentials: 'same-origin' });
if (!resp.ok) { tbody.innerHTML = '<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> Failed to load users</td></tr>'; return; }
if (!resp.ok) { tbody.innerHTML = '<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ' + escapeHtml(t('admin.failed_load_users')) + '</td></tr>'; return; }
const data = await resp.json();
totalUsers = data.total;
const users = data.users;
if (users.length === 0) { tbody.innerHTML = '<tr><td colspan="7" class="table-status-empty">No users found</td></tr>'; return; }
if (users.length === 0) { tbody.innerHTML = '<tr><td colspan="7" class="table-status-empty">' + escapeHtml(t('admin.no_users_found')) + '</td></tr>'; return; }
tbody.innerHTML = users.map(u => {
const quotaPct = u.storage_quota_bytes > 0 ? ((u.storage_used_bytes / u.storage_quota_bytes) * 100) : 0;
@@ -118,20 +182,20 @@ async function loadUsers() {
const isOidc = u.auth_provider && u.auth_provider !== 'local';
const authBadge = isOidc
? '<span class="badge badge-oidc" title="Authenticated via ' + escapeHtml(u.auth_provider) + '"><i class="fas fa-key badge-admin-icon-small"></i> ' + escapeHtml(u.auth_provider) + '</span>'
: '<span class="badge badge-local">Local</span>';
: '<span class="badge badge-local">' + escapeHtml(t('admin.local')) + '</span>';
return '<tr>' +
'<td><div class="user-info"><span class="user-name">' + escapeHtml(u.username) + (isSelf ? ' <span class="user-self-badge">(you)</span>' : '') + '</span><span class="user-email">' + escapeHtml(u.email) + '</span></div></td>' +
'<td><div class="user-info"><span class="user-name">' + escapeHtml(u.username) + (isSelf ? ' <span class="user-self-badge">' + escapeHtml(t('admin.you_badge')) + '</span>' : '') + '</span><span class="user-email">' + escapeHtml(u.email) + '</span></div></td>' +
'<td><span class="badge badge-' + escapeHtml(u.role) + '">' + (u.role === 'admin' ? '<i class="fas fa-shield-alt badge-admin-icon-small"></i> ' : '') + escapeHtml(u.role) + '</span></td>' +
'<td>' + authBadge + '</td>' +
'<td><span class="badge badge-' + (u.active ? 'active' : 'inactive') + '">' + (u.active ? 'Active' : 'Inactive') + '</span></td>' +
'<td><span class="badge badge-' + (u.active ? 'active' : 'inactive') + '">' + (u.active ? escapeHtml(t('admin.active')) : escapeHtml(t('admin.inactive'))) + '</span></td>' +
'<td><div class="quota-bar"><div class="progress-bar quota-progress-fixed"><div class="progress-fill ' + quotaColor + '" data-width="' + Math.min(quotaPct, 100) + '"></div></div><span class="quota-text">' + quotaText + '</span></div></td>' +
'<td class="user-last-login-cell">' + timeAgo(u.last_login_at) + '</td>' +
'<td><div class="actions-row">' +
'<button class="btn btn-sm btn-secondary admin-action-btn" data-action="quota" data-uid="' + _escJs(u.id) + '" data-uname="' + _escJs(u.username) + '" data-quota="' + u.storage_quota_bytes + '" title="Edit quota"><i class="fas fa-box"></i></button>' +
(isOidc ? '' : '<button class="btn btn-sm btn-secondary admin-action-btn" data-action="reset-pw" data-uid="' + _escJs(u.id) + '" data-uname="' + _escJs(u.username) + '" title="Reset password"><i class="fas fa-key"></i></button>') +
'<button class="btn btn-sm btn-secondary admin-action-btn" data-action="toggle-role" data-uid="' + _escJs(u.id) + '" data-role="' + _escJs(u.role) + '" title="Toggle role"' + (isSelf ? ' disabled' : '') + '><i class="fas fa-' + (u.role === 'admin' ? 'user' : 'crown') + '"></i></button>' +
'<button class="btn btn-sm ' + (u.active ? 'btn-danger' : 'btn-success') + ' admin-action-btn" data-action="toggle-active" data-uid="' + _escJs(u.id) + '" data-active="' + u.active + '" title="' + (u.active ? 'Deactivate' : 'Activate') + '"' + (isSelf && u.active ? ' disabled' : '') + '><i class="fas fa-' + (u.active ? 'ban' : 'check') + '"></i></button>' +
'<button class="btn btn-sm btn-danger admin-action-btn" data-action="delete" data-uid="' + _escJs(u.id) + '" data-uname="' + _escJs(u.username) + '" title="Delete"' + (isSelf ? ' disabled' : '') + '><i class="fas fa-trash-alt"></i></button>' +
'<button class="btn btn-sm btn-secondary admin-action-btn" data-action="quota" data-uid="' + _escJs(u.id) + '" data-uname="' + _escJs(u.username) + '" data-quota="' + u.storage_quota_bytes + '" title="' + escapeHtml(t('admin.edit_quota_title')) + '"><i class="fas fa-box"></i></button>' +
(isOidc ? '' : '<button class="btn btn-sm btn-secondary admin-action-btn" data-action="reset-pw" data-uid="' + _escJs(u.id) + '" data-uname="' + _escJs(u.username) + '" title="' + escapeHtml(t('admin.reset_password_title')) + '"><i class="fas fa-key"></i></button>') +
'<button class="btn btn-sm btn-secondary admin-action-btn" data-action="toggle-role" data-uid="' + _escJs(u.id) + '" data-role="' + _escJs(u.role) + '" title="' + escapeHtml(t('admin.toggle_role_title')) + '"' + (isSelf ? ' disabled' : '') + '><i class="fas fa-' + (u.role === 'admin' ? 'user' : 'crown') + '"></i></button>' +
'<button class="btn btn-sm ' + (u.active ? 'btn-danger' : 'btn-success') + ' admin-action-btn" data-action="toggle-active" data-uid="' + _escJs(u.id) + '" data-active="' + u.active + '" title="' + (u.active ? escapeHtml(t('admin.deactivate_title')) : escapeHtml(t('admin.activate_title'))) + '"' + (isSelf && u.active ? ' disabled' : '') + '><i class="fas fa-' + (u.active ? 'ban' : 'check') + '"></i></button>' +
'<button class="btn btn-sm btn-danger admin-action-btn" data-action="delete" data-uid="' + _escJs(u.id) + '" data-uname="' + _escJs(u.username) + '" title="' + escapeHtml(t('admin.delete_title')) + '"' + (isSelf ? ' disabled' : '') + '><i class="fas fa-trash-alt"></i></button>' +
'</div></td></tr>';
}).join('');
@@ -153,11 +217,13 @@ async function loadUsers() {
});
});
document.getElementById('users-info').textContent = 'Showing ' + (usersPage * PAGE_SIZE + 1) + '-' + Math.min((usersPage + 1) * PAGE_SIZE, totalUsers) + ' of ' + totalUsers;
const from = usersPage * PAGE_SIZE + 1;
const to = Math.min((usersPage + 1) * PAGE_SIZE, totalUsers);
document.getElementById('users-info').textContent = t('admin.showing_users', { from: from, to: to, total: totalUsers });
document.getElementById('prev-btn').disabled = usersPage === 0;
document.getElementById('next-btn').disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers;
} catch (e) {
tbody.innerHTML = '<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> Error: ' + escapeHtml(e.message) + '</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ' + escapeHtml(t('admin.error_network', { message: e.message })) + '</td></tr>';
}
}
@@ -166,32 +232,35 @@ function nextPage() { if ((usersPage + 1) * PAGE_SIZE < totalUsers) { usersPage+
async function toggleRole(userId, currentRole) {
const newRole = currentRole === 'admin' ? 'user' : 'admin';
if (!confirm('Change role to ' + newRole + '?')) return;
const ok = await showConfirm(t('admin.confirm_role_change', { role: newRole }));
if (!ok) return;
try {
const resp = await fetch(API + '/admin/users/' + userId + '/role', {
method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ role: newRole })
});
if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || 'Failed'); }
} catch (e) { alert('Error: ' + e.message); }
if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || t('admin.error_generic')); }
} catch (e) { alert(t('admin.error_network', { message: e.message })); }
}
async function toggleActive(userId, currentActive) {
const action = currentActive ? 'deactivate' : 'activate';
if (!confirm('Are you sure you want to ' + action + ' this user?')) return;
const msg = currentActive ? t('admin.confirm_deactivate') : t('admin.confirm_activate');
const ok = await showConfirm(msg);
if (!ok) return;
try {
const resp = await fetch(API + '/admin/users/' + userId + '/active', {
method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ active: !currentActive })
});
if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || 'Failed'); }
} catch (e) { alert('Error: ' + e.message); }
if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || t('admin.error_generic')); }
} catch (e) { alert(t('admin.error_network', { message: e.message })); }
}
async function deleteUser(userId, username) {
if (!confirm('DELETE user "' + username + '"? This cannot be undone!')) return;
const ok = await showConfirm(t('admin.confirm_delete_user', { name: username }));
if (!ok) return;
try {
const resp = await fetch(API + '/admin/users/' + userId, { method: 'DELETE', headers: headers(), credentials: 'same-origin' });
if (resp.ok) { loadUsers(); loadDashboard(); } else { const e = await resp.json(); alert(e.message || 'Failed'); }
} catch (e) { alert('Error: ' + e.message); }
if (resp.ok) { loadUsers(); loadDashboard(); } else { const e = await resp.json(); alert(e.message || t('admin.error_generic')); }
} catch (e) { alert(t('admin.error_network', { message: e.message })); }
}
let quotaUserId = '';
@@ -214,8 +283,8 @@ async function saveQuota() {
method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ quota_bytes: bytes })
});
if (resp.ok) { closeQuotaModal(); loadUsers(); loadDashboard(); }
else { const e = await resp.json(); alert(e.message || 'Failed'); }
} catch (e) { alert('Error: ' + e.message); }
else { const e = await resp.json(); alert(e.message || t('admin.error_generic')); }
} catch (e) { alert(t('admin.error_network', { message: e.message })); }
}
function openCreateUserModal() {
@@ -242,11 +311,11 @@ async function submitCreateUser() {
const quotaBytes = Math.round(quotaVal * quotaUnit);
const errorEl = document.getElementById('cu-error');
if (username.length < 3) { errorEl.textContent = 'Username must be at least 3 characters'; errorEl.className = 'alert alert-error'; return; }
if (password.length < 8) { errorEl.textContent = 'Password must be at least 8 characters'; errorEl.className = 'alert alert-error'; return; }
if (username.length < 3) { errorEl.textContent = t('admin.error_username_short'); errorEl.className = 'alert alert-error'; return; }
if (password.length < 8) { errorEl.textContent = t('admin.error_password_short'); errorEl.className = 'alert alert-error'; return; }
const btn = document.getElementById('cu-submit');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Creating…';
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> ' + escapeHtml(t('admin.creating'));
try {
const resp = await fetch(API + '/admin/users', {
method: 'POST', headers: headers(), credentials: 'same-origin',
@@ -258,14 +327,14 @@ async function submitCreateUser() {
loadDashboard();
} else {
const e = await resp.json().catch(() => ({}));
errorEl.textContent = e.message || 'Failed to create user';
errorEl.textContent = e.message || t('admin.error_create_user');
errorEl.className = 'alert alert-error';
}
} catch (e) {
errorEl.textContent = 'Network error: ' + e.message;
errorEl.textContent = t('admin.error_network', { message: e.message });
errorEl.className = 'alert alert-error';
}
btn.disabled = false; btn.innerHTML = '<i class="fas fa-user-plus"></i> Create';
btn.disabled = false; btn.innerHTML = '<i class="fas fa-user-plus"></i> ' + escapeHtml(t('admin.create_user'));
}
let resetPwUserId = '';
@@ -283,19 +352,19 @@ function closeResetPasswordModal() { hideElement('reset-pw-modal'); }
async function submitResetPassword() {
const password = document.getElementById('rp-password').value;
const errorEl = document.getElementById('rp-error');
if (password.length < 8) { errorEl.textContent = 'Password must be at least 8 characters'; errorEl.className = 'alert alert-error'; return; }
if (password.length < 8) { errorEl.textContent = t('admin.error_password_short'); errorEl.className = 'alert alert-error'; return; }
const btn = document.getElementById('rp-submit');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Resetting…';
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> ' + escapeHtml(t('admin.resetting'));
try {
const resp = await fetch(API + '/admin/users/' + resetPwUserId + '/password', {
method: 'PUT', headers: headers(), credentials: 'same-origin',
body: JSON.stringify({ new_password: password })
});
if (resp.ok) { closeResetPasswordModal(); }
else { const e = await resp.json().catch(() => ({})); errorEl.textContent = e.message || 'Failed'; errorEl.className = 'alert alert-error'; }
} catch (e) { errorEl.textContent = 'Error: ' + e.message; errorEl.className = 'alert alert-error'; }
btn.disabled = false; btn.innerHTML = '<i class="fas fa-save"></i> Reset';
else { const e = await resp.json().catch(() => ({})); errorEl.textContent = e.message || t('admin.error_generic'); errorEl.className = 'alert alert-error'; }
} catch (e) { errorEl.textContent = t('admin.error_network', { message: e.message }); errorEl.className = 'alert alert-error'; }
btn.disabled = false; btn.innerHTML = '<i class="fas fa-save"></i> ' + escapeHtml(t('admin.reset_btn'));
}
async function toggleRegistration(enabled) {
@@ -311,13 +380,13 @@ async function toggleRegistration(enabled) {
if (!enabled) showElement('registration-warning', 'flex');
else hideElement('registration-warning');
const e = await resp.json().catch(() => ({}));
alert(e.message || 'Failed to update registration setting');
alert(e.message || t('admin.error_generic'));
}
} catch (e) {
document.getElementById('ds-registration').checked = !enabled;
if (!enabled) showElement('registration-warning', 'flex');
else hideElement('registration-warning');
alert('Error: ' + e.message);
alert(t('admin.error_network', { message: e.message }));
}
}
@@ -345,7 +414,7 @@ async function testConnection() {
const url = document.getElementById('issuer-url').value.trim();
if (!url) { showOidcStatus('Enter an Issuer URL first', 'error'); return; }
const btn = document.getElementById('discover-btn');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Discovering…';
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> ' + escapeHtml(t('admin.discovering'));
const resultDiv = document.getElementById('discovery-result');
try {
const resp = await fetch(API + '/admin/settings/oidc/test', { method: 'POST', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ issuer_url: url }) });
@@ -357,12 +426,12 @@ async function testConnection() {
resultDiv.innerHTML = '<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ' + escapeHtml(r.message) + '</strong></div>';
}
} catch (e) { resultDiv.innerHTML = '<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ' + escapeHtml(e.message) + '</div>'; }
btn.disabled = false; btn.innerHTML = '<i class="fas fa-search"></i> Auto-discover';
btn.disabled = false; btn.innerHTML = '<i class="fas fa-search"></i> ' + escapeHtml(t('admin.auto_discover'));
}
async function saveOidcSettings() {
const btn = document.getElementById('save-btn');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving…';
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> ' + escapeHtml(t('admin.saving'));
const body = {
enabled: document.getElementById('oidc-enabled').checked,
issuer_url: document.getElementById('issuer-url').value.trim(),
@@ -376,10 +445,14 @@ async function saveOidcSettings() {
};
try {
const resp = await fetch(API + '/admin/settings/oidc', { method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify(body) });
if (resp.ok) { showOidcStatus('Settings saved — OIDC is now ' + (body.enabled ? 'active' : 'disabled'), 'success'); loadDashboard(); }
if (resp.ok) {
const status = body.enabled ? t('admin.active').toLowerCase() : t('admin.disabled').toLowerCase();
showOidcStatus(t('admin.settings_saved', { status: status }), 'success');
loadDashboard();
}
else { const e = await resp.json().catch(()=>({})); showOidcStatus('Error: ' + (e.message || resp.statusText), 'error'); }
} catch (e) { showOidcStatus('Network error: ' + e.message, 'error'); }
btn.disabled = false; btn.innerHTML = '<i class="fas fa-save"></i> Save';
} catch (e) { showOidcStatus(t('admin.error_network', { message: e.message }), 'error'); }
btn.disabled = false; btn.innerHTML = '<i class="fas fa-save"></i> ' + escapeHtml(t('admin.save_btn'));
}
async function init() {
@@ -424,6 +497,19 @@ function showAccessDenied() {
showElement('access-denied');
}
/* ── Apply i18n when translations load / change ── */
document.addEventListener('translationsLoaded', function() {
if (window.i18n && window.i18n.translatePage) window.i18n.translatePage();
// Re-render dynamic content that uses t()
loadDashboard();
if (activeTabName === 'users') loadUsers();
});
document.addEventListener('localeChanged', function() {
if (window.i18n && window.i18n.translatePage) window.i18n.translatePage();
loadDashboard();
if (activeTabName === 'users') loadUsers();
});
init();
/* ── Event-listener wiring (replaces inline onclick/onchange) ── */
+37 -27
View File
@@ -1,5 +1,11 @@
const API = '/api';
/* ── i18n helper — falls back to key if i18n not ready ── */
function t(key, params) {
if (window.i18n && typeof window.i18n.t === 'function') return window.i18n.t(key, params);
return key.split('.').pop().replace(/_/g, ' ');
}
function headers() {
return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
}
@@ -12,14 +18,14 @@ function formatBytes(bytes) {
}
function timeAgo(dateStr) {
if (!dateStr) return 'Never';
if (!dateStr) return t('profile.never');
const d = new Date(dateStr);
const now = new Date();
const secs = Math.floor((now - d) / 1000);
if (secs < 60) return 'Just now';
if (secs < 3600) return Math.floor(secs/60) + ' min ago';
if (secs < 86400) return Math.floor(secs/3600) + 'h ago';
if (secs < 2592000) return Math.floor(secs/86400) + ' days ago';
if (secs < 60) return t('profile.just_now');
if (secs < 3600) return t('profile.minutes_ago', { n: Math.floor(secs/60) });
if (secs < 86400) return t('profile.hours_ago', { n: Math.floor(secs/3600) });
if (secs < 2592000) return t('profile.days_ago', { n: Math.floor(secs/86400) });
return d.toLocaleDateString();
}
@@ -37,15 +43,15 @@ async function init() {
const badge = document.getElementById('p-role-badge');
if (user.role === 'admin') {
badge.className = 'role-badge role-badge-admin';
badge.innerHTML = '<i class="fas fa-shield-alt"></i> Administrator';
badge.innerHTML = '<i class="fas fa-shield-alt"></i> ' + t('profile.role_admin');
} else {
badge.className = 'role-badge role-badge-user';
badge.innerHTML = '<i class="fas fa-user"></i> User';
badge.innerHTML = '<i class="fas fa-user"></i> ' + t('profile.role_user');
}
document.getElementById('p-detail-username').textContent = user.username;
document.getElementById('p-detail-email').textContent = user.email || '—';
document.getElementById('p-detail-role').textContent = user.role === 'admin' ? 'Administrator' : 'User';
document.getElementById('p-detail-role').textContent = user.role === 'admin' ? t('profile.role_admin') : t('profile.role_user');
document.getElementById('p-detail-login').textContent = timeAgo(user.last_login_at);
const used = user.storage_used_bytes || 0;
@@ -59,7 +65,7 @@ async function init() {
const bar = document.getElementById('p-storage-bar');
bar.style.width = pct + '%';
bar.className = 'storage-fill ' + (pct > 90 ? 'red' : pct > 70 ? 'orange' : 'green');
document.getElementById('p-storage-text').textContent = formatBytes(used) + ' / ' + (quota > 0 ? formatBytes(quota) : 'Unlimited');
document.getElementById('p-storage-text').textContent = formatBytes(used) + ' / ' + (quota > 0 ? formatBytes(quota) : t('profile.unlimited'));
if (user.auth_provider && user.auth_provider !== 'local') {
document.getElementById('password-section').classList.add('hidden');
@@ -99,18 +105,18 @@ async function changePassword(e) {
const statusEl = document.getElementById('pw-status');
if (newPw !== confirmPw) {
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Passwords do not match</div>';
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + escapeHtml(t('profile.passwords_no_match')) + '</div>';
return false;
}
if (newPw.length < 8) {
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Password must be at least 8 characters</div>';
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + escapeHtml(t('profile.password_too_short')) + '</div>';
return false;
}
const btn = document.getElementById('pw-submit');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating…';
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> ' + escapeHtml(t('profile.updating'));
try {
const resp = await fetch(API + '/auth/change-password', {
@@ -121,18 +127,18 @@ async function changePassword(e) {
});
if (resp.ok) {
statusEl.innerHTML = '<div class="alert alert-success"><i class="fas fa-check-circle"></i> Password updated successfully</div>';
statusEl.innerHTML = '<div class="alert alert-success"><i class="fas fa-check-circle"></i> ' + escapeHtml(t('profile.password_updated')) + '</div>';
document.getElementById('password-form').reset();
} else {
const err = await resp.json().catch(() => ({}));
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + escapeHtml(err.message || 'Failed to change password') + '</div>';
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + escapeHtml(err.message || t('profile.password_change_failed')) + '</div>';
}
} catch (err) {
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Network error: ' + escapeHtml(err.message) + '</div>';
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + escapeHtml(t('profile.error_network', { message: err.message })) + '</div>';
}
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save"></i> Update Password';
btn.innerHTML = '<i class="fas fa-save"></i> ' + escapeHtml(t('profile.update_password'));
return false;
}
@@ -151,15 +157,15 @@ function renderPwRow(pw) {
const created = document.createElement('td');
created.textContent = new Date(pw.created_at).toLocaleDateString();
const lastUsed = document.createElement('td');
lastUsed.textContent = pw.last_used_at ? timeAgo(pw.last_used_at) : 'Never';
lastUsed.textContent = pw.last_used_at ? timeAgo(pw.last_used_at) : t('profile.never');
const status = document.createElement('td');
const badge = document.createElement('span');
if (pw.active !== false) {
badge.className = 'badge badge-active';
badge.textContent = 'Active';
badge.textContent = t('profile.active');
} else {
badge.className = 'badge badge-expired';
badge.textContent = 'Revoked';
badge.textContent = t('profile.revoked');
}
status.appendChild(badge);
const actions = document.createElement('td');
@@ -167,7 +173,7 @@ function renderPwRow(pw) {
const btn = document.createElement('button');
btn.className = 'btn btn-danger-sm';
btn.innerHTML = '<i class="fas fa-trash"></i>';
btn.title = 'Revoke';
btn.title = t('profile.revoke_title');
btn.addEventListener('click', function () { revokeAppPassword(pw.id, pw.label); });
actions.appendChild(btn);
}
@@ -232,12 +238,12 @@ async function createAppPassword() {
const btn = document.getElementById('app-pw-generate');
if (!label) {
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Please enter a label</div>';
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + escapeHtml(t('profile.error_label_required')) + '</div>';
return;
}
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating…';
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> ' + escapeHtml(t('profile.generating'));
statusEl.innerHTML = '';
try {
@@ -249,7 +255,7 @@ async function createAppPassword() {
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + (err.message || 'Failed to create app password') + '</div>';
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + escapeHtml(err.message || t('profile.error_create_pw')) + '</div>';
return;
}
const result = await resp.json();
@@ -262,7 +268,7 @@ async function createAppPassword() {
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + err.message + '</div>';
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-plus"></i> Generate';
btn.innerHTML = '<i class="fas fa-plus"></i> ' + escapeHtml(t('profile.generate'));
}
}
@@ -276,7 +282,7 @@ function copyAppPassword() {
}
async function revokeAppPassword(id, label) {
if (!confirm('Revoke app password "' + label + '"? Clients using this password will stop working.')) return;
if (!confirm(t('profile.confirm_revoke', { label: label }))) return;
try {
const resp = await fetch(API + '/auth/app-passwords/' + encodeURIComponent(id), {
method: 'DELETE',
@@ -288,10 +294,10 @@ async function revokeAppPassword(id, label) {
loadAppPasswords();
} else {
const err = await resp.json().catch(() => ({}));
alert(err.message || 'Failed to revoke app password');
alert(err.message || t('profile.error_revoke'));
}
} catch (err) {
alert('Network error: ' + err.message);
alert(t('profile.error_network', { message: err.message }));
}
}
@@ -308,3 +314,7 @@ document.getElementById('password-form').addEventListener('submit', changePasswo
document.getElementById('app-pw-generate').addEventListener('click', createAppPassword);
document.getElementById('app-pw-copy-btn').addEventListener('click', copyAppPassword);
document.getElementById('app-pw-auto-toggle').addEventListener('click', toggleAutoPasswords);
/* Re-render when language changes */
window.addEventListener('translationsLoaded', function () { init(); });
window.addEventListener('localeChanged', function () { init(); });