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:
+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