feat: replace settings modal with full-page sidebar layout

Convert the users/admin modal into a dedicated settings page with
left sidebar navigation and spacious content panels. Hash-based
routing (#settings/users, #settings/smtp, etc.) enables browser
back-button support and direct URL access. Admin-only tabs are
hidden for non-admin users.
This commit is contained in:
2026-04-11 09:43:24 -06:00
parent a4be7c4cff
commit fd36c25636
3 changed files with 373 additions and 150 deletions
+122 -2
View File
@@ -22,11 +22,17 @@ body {
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
height: 100vh; height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden; overflow: hidden;
} }
#main-app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
#main-app[hidden] { display: none; }
/* ── Header ── */ /* ── Header ── */
header { header {
background: var(--header-bg); background: var(--header-bg);
@@ -879,3 +885,117 @@ input[type="file"] {
@media (max-width: 768px), (orientation: portrait) { @media (max-width: 768px), (orientation: portrait) {
#btn-layout-editor { display: none !important; } #btn-layout-editor { display: none !important; }
} }
/* ── Settings Page ── */
.settings-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.settings-page[hidden] { display: none; }
.settings-header {
background: var(--header-bg);
color: var(--header-fg);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 44px;
flex-shrink: 0;
}
.settings-header-left { display: flex; align-items: center; gap: 12px; }
.settings-header-right { display: flex; align-items: center; gap: 10px; }
.settings-back-link {
color: rgba(255,255,255,0.7);
text-decoration: none;
font-size: 13px;
font-weight: 500;
transition: color 0.15s;
}
.settings-back-link:hover { color: #fff; }
.settings-layout {
display: flex;
flex: 1;
overflow: hidden;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.settings-sidebar {
width: 220px;
flex-shrink: 0;
padding: 20px 0;
border-right: 1px solid var(--border);
overflow-y: auto;
background: var(--surface);
}
.settings-sidebar-title {
font-size: 15px;
font-weight: 700;
padding: 4px 20px 16px;
color: var(--text);
}
.settings-nav-group { margin-bottom: 8px; }
.settings-nav-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
padding: 12px 20px 6px;
}
.settings-nav-item {
display: block;
padding: 7px 20px;
font-size: 13px;
color: var(--text);
text-decoration: none;
border-radius: 6px;
margin: 1px 8px;
transition: background 0.12s;
}
.settings-nav-item:hover { background: var(--bg); }
.settings-nav-item.active {
background: var(--primary);
color: #fff;
font-weight: 500;
}
.settings-content {
flex: 1;
padding: 32px 48px;
overflow-y: auto;
max-width: 780px;
}
.settings-panel h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text);
}
.settings-panel h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text);
}
.settings-desc {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 20px;
line-height: 1.5;
}
.settings-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
margin-bottom: 20px;
}
.settings-section .form-row { gap: 16px; }
.settings-section .form-group { margin-bottom: 4px; }
+164 -121
View File
@@ -87,6 +87,169 @@
</div> </div>
</div> </div>
<!-- Settings page (full-page, hidden by default) -->
<div id="settings-page" class="settings-page" hidden>
<header class="settings-header">
<div class="settings-header-left">
<a href="#" id="settings-back-link" class="settings-back-link">&larr; Back</a>
</div>
<div class="settings-header-right">
<span id="settings-username" class="header-username"></span>
<button id="btn-settings-logout" class="btn-header-icon" title="Sign out"></button>
</div>
</header>
<div class="settings-layout">
<nav class="settings-sidebar">
<div class="settings-sidebar-title">Settings</div>
<div class="settings-nav-group" data-admin-only>
<div class="settings-nav-label">Administration</div>
<a href="#settings/users" class="settings-nav-item" data-settings-tab="users">Users</a>
<a href="#settings/smtp" class="settings-nav-item" data-settings-tab="smtp">Email (SMTP)</a>
</div>
<div class="settings-nav-group">
<div class="settings-nav-label">Account</div>
<a href="#settings/password" class="settings-nav-item" data-settings-tab="password">Password</a>
<a href="#settings/sso" class="settings-nav-item" data-settings-tab="sso">Single Sign-On</a>
</div>
</nav>
<main class="settings-content">
<!-- Users panel (admin only) -->
<div id="settings-panel-users" class="settings-panel" hidden>
<h2>Users</h2>
<p class="settings-desc">Manage user accounts and access permissions.</p>
<div class="settings-section">
<div id="users-list"></div>
</div>
<div class="settings-section" id="user-form-section">
<h3 id="user-form-title">Add User</h3>
<div class="form-row">
<div class="form-group required">
<label for="uf-username">Username</label>
<input type="text" id="uf-username" autocapitalize="none">
</div>
<div class="form-group">
<label for="uf-email">Email <span class="field-hint">(for password reset)</span></label>
<input type="email" id="uf-email" autocomplete="email">
</div>
<div class="form-group required">
<label for="uf-password">Password <span class="field-hint" id="uf-password-hint">(min 10 chars, include a digit or symbol)</span></label>
<input type="password" id="uf-password" autocomplete="new-password">
</div>
<div class="form-group required">
<label for="uf-role">Role</label>
<select id="uf-role">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div class="form-group" id="uf-accounts-group">
<label>Account Access <span class="field-hint">(admins see all — no selection needed)</span></label>
<div id="uf-accounts-checkboxes" class="account-checkboxes"></div>
</div>
<div id="uf-oidc-group" class="form-row" hidden>
<div class="form-group">
<label for="uf-oidc-sub">OIDC Subject <span class="field-hint">(sub claim from provider)</span></label>
<input type="text" id="uf-oidc-sub" autocomplete="off">
</div>
<div class="form-group">
<label for="uf-oidc-issuer">OIDC Issuer <span class="field-hint">(provider URL)</span></label>
<input type="text" id="uf-oidc-issuer" autocomplete="off">
</div>
</div>
<div id="user-form-error" class="wizard-error" hidden></div>
<div style="display:flex;gap:8px;margin-top:12px">
<button id="btn-save-user" class="btn-primary">Add User</button>
<button id="btn-cancel-user-edit" class="btn-ghost" hidden>Cancel</button>
</div>
</div>
</div>
<!-- SMTP panel (admin only) -->
<div id="settings-panel-smtp" class="settings-panel" hidden>
<h2>Email Settings</h2>
<p class="settings-desc">Configure SMTP for password reset emails.</p>
<div class="settings-section" id="smtp-settings-section">
<div class="form-row">
<div class="form-group">
<label for="smtp-host">SMTP Host</label>
<input type="text" id="smtp-host" placeholder="smtp.example.com">
</div>
<div class="form-group" style="max-width:100px">
<label for="smtp-port">Port</label>
<input type="number" id="smtp-port" value="587" min="1" max="65535">
</div>
<div class="form-group" style="max-width:160px">
<label for="smtp-secure">Encryption</label>
<select id="smtp-secure">
<option value="0">STARTTLS (587)</option>
<option value="1">SSL/TLS (465)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="smtp-user">Username</label>
<input type="text" id="smtp-user" autocomplete="off">
</div>
<div class="form-group">
<label for="smtp-pass">Password <span class="field-hint" id="smtp-pass-hint"></span></label>
<input type="password" id="smtp-pass" autocomplete="new-password">
</div>
<div class="form-group">
<label for="smtp-from">From Address</label>
<input type="email" id="smtp-from" placeholder="ezcheck@example.com">
</div>
</div>
<div id="smtp-error" class="wizard-error" hidden></div>
<div id="smtp-success" class="import-result" hidden></div>
<button id="btn-save-smtp" class="btn-secondary" style="margin-top:12px">Save Email Settings</button>
</div>
</div>
<!-- Password panel (all users) -->
<div id="settings-panel-password" class="settings-panel" hidden>
<h2>Change Password</h2>
<p class="settings-desc">Update your login password.</p>
<div class="settings-section">
<div class="form-row">
<div class="form-group">
<label for="cp-current">Current Password</label>
<input type="password" id="cp-current" autocomplete="current-password">
</div>
<div class="form-group">
<label for="cp-new">New Password <span class="field-hint">(min 10 chars, include a digit or symbol)</span></label>
<input type="password" id="cp-new" autocomplete="new-password">
</div>
<div class="form-group">
<label for="cp-confirm">Confirm New</label>
<input type="password" id="cp-confirm" autocomplete="new-password">
</div>
</div>
<div id="cp-error" class="wizard-error" hidden></div>
<div id="cp-success" class="import-result" hidden>Password changed.</div>
<button id="btn-change-password" class="btn-secondary" style="margin-top:12px">Change Password</button>
</div>
</div>
<!-- SSO panel (all users, shown when OIDC enabled) -->
<div id="settings-panel-sso" class="settings-panel" hidden>
<h2>Single Sign-On</h2>
<p class="settings-desc">Link your account to an external identity provider.</p>
<div class="settings-section" id="oidc-link-section">
<p id="oidc-link-status" class="settings-desc" style="margin-bottom:12px"></p>
<a id="btn-oidc-link" href="/api/auth/oidc/link" class="btn-secondary" style="display:inline-block;text-decoration:none">Link My Account</a>
<button id="btn-oidc-unlink" class="btn-ghost" style="margin-left:8px" hidden>Unlink</button>
</div>
</div>
</main>
</div>
</div>
<div id="main-app">
<header> <header>
<div class="header-left"> <div class="header-left">
<span class="header-brand" id="company-name">ezcheck</span> <span class="header-brand" id="company-name">ezcheck</span>
@@ -657,128 +820,8 @@
</div> </div>
</div> </div>
</aside> </aside>
</div><!-- /main-app -->
<!-- User management modal (admin only) -->
<div id="users-overlay" class="modal-overlay"></div>
<div id="users-modal" class="modal modal-wide" role="dialog" aria-labelledby="users-title">
<div class="modal-header">
<h2 id="users-title">Manage Users</h2>
<button id="btn-close-users" class="btn-icon" title="Close">×</button>
</div>
<div class="modal-body">
<div id="users-list"></div>
<div id="user-form-section" style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px" id="user-form-title">Add User</h3>
<div class="form-row">
<div class="form-group required">
<label for="uf-username">Username</label>
<input type="text" id="uf-username" autocapitalize="none">
</div>
<div class="form-group">
<label for="uf-email">Email <span class="field-hint">(for password reset)</span></label>
<input type="email" id="uf-email" autocomplete="email">
</div>
<div class="form-group required">
<label for="uf-password">Password <span class="field-hint" id="uf-password-hint">(min 10 chars, include a digit or symbol)</span></label>
<input type="password" id="uf-password" autocomplete="new-password">
</div>
<div class="form-group required">
<label for="uf-role">Role</label>
<select id="uf-role">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div class="form-group" id="uf-accounts-group">
<label>Account Access <span class="field-hint">(admins see all — no selection needed)</span></label>
<div id="uf-accounts-checkboxes" class="account-checkboxes"></div>
</div>
<div id="uf-oidc-group" class="form-row" hidden>
<div class="form-group">
<label for="uf-oidc-sub">OIDC Subject <span class="field-hint">(sub claim from provider)</span></label>
<input type="text" id="uf-oidc-sub" autocomplete="off">
</div>
<div class="form-group">
<label for="uf-oidc-issuer">OIDC Issuer <span class="field-hint">(provider URL)</span></label>
<input type="text" id="uf-oidc-issuer" autocomplete="off">
</div>
</div>
<div id="user-form-error" class="wizard-error" hidden></div>
<div style="display:flex;gap:8px;margin-top:8px">
<button id="btn-save-user" class="btn-primary">Add User</button>
<button id="btn-cancel-user-edit" class="btn-ghost" hidden>Cancel</button>
</div>
</div>
<!-- SMTP settings (admin only) -->
<div id="smtp-settings-section" style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Email Settings (SMTP)</h3>
<div class="form-row">
<div class="form-group">
<label for="smtp-host">SMTP Host</label>
<input type="text" id="smtp-host" placeholder="smtp.example.com">
</div>
<div class="form-group" style="max-width:90px">
<label for="smtp-port">Port</label>
<input type="number" id="smtp-port" value="587" min="1" max="65535">
</div>
<div class="form-group" style="max-width:140px">
<label for="smtp-secure">Encryption</label>
<select id="smtp-secure">
<option value="0">STARTTLS (587)</option>
<option value="1">SSL/TLS (465)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="smtp-user">Username</label>
<input type="text" id="smtp-user" autocomplete="off">
</div>
<div class="form-group">
<label for="smtp-pass">Password <span class="field-hint" id="smtp-pass-hint"></span></label>
<input type="password" id="smtp-pass" autocomplete="new-password">
</div>
<div class="form-group">
<label for="smtp-from">From Address</label>
<input type="email" id="smtp-from" placeholder="ezcheck@example.com">
</div>
</div>
<div id="smtp-error" class="wizard-error" hidden></div>
<div id="smtp-success" class="import-result" hidden></div>
<button id="btn-save-smtp" class="btn-secondary" style="margin-top:8px">Save Email Settings</button>
</div>
<!-- Change own password -->
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Change My Password</h3>
<div class="form-row">
<div class="form-group">
<label for="cp-current">Current Password</label>
<input type="password" id="cp-current" autocomplete="current-password">
</div>
<div class="form-group">
<label for="cp-new">New Password <span class="field-hint">(min 10 chars, include a digit or symbol)</span></label>
<input type="password" id="cp-new" autocomplete="new-password">
</div>
<div class="form-group">
<label for="cp-confirm">Confirm New</label>
<input type="password" id="cp-confirm" autocomplete="new-password">
</div>
</div>
<div id="cp-error" class="wizard-error" hidden></div>
<div id="cp-success" class="import-result" hidden>Password changed.</div>
<button id="btn-change-password" class="btn-secondary" style="margin-top:8px">Change Password</button>
</div>
<!-- Link my OIDC identity (self-service, shown when OIDC is enabled) -->
<div id="oidc-link-section" style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px" hidden>
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Single Sign-On</h3>
<p id="oidc-link-status" style="font-size:12px;color:var(--text-muted);margin-bottom:8px"></p>
<a id="btn-oidc-link" href="/api/auth/oidc/link" class="btn-secondary" style="display:inline-block;text-decoration:none">Link My Account</a>
<button id="btn-oidc-unlink" class="btn-ghost" style="margin-left:8px" hidden>Unlink</button>
</div>
</div>
</div>
<!-- Layout Editor Modal --> <!-- Layout Editor Modal -->
<div id="layout-editor-overlay" class="modal-overlay"></div> <div id="layout-editor-overlay" class="modal-overlay"></div>
+87 -27
View File
@@ -65,7 +65,8 @@ async function checkAuth() {
return false; return false;
} }
if (location.hash === '#oidc-linked') { if (location.hash === '#oidc-linked') {
history.replaceState(null, '', location.pathname); // After OIDC link callback, navigate to SSO settings panel
location.hash = '#settings/sso';
// Fall through to normal auth check — user is still logged in // Fall through to normal auth check — user is still logged in
} }
@@ -75,6 +76,8 @@ async function checkAuth() {
state.user = await res.json(); state.user = await res.json();
hideLoginOverlay(); hideLoginOverlay();
applyRoleUI(); applyRoleUI();
// Route to settings page if hash says so
if (location.hash.startsWith('#settings')) handleHashRoute();
return true; return true;
} }
// No session — check if this is first-run (no users at all) // No session — check if this is first-run (no users at all)
@@ -165,6 +168,9 @@ async function logout() {
document.getElementById('login-error').hidden = true; document.getElementById('login-error').hidden = true;
document.getElementById('login-setup-section').hidden = true; document.getElementById('login-setup-section').hidden = true;
document.getElementById('login-form-section').hidden = false; document.getElementById('login-form-section').hidden = false;
// Ensure settings page is hidden and main app restored
document.getElementById('settings-page').hidden = true;
document.getElementById('main-app').hidden = false;
showLoginOverlay(); showLoginOverlay();
} }
@@ -176,8 +182,9 @@ function applyRoleUI() {
const isEditor = state.accountRole === 'editor' || (!state.accountRole && (role === 'admin' || role === 'editor')); const isEditor = state.accountRole === 'editor' || (!state.accountRole && (role === 'admin' || role === 'editor'));
document.getElementById('header-username').textContent = state.user ? state.user.username : ''; document.getElementById('header-username').textContent = state.user ? state.user.username : '';
document.getElementById('settings-username').textContent = state.user ? state.user.username : '';
// Admin-only elements // Admin-only elements (main app + settings sidebar)
document.querySelectorAll('[data-admin-only]').forEach(el => { el.hidden = !isAdmin; }); document.querySelectorAll('[data-admin-only]').forEach(el => { el.hidden = !isAdmin; });
// Editor+ elements (hide for viewers) // Editor+ elements (hide for viewers)
@@ -191,28 +198,62 @@ function applyRoleUI() {
let usersState = { users: [], editingId: null }; let usersState = { users: [], editingId: null };
function openUsersModal() { // ── Settings page navigation ────────────────────────────────────────────────
function navigateToSettings(tab) {
const isAdmin = state.user && state.user.role === 'admin'; const isAdmin = state.user && state.user.role === 'admin';
document.getElementById('user-form-error').hidden = true; const defaultTab = isAdmin ? 'users' : 'password';
document.getElementById('users-title').textContent = isAdmin ? 'Manage Users' : 'My Account'; const resolved = tab || defaultTab;
document.getElementById('users-overlay').classList.add('open');
document.getElementById('users-modal').classList.add('open'); // Guard non-admin from admin tabs
// Admin-only sections if (!isAdmin && (resolved === 'users' || resolved === 'smtp')) {
document.getElementById('users-list').hidden = !isAdmin; location.hash = '#settings/password';
document.getElementById('user-form-section').hidden = !isAdmin; return;
document.getElementById('smtp-settings-section').hidden = !isAdmin;
if (isAdmin) {
loadUsers();
renderUfAccountCheckboxes();
loadSmtpSettings();
} }
document.getElementById('main-app').hidden = true;
const sp = document.getElementById('settings-page');
sp.hidden = false;
document.getElementById('settings-username').textContent = state.user ? state.user.username : '';
// Check OIDC status to show/hide SSO tab
loadOidcLinkStatus(); loadOidcLinkStatus();
activateSettingsTab(resolved);
} }
function closeUsersModal() { function activateSettingsTab(tab) {
document.getElementById('users-overlay').classList.remove('open'); // Hide all panels, show the target
document.getElementById('users-modal').classList.remove('open'); document.querySelectorAll('.settings-panel').forEach(p => { p.hidden = true; });
cancelUserEdit(); const panel = document.getElementById('settings-panel-' + tab);
if (panel) panel.hidden = false;
// Update sidebar active state
document.querySelectorAll('.settings-nav-item').forEach(a => {
a.classList.toggle('active', a.dataset.settingsTab === tab);
});
// Load data for the activated tab
if (tab === 'users') { loadUsers(); renderUfAccountCheckboxes(); }
if (tab === 'smtp') { loadSmtpSettings(); }
if (tab === 'sso') { loadOidcLinkStatus(); }
}
function showMainApp() {
document.getElementById('settings-page').hidden = true;
document.getElementById('main-app').hidden = false;
}
function handleHashRoute() {
const hash = location.hash;
if (hash.startsWith('#settings/')) {
const tab = hash.split('/')[1];
navigateToSettings(tab);
} else if (hash.startsWith('#settings')) {
navigateToSettings();
} else {
showMainApp();
}
} }
async function loadUsers() { async function loadUsers() {
@@ -1664,9 +1705,12 @@ async function saveSmtpSettings() {
async function loadOidcLinkStatus() { async function loadOidcLinkStatus() {
try { try {
const cfg = await fetch('/api/auth/oidc/config').then(r => r.json()); const cfg = await fetch('/api/auth/oidc/config').then(r => r.json());
const section = document.getElementById('oidc-link-section'); const ssoNavItem = document.querySelector('[data-settings-tab="sso"]');
if (!cfg.enabled) { section.hidden = true; return; } if (!cfg.enabled) {
section.hidden = false; if (ssoNavItem) ssoNavItem.hidden = true;
return;
}
if (ssoNavItem) ssoNavItem.hidden = false;
const me = await apiFetch('GET', '/api/auth/me'); const me = await apiFetch('GET', '/api/auth/me');
const statusEl = document.getElementById('oidc-link-status'); const statusEl = document.getElementById('oidc-link-status');
@@ -1886,11 +1930,27 @@ async function init() {
document.getElementById('btn-reset-submit').addEventListener('click', submitResetPassword); document.getElementById('btn-reset-submit').addEventListener('click', submitResetPassword);
document.getElementById('reset-password2').addEventListener('keydown', e => { if (e.key === 'Enter') submitResetPassword(); }); document.getElementById('reset-password2').addEventListener('keydown', e => { if (e.key === 'Enter') submitResetPassword(); });
// User management // User management / settings page
document.getElementById('btn-users').addEventListener('click', openUsersModal); document.getElementById('btn-users').addEventListener('click', () => { location.hash = '#settings/users'; });
document.getElementById('header-username').addEventListener('click', openUsersModal); document.getElementById('header-username').addEventListener('click', () => {
document.getElementById('btn-close-users').addEventListener('click', closeUsersModal); const isAdmin = state.user && state.user.role === 'admin';
document.getElementById('users-overlay').addEventListener('click', closeUsersModal); location.hash = isAdmin ? '#settings/users' : '#settings/password';
});
document.getElementById('settings-back-link').addEventListener('click', e => {
e.preventDefault();
location.hash = '';
});
document.getElementById('btn-settings-logout').addEventListener('click', () => {
location.hash = '';
logout();
});
// Sidebar tab navigation
document.querySelectorAll('.settings-nav-item').forEach(a => {
a.addEventListener('click', e => { e.preventDefault(); location.hash = a.getAttribute('href'); });
});
window.addEventListener('hashchange', () => {
if (state.user) handleHashRoute();
});
document.getElementById('users-list').addEventListener('click', e => { document.getElementById('users-list').addEventListener('click', e => {
const editBtn = e.target.closest('.user-btn-edit'); const editBtn = e.target.closest('.user-btn-edit');
const deleteBtn = e.target.closest('.user-btn-delete'); const deleteBtn = e.target.closest('.user-btn-delete');