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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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) ── */
|
||||
|
||||
@@ -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(); });
|
||||
|
||||
Reference in New Issue
Block a user