feat: add OIDC login with account linking
Add OpenID Connect as an alternative login method. Users can sign in via an external identity provider (e.g., Authentik, Keycloak, Google). - OIDC settings configured in admin UI (discovery URL, client ID/secret, redirect URI, button label, enable/disable toggle) - PKCE-based authorization code flow with state and nonce validation - Admin can manually link any user's OIDC identity (sub/issuer fields) - Self-service linking: logged-in users can link/unlink their own account - SSO button conditionally shown on login page when OIDC is enabled - Username in header now clickable to open profile for all users - Callback errors/success communicated via URL hash fragments
This commit is contained in:
+65
-2
@@ -43,6 +43,10 @@
|
||||
</div>
|
||||
<div id="login-error" class="wizard-error" hidden></div>
|
||||
<button id="btn-login-submit" class="btn-primary" style="width:100%;margin-top:8px">Sign In</button>
|
||||
<div id="oidc-login-section" hidden>
|
||||
<div style="text-align:center;margin:12px 0 4px;color:var(--text-muted);font-size:12px">or</div>
|
||||
<a id="btn-oidc-login" href="/api/auth/oidc/authorize" class="btn-secondary" style="width:100%;display:block;text-align:center;text-decoration:none">Sign in with SSO</a>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:8px">
|
||||
<a href="#" id="link-forgot-password" style="font-size:12px;color:var(--text-muted)">Forgot password?</a>
|
||||
</div>
|
||||
@@ -91,7 +95,7 @@
|
||||
<div class="header-right">
|
||||
<span class="header-info">Next check: <strong id="current-check-no">—</strong><button id="btn-set-check-no" class="btn-header-inline" title="Set next check number" data-admin-only>✎</button></span>
|
||||
<button id="btn-users" class="btn-header-icon" title="Manage users" data-admin-only hidden>👥</button>
|
||||
<span id="header-username" class="header-username"></span>
|
||||
<span id="header-username" class="header-username" style="cursor:pointer" title="Account settings"></span>
|
||||
<button id="btn-logout" class="btn-header-icon" title="Sign out">↩</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -648,7 +652,7 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="users-list"></div>
|
||||
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
|
||||
<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">
|
||||
@@ -676,6 +680,16 @@
|
||||
<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>
|
||||
@@ -720,6 +734,48 @@
|
||||
<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>
|
||||
<!-- OIDC settings (admin only) -->
|
||||
<div id="oidc-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">Single Sign-On (OIDC)</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="max-width:100px">
|
||||
<label for="oidc-enabled">Enabled</label>
|
||||
<select id="oidc-enabled">
|
||||
<option value="0">No</option>
|
||||
<option value="1">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="oidc-button-label">Button Label</label>
|
||||
<input type="text" id="oidc-button-label" placeholder="Sign in with SSO">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="oidc-discovery-url">Discovery URL</label>
|
||||
<input type="url" id="oidc-discovery-url" placeholder="https://auth.example.com/.well-known/openid-configuration">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="oidc-client-id">Client ID</label>
|
||||
<input type="text" id="oidc-client-id" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="oidc-client-secret">Client Secret <span class="field-hint" id="oidc-secret-hint"></span></label>
|
||||
<input type="password" id="oidc-client-secret" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="oidc-redirect-uri">Redirect URI <span class="field-hint">(full external callback URL)</span></label>
|
||||
<input type="url" id="oidc-redirect-uri" placeholder="https://checks.example.com/api/auth/oidc/callback">
|
||||
</div>
|
||||
</div>
|
||||
<div id="oidc-error" class="wizard-error" hidden></div>
|
||||
<div id="oidc-success" class="import-result" hidden></div>
|
||||
<button id="btn-save-oidc" class="btn-secondary" style="margin-top:8px">Save OIDC 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>
|
||||
@@ -741,6 +797,13 @@
|
||||
<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>
|
||||
|
||||
|
||||
+140
-4
@@ -52,6 +52,23 @@ async function checkAuth() {
|
||||
showLoginOverlay();
|
||||
return false;
|
||||
}
|
||||
|
||||
// OIDC callback error/success detection
|
||||
if (location.hash.startsWith('#oidc-error=')) {
|
||||
const msg = decodeURIComponent(location.hash.slice('#oidc-error='.length));
|
||||
history.replaceState(null, '', location.pathname);
|
||||
showLoginSection('login-form-section');
|
||||
const errEl = document.getElementById('login-error');
|
||||
errEl.textContent = msg;
|
||||
errEl.hidden = false;
|
||||
showLoginOverlay();
|
||||
return false;
|
||||
}
|
||||
if (location.hash === '#oidc-linked') {
|
||||
history.replaceState(null, '', location.pathname);
|
||||
// Fall through to normal auth check — user is still logged in
|
||||
}
|
||||
|
||||
// Is there already a session?
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (res.ok) {
|
||||
@@ -68,10 +85,27 @@ async function checkAuth() {
|
||||
} else {
|
||||
showLoginSection('login-form-section');
|
||||
}
|
||||
// Show SSO button if OIDC is enabled
|
||||
loadOidcLoginButton();
|
||||
showLoginOverlay();
|
||||
return false;
|
||||
}
|
||||
|
||||
async function loadOidcLoginButton() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/oidc/config');
|
||||
if (!res.ok) return;
|
||||
const cfg = await res.json();
|
||||
const section = document.getElementById('oidc-login-section');
|
||||
if (cfg.enabled) {
|
||||
document.getElementById('btn-oidc-login').textContent = cfg.button_label || 'Sign in with SSO';
|
||||
section.hidden = false;
|
||||
} else {
|
||||
section.hidden = true;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function submitLogin() {
|
||||
const username = document.getElementById('login-username').value.trim();
|
||||
const password = document.getElementById('login-password').value;
|
||||
@@ -158,12 +192,23 @@ function applyRoleUI() {
|
||||
let usersState = { users: [], editingId: null };
|
||||
|
||||
function openUsersModal() {
|
||||
const isAdmin = state.user && state.user.role === 'admin';
|
||||
document.getElementById('user-form-error').hidden = true;
|
||||
document.getElementById('users-title').textContent = isAdmin ? 'Manage Users' : 'My Account';
|
||||
document.getElementById('users-overlay').classList.add('open');
|
||||
document.getElementById('users-modal').classList.add('open');
|
||||
loadUsers();
|
||||
renderUfAccountCheckboxes();
|
||||
if (state.user && state.user.role === 'admin') loadSmtpSettings();
|
||||
// Admin-only sections
|
||||
document.getElementById('users-list').hidden = !isAdmin;
|
||||
document.getElementById('user-form-section').hidden = !isAdmin;
|
||||
document.getElementById('smtp-settings-section').hidden = !isAdmin;
|
||||
document.getElementById('oidc-settings-section').hidden = !isAdmin;
|
||||
if (isAdmin) {
|
||||
loadUsers();
|
||||
renderUfAccountCheckboxes();
|
||||
loadSmtpSettings();
|
||||
loadOidcSettings();
|
||||
}
|
||||
loadOidcLinkStatus();
|
||||
}
|
||||
|
||||
function closeUsersModal() {
|
||||
@@ -204,8 +249,9 @@ function renderUsersList() {
|
||||
const name = escHtml(a ? (a.company1 || `Account ${a.account_id}`) : `#${ua.account_id}`);
|
||||
return `${name} <span style="font-size:10px;color:${ua.role === 'editor' ? '#16a34a' : '#6b7280'};font-weight:600;text-transform:uppercase">${ua.role}</span>`;
|
||||
}).join(', ') : '<em style="color:var(--text-muted)">None</em>');
|
||||
const oidcTag = u.oidc_sub ? ' <span style="font-size:10px;color:#2563eb;font-weight:600" title="OIDC linked">SSO</span>' : '';
|
||||
return `<tr>
|
||||
<td><strong>${escHtml(u.username)}</strong>${isSelf ? ' <em style="color:var(--text-muted)">(you)</em>' : ''}</td>
|
||||
<td><strong>${escHtml(u.username)}</strong>${isSelf ? ' <em style="color:var(--text-muted)">(you)</em>' : ''}${oidcTag}</td>
|
||||
<td>${roleBadge(u.role)}</td>
|
||||
<td style="font-size:12px">${accountsLabel}</td>
|
||||
<td style="white-space:nowrap">
|
||||
@@ -253,6 +299,10 @@ function startUserEdit(userId) {
|
||||
document.getElementById('btn-save-user').textContent = 'Save Changes';
|
||||
document.getElementById('btn-cancel-user-edit').hidden = false;
|
||||
document.getElementById('user-form-error').hidden = true;
|
||||
// OIDC fields
|
||||
document.getElementById('uf-oidc-sub').value = u.oidc_sub || '';
|
||||
document.getElementById('uf-oidc-issuer').value = u.oidc_issuer || '';
|
||||
document.getElementById('uf-oidc-group').hidden = false;
|
||||
renderUfAccountCheckboxes();
|
||||
document.getElementById('uf-username').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
@@ -268,6 +318,10 @@ function cancelUserEdit() {
|
||||
document.getElementById('btn-save-user').textContent = 'Add User';
|
||||
document.getElementById('btn-cancel-user-edit').hidden = true;
|
||||
document.getElementById('user-form-error').hidden = true;
|
||||
// OIDC fields
|
||||
document.getElementById('uf-oidc-sub').value = '';
|
||||
document.getElementById('uf-oidc-issuer').value = '';
|
||||
document.getElementById('uf-oidc-group').hidden = true;
|
||||
renderUfAccountCheckboxes();
|
||||
}
|
||||
|
||||
@@ -296,6 +350,8 @@ async function saveUser() {
|
||||
const body = { username, email, role, accounts };
|
||||
if (password) body.password = password;
|
||||
if (usersState.editingId) {
|
||||
body.oidc_sub = document.getElementById('uf-oidc-sub').value.trim();
|
||||
body.oidc_issuer = document.getElementById('uf-oidc-issuer').value.trim();
|
||||
await apiFetch('PUT', `/api/users/${usersState.editingId}`, body);
|
||||
} else {
|
||||
await apiFetch('POST', '/api/users', body);
|
||||
@@ -1603,6 +1659,83 @@ async function saveSmtpSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── OIDC settings ────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadOidcSettings() {
|
||||
try {
|
||||
const s = await apiFetch('GET', '/api/settings/oidc');
|
||||
if (!s) return;
|
||||
document.getElementById('oidc-enabled').value = s.enabled ? '1' : '0';
|
||||
document.getElementById('oidc-discovery-url').value = s.discovery_url;
|
||||
document.getElementById('oidc-client-id').value = s.client_id;
|
||||
document.getElementById('oidc-redirect-uri').value = s.redirect_uri;
|
||||
document.getElementById('oidc-button-label').value = s.button_label;
|
||||
document.getElementById('oidc-secret-hint').textContent = s.has_secret ? '(leave blank to keep)' : '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function saveOidcSettings() {
|
||||
const errEl = document.getElementById('oidc-error');
|
||||
const successEl = document.getElementById('oidc-success');
|
||||
const btn = document.getElementById('btn-save-oidc');
|
||||
errEl.hidden = true; successEl.hidden = true;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await apiFetch('PUT', '/api/settings/oidc', {
|
||||
enabled: document.getElementById('oidc-enabled').value === '1',
|
||||
discovery_url: document.getElementById('oidc-discovery-url').value.trim(),
|
||||
client_id: document.getElementById('oidc-client-id').value.trim(),
|
||||
client_secret: document.getElementById('oidc-client-secret').value,
|
||||
redirect_uri: document.getElementById('oidc-redirect-uri').value.trim(),
|
||||
button_label: document.getElementById('oidc-button-label').value.trim(),
|
||||
});
|
||||
successEl.textContent = 'Saved.'; successEl.hidden = false;
|
||||
document.getElementById('oidc-client-secret').value = '';
|
||||
await loadOidcSettings();
|
||||
setTimeout(() => { successEl.hidden = true; }, 3000);
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message; errEl.hidden = false;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── OIDC self-service linking ────────────────────────────────────────────────
|
||||
|
||||
async function loadOidcLinkStatus() {
|
||||
try {
|
||||
const cfg = await fetch('/api/auth/oidc/config').then(r => r.json());
|
||||
const section = document.getElementById('oidc-link-section');
|
||||
if (!cfg.enabled) { section.hidden = true; return; }
|
||||
section.hidden = false;
|
||||
|
||||
const me = await apiFetch('GET', '/api/auth/me');
|
||||
const statusEl = document.getElementById('oidc-link-status');
|
||||
const linkBtn = document.getElementById('btn-oidc-link');
|
||||
const unlinkBtn = document.getElementById('btn-oidc-unlink');
|
||||
|
||||
if (me.oidc_linked) {
|
||||
statusEl.textContent = 'Your account is linked to SSO.';
|
||||
linkBtn.hidden = true;
|
||||
unlinkBtn.hidden = false;
|
||||
} else {
|
||||
statusEl.textContent = 'Link your account to sign in with SSO.';
|
||||
linkBtn.hidden = false;
|
||||
unlinkBtn.hidden = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function unlinkOidc() {
|
||||
if (!confirm('Unlink your SSO identity? You will need to use your password to sign in.')) return;
|
||||
try {
|
||||
await apiFetch('POST', '/api/auth/oidc/unlink');
|
||||
await loadOidcLinkStatus();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Initialization ───────────────────────────────────────────────────────────
|
||||
|
||||
async function init() {
|
||||
@@ -1796,6 +1929,7 @@ async function init() {
|
||||
|
||||
// User management
|
||||
document.getElementById('btn-users').addEventListener('click', openUsersModal);
|
||||
document.getElementById('header-username').addEventListener('click', openUsersModal);
|
||||
document.getElementById('btn-close-users').addEventListener('click', closeUsersModal);
|
||||
document.getElementById('users-overlay').addEventListener('click', closeUsersModal);
|
||||
document.getElementById('users-list').addEventListener('click', e => {
|
||||
@@ -1809,6 +1943,8 @@ async function init() {
|
||||
document.getElementById('uf-role').addEventListener('change', renderUfAccountCheckboxes);
|
||||
document.getElementById('btn-change-password').addEventListener('click', changeOwnPassword);
|
||||
document.getElementById('btn-save-smtp').addEventListener('click', saveSmtpSettings);
|
||||
document.getElementById('btn-save-oidc').addEventListener('click', saveOidcSettings);
|
||||
document.getElementById('btn-oidc-unlink').addEventListener('click', unlinkOidc);
|
||||
|
||||
// Add checking account
|
||||
document.getElementById('btn-add-account').addEventListener('click', openWizard);
|
||||
|
||||
Reference in New Issue
Block a user