Embed admin_id claim in impersonation JWTs and add a backend /api/admin/unimpersonate endpoint that re-issues the admin token from that claim. The admin token no longer needs to be stored in sessionStorage, eliminating the risk of token theft via XSS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
251 lines
9.4 KiB
JavaScript
251 lines
9.4 KiB
JavaScript
// auth.js — authentication utilities used by every authenticated page
|
|
|
|
const Auth = {
|
|
getToken() {
|
|
return localStorage.getItem('token');
|
|
},
|
|
|
|
setToken(token) {
|
|
localStorage.setItem('token', token);
|
|
},
|
|
|
|
removeToken() {
|
|
localStorage.removeItem('token');
|
|
},
|
|
|
|
getUser() {
|
|
const token = this.getToken();
|
|
if (!token) return null;
|
|
try {
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
if (payload.exp < Date.now() / 1000) {
|
|
this.removeToken();
|
|
return null;
|
|
}
|
|
return payload;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
requireAuth() {
|
|
const user = this.getUser();
|
|
if (!user) {
|
|
window.location.href = '/login';
|
|
return null;
|
|
}
|
|
return user;
|
|
},
|
|
|
|
logout() {
|
|
this.removeToken();
|
|
window.location.href = '/login';
|
|
},
|
|
};
|
|
|
|
async function returnToAdmin() {
|
|
try {
|
|
const data = await API.post('/api/admin/unimpersonate', {});
|
|
Auth.setToken(data.access_token);
|
|
window.location.href = '/admin';
|
|
} catch (err) {
|
|
Auth.logout();
|
|
}
|
|
}
|
|
|
|
// ── Timezone helpers ──────────────────────────────────────────────────────────
|
|
|
|
function getUserTimezone() {
|
|
return Auth.getUser()?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
}
|
|
|
|
function buildTimezoneOptions(selected) {
|
|
let allTz;
|
|
try {
|
|
allTz = Intl.supportedValuesOf('timeZone');
|
|
} catch (_) {
|
|
// Fallback for older browsers
|
|
allTz = ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver',
|
|
'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu',
|
|
'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo',
|
|
'Asia/Shanghai', 'Australia/Sydney'];
|
|
}
|
|
|
|
const groups = {};
|
|
for (const tz of allTz) {
|
|
const slash = tz.indexOf('/');
|
|
const group = slash === -1 ? 'Other' : tz.slice(0, slash);
|
|
(groups[group] = groups[group] || []).push(tz);
|
|
}
|
|
|
|
return Object.keys(groups).sort().map(group => {
|
|
const options = groups[group].map(tz => {
|
|
const label = tz.slice(tz.indexOf('/') + 1).replace(/_/g, ' ').replace(/\//g, ' / ');
|
|
return `<option value="${tz}"${tz === selected ? ' selected' : ''}>${label}</option>`;
|
|
}).join('');
|
|
return `<optgroup label="${group}">${options}</optgroup>`;
|
|
}).join('');
|
|
}
|
|
|
|
// ── Nav + settings modal ──────────────────────────────────────────────────────
|
|
|
|
function _checkSessionExpiry(user) {
|
|
const msLeft = (user.exp * 1000) - Date.now();
|
|
const hoursLeft = msLeft / (1000 * 60 * 60);
|
|
if (hoursLeft > 24) return;
|
|
|
|
const warning = document.createElement('div');
|
|
warning.id = 'session-warning';
|
|
const label = hoursLeft < 1
|
|
? 'Your session expires in less than an hour — please log out and back in.'
|
|
: `Your session expires in ${Math.floor(hoursLeft)} hours — please log out and back in.`;
|
|
warning.innerHTML = `<span>${label}</span><button title="Dismiss" onclick="this.parentElement.remove()">✕</button>`;
|
|
document.querySelector('.nav')?.insertAdjacentElement('afterend', warning);
|
|
}
|
|
|
|
function initNav() {
|
|
const user = Auth.requireAuth();
|
|
if (!user) return;
|
|
|
|
_checkSessionExpiry(user);
|
|
|
|
const nav = document.querySelector('.nav');
|
|
if (!nav) return;
|
|
|
|
const isImpersonating = !!user.admin_id;
|
|
const navUser = document.createElement('div');
|
|
navUser.className = 'nav-user';
|
|
|
|
if (isImpersonating) {
|
|
navUser.innerHTML = `
|
|
<span class="nav-impersonating">Viewing as <strong>${user.username}</strong></span>
|
|
<button onclick="returnToAdmin()" class="btn btn-sm btn-amber">↩ Return to Admin</button>
|
|
`;
|
|
} else {
|
|
navUser.innerHTML = `
|
|
<span class="nav-username">${user.username}</span>
|
|
${user.is_admin ? '<a href="/admin" class="btn btn-sm btn-ghost nav-admin-btn">Admin</a>' : ''}
|
|
<button onclick="showSettingsModal()" class="btn btn-sm btn-ghost" title="Settings">⚙</button>
|
|
<button onclick="Auth.logout()" class="btn btn-sm btn-ghost">Logout</button>
|
|
`;
|
|
}
|
|
|
|
nav.appendChild(navUser);
|
|
|
|
if (!isImpersonating) {
|
|
const tzOptions = buildTimezoneOptions(user.timezone || 'UTC');
|
|
document.body.insertAdjacentHTML('beforeend', `
|
|
<div id="settings-modal" class="modal-overlay" style="display:none">
|
|
<div class="modal-box">
|
|
<h2>Settings</h2>
|
|
<div id="settings-msg" class="message"></div>
|
|
|
|
<h3 class="settings-section-title">Timezone</h3>
|
|
<div class="form-group" style="margin-bottom:0.5rem">
|
|
<label for="tz-select">Your timezone</label>
|
|
<select id="tz-select">${tzOptions}</select>
|
|
</div>
|
|
<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:1.5rem">
|
|
<button class="btn btn-sm btn-ghost" onclick="detectTimezone()">Detect automatically</button>
|
|
<button class="btn btn-primary btn-sm" onclick="submitTimezone()">Save Timezone</button>
|
|
</div>
|
|
|
|
<hr class="settings-divider">
|
|
|
|
<h3 class="settings-section-title">Change Password</h3>
|
|
<div class="form-group" style="margin-bottom:0.75rem">
|
|
<label>Current Password</label>
|
|
<input type="password" id="pw-current" autocomplete="current-password">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:0.75rem">
|
|
<label>New Password</label>
|
|
<input type="password" id="pw-new" autocomplete="new-password" minlength="10">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:1rem">
|
|
<label>Confirm New Password</label>
|
|
<input type="password" id="pw-confirm" autocomplete="new-password">
|
|
</div>
|
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
|
|
<button class="btn btn-ghost" onclick="hideSettingsModal()">Close</button>
|
|
<button class="btn btn-primary" onclick="submitPasswordChange()">Update Password</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
}
|
|
}
|
|
|
|
function showSettingsModal() {
|
|
document.getElementById('settings-modal').style.display = 'flex';
|
|
document.getElementById('settings-msg').className = 'message';
|
|
document.getElementById('pw-current').value = '';
|
|
document.getElementById('pw-new').value = '';
|
|
document.getElementById('pw-confirm').value = '';
|
|
}
|
|
|
|
function hideSettingsModal() {
|
|
document.getElementById('settings-modal').style.display = 'none';
|
|
}
|
|
|
|
function detectTimezone() {
|
|
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
const sel = document.getElementById('tz-select');
|
|
if (sel) sel.value = detected;
|
|
}
|
|
|
|
async function submitTimezone() {
|
|
const tz = document.getElementById('tz-select').value;
|
|
const msgEl = document.getElementById('settings-msg');
|
|
try {
|
|
const data = await API.put('/api/auth/timezone', { timezone: tz });
|
|
Auth.setToken(data.access_token);
|
|
msgEl.textContent = `Timezone saved: ${tz.replace(/_/g, ' ')}`;
|
|
msgEl.className = 'message success visible';
|
|
setTimeout(() => { msgEl.className = 'message'; }, 3000);
|
|
} catch (err) {
|
|
msgEl.textContent = err.message;
|
|
msgEl.className = 'message error visible';
|
|
}
|
|
}
|
|
|
|
async function submitPasswordChange() {
|
|
const current = document.getElementById('pw-current').value;
|
|
const newPw = document.getElementById('pw-new').value;
|
|
const confirm = document.getElementById('pw-confirm').value;
|
|
const msgEl = document.getElementById('settings-msg');
|
|
|
|
if (newPw !== confirm) {
|
|
msgEl.textContent = 'New passwords do not match';
|
|
msgEl.className = 'message error visible';
|
|
return;
|
|
}
|
|
if (newPw.length < 10) {
|
|
msgEl.textContent = 'Password must be at least 10 characters';
|
|
msgEl.className = 'message error visible';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await API.post('/api/auth/change-password', {
|
|
current_password: current,
|
|
new_password: newPw,
|
|
});
|
|
msgEl.textContent = 'Password updated!';
|
|
msgEl.className = 'message success visible';
|
|
document.getElementById('pw-current').value = '';
|
|
document.getElementById('pw-new').value = '';
|
|
document.getElementById('pw-confirm').value = '';
|
|
setTimeout(() => { msgEl.className = 'message'; }, 3000);
|
|
} catch (err) {
|
|
msgEl.textContent = err.message;
|
|
msgEl.className = 'message error visible';
|
|
}
|
|
}
|
|
|
|
document.addEventListener('click', (e) => {
|
|
const modal = document.getElementById('settings-modal');
|
|
if (modal && e.target === modal) hideSettingsModal();
|
|
});
|
|
|
|
document.addEventListener('DOMContentLoaded', initNav);
|