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:
@@ -5,3 +5,7 @@ SESSION_SECRET=replace-with-a-random-64-character-hex-string
|
|||||||
SESSION_MAX_AGE_HOURS=168 # default: 168 (7 days)
|
SESSION_MAX_AGE_HOURS=168 # default: 168 (7 days)
|
||||||
PORT=3000
|
PORT=3000
|
||||||
DB_PATH=/app/data/check-printing.db
|
DB_PATH=/app/data/check-printing.db
|
||||||
|
|
||||||
|
# OIDC settings are configured in the admin UI (Manage Users > Single Sign-On).
|
||||||
|
# No environment variables needed — discovery URL, client ID/secret, and
|
||||||
|
# redirect URI are stored in the database settings table.
|
||||||
|
|||||||
Generated
+63
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ezcheck",
|
"name": "ezcheck",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ezcheck",
|
"name": "ezcheck",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^9.4.3",
|
"better-sqlite3": "^9.4.3",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"nodemailer": "^8.0.4",
|
"nodemailer": "^8.0.4",
|
||||||
|
"openid-client": "^5.7.1",
|
||||||
"pdfkit": "^0.15.0"
|
"pdfkit": "^0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1391,6 +1392,15 @@
|
|||||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jpeg-exif": {
|
"node_modules/jpeg-exif": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||||
@@ -1417,6 +1427,18 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -1654,6 +1676,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -1711,6 +1742,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oidc-token-hash": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -1741,6 +1781,21 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^4.15.9",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.2.0",
|
||||||
|
"oidc-token-hash": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pako": {
|
"node_modules/pako": {
|
||||||
"version": "0.2.9",
|
"version": "0.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
@@ -2542,6 +2597,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"nodemailer": "^8.0.4",
|
"nodemailer": "^8.0.4",
|
||||||
|
"openid-client": "^5.7.1",
|
||||||
"pdfkit": "^0.15.0"
|
"pdfkit": "^0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+65
-2
@@ -43,6 +43,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="login-error" class="wizard-error" hidden></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>
|
<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">
|
<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>
|
<a href="#" id="link-forgot-password" style="font-size:12px;color:var(--text-muted)">Forgot password?</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,7 +95,7 @@
|
|||||||
<div class="header-right">
|
<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>
|
<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>
|
<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>
|
<button id="btn-logout" class="btn-header-icon" title="Sign out">↩</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -648,7 +652,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div id="users-list"></div>
|
<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>
|
<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-row">
|
||||||
<div class="form-group required">
|
<div class="form-group required">
|
||||||
@@ -676,6 +680,16 @@
|
|||||||
<label>Account Access <span class="field-hint">(admins see all — no selection needed)</span></label>
|
<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 id="uf-accounts-checkboxes" class="account-checkboxes"></div>
|
||||||
</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 id="user-form-error" class="wizard-error" hidden></div>
|
||||||
<div style="display:flex;gap:8px;margin-top:8px">
|
<div style="display:flex;gap:8px;margin-top:8px">
|
||||||
<button id="btn-save-user" class="btn-primary">Add User</button>
|
<button id="btn-save-user" class="btn-primary">Add User</button>
|
||||||
@@ -720,6 +734,48 @@
|
|||||||
<div id="smtp-success" class="import-result" 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>
|
<button id="btn-save-smtp" class="btn-secondary" style="margin-top:8px">Save Email Settings</button>
|
||||||
</div>
|
</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 -->
|
<!-- Change own password -->
|
||||||
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
|
<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>
|
<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>
|
<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>
|
<button id="btn-change-password" class="btn-secondary" style="margin-top:8px">Change Password</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+140
-4
@@ -52,6 +52,23 @@ async function checkAuth() {
|
|||||||
showLoginOverlay();
|
showLoginOverlay();
|
||||||
return false;
|
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?
|
// Is there already a session?
|
||||||
const res = await fetch('/api/auth/me');
|
const res = await fetch('/api/auth/me');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -68,10 +85,27 @@ async function checkAuth() {
|
|||||||
} else {
|
} else {
|
||||||
showLoginSection('login-form-section');
|
showLoginSection('login-form-section');
|
||||||
}
|
}
|
||||||
|
// Show SSO button if OIDC is enabled
|
||||||
|
loadOidcLoginButton();
|
||||||
showLoginOverlay();
|
showLoginOverlay();
|
||||||
return false;
|
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() {
|
async function submitLogin() {
|
||||||
const username = document.getElementById('login-username').value.trim();
|
const username = document.getElementById('login-username').value.trim();
|
||||||
const password = document.getElementById('login-password').value;
|
const password = document.getElementById('login-password').value;
|
||||||
@@ -158,12 +192,23 @@ function applyRoleUI() {
|
|||||||
let usersState = { users: [], editingId: null };
|
let usersState = { users: [], editingId: null };
|
||||||
|
|
||||||
function openUsersModal() {
|
function openUsersModal() {
|
||||||
|
const isAdmin = state.user && state.user.role === 'admin';
|
||||||
document.getElementById('user-form-error').hidden = true;
|
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-overlay').classList.add('open');
|
||||||
document.getElementById('users-modal').classList.add('open');
|
document.getElementById('users-modal').classList.add('open');
|
||||||
loadUsers();
|
// Admin-only sections
|
||||||
renderUfAccountCheckboxes();
|
document.getElementById('users-list').hidden = !isAdmin;
|
||||||
if (state.user && state.user.role === 'admin') loadSmtpSettings();
|
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() {
|
function closeUsersModal() {
|
||||||
@@ -204,8 +249,9 @@ function renderUsersList() {
|
|||||||
const name = escHtml(a ? (a.company1 || `Account ${a.account_id}`) : `#${ua.account_id}`);
|
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>`;
|
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>');
|
}).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>
|
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>${roleBadge(u.role)}</td>
|
||||||
<td style="font-size:12px">${accountsLabel}</td>
|
<td style="font-size:12px">${accountsLabel}</td>
|
||||||
<td style="white-space:nowrap">
|
<td style="white-space:nowrap">
|
||||||
@@ -253,6 +299,10 @@ function startUserEdit(userId) {
|
|||||||
document.getElementById('btn-save-user').textContent = 'Save Changes';
|
document.getElementById('btn-save-user').textContent = 'Save Changes';
|
||||||
document.getElementById('btn-cancel-user-edit').hidden = false;
|
document.getElementById('btn-cancel-user-edit').hidden = false;
|
||||||
document.getElementById('user-form-error').hidden = true;
|
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();
|
renderUfAccountCheckboxes();
|
||||||
document.getElementById('uf-username').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
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-save-user').textContent = 'Add User';
|
||||||
document.getElementById('btn-cancel-user-edit').hidden = true;
|
document.getElementById('btn-cancel-user-edit').hidden = true;
|
||||||
document.getElementById('user-form-error').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();
|
renderUfAccountCheckboxes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +350,8 @@ async function saveUser() {
|
|||||||
const body = { username, email, role, accounts };
|
const body = { username, email, role, accounts };
|
||||||
if (password) body.password = password;
|
if (password) body.password = password;
|
||||||
if (usersState.editingId) {
|
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);
|
await apiFetch('PUT', `/api/users/${usersState.editingId}`, body);
|
||||||
} else {
|
} else {
|
||||||
await apiFetch('POST', '/api/users', body);
|
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 ───────────────────────────────────────────────────────────
|
// ── Initialization ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -1796,6 +1929,7 @@ async function init() {
|
|||||||
|
|
||||||
// User management
|
// User management
|
||||||
document.getElementById('btn-users').addEventListener('click', openUsersModal);
|
document.getElementById('btn-users').addEventListener('click', openUsersModal);
|
||||||
|
document.getElementById('header-username').addEventListener('click', openUsersModal);
|
||||||
document.getElementById('btn-close-users').addEventListener('click', closeUsersModal);
|
document.getElementById('btn-close-users').addEventListener('click', closeUsersModal);
|
||||||
document.getElementById('users-overlay').addEventListener('click', closeUsersModal);
|
document.getElementById('users-overlay').addEventListener('click', closeUsersModal);
|
||||||
document.getElementById('users-list').addEventListener('click', e => {
|
document.getElementById('users-list').addEventListener('click', e => {
|
||||||
@@ -1809,6 +1943,8 @@ async function init() {
|
|||||||
document.getElementById('uf-role').addEventListener('change', renderUfAccountCheckboxes);
|
document.getElementById('uf-role').addEventListener('change', renderUfAccountCheckboxes);
|
||||||
document.getElementById('btn-change-password').addEventListener('click', changeOwnPassword);
|
document.getElementById('btn-change-password').addEventListener('click', changeOwnPassword);
|
||||||
document.getElementById('btn-save-smtp').addEventListener('click', saveSmtpSettings);
|
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
|
// Add checking account
|
||||||
document.getElementById('btn-add-account').addEventListener('click', openWizard);
|
document.getElementById('btn-add-account').addEventListener('click', openWizard);
|
||||||
|
|||||||
@@ -139,6 +139,17 @@ db.exec(`
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migration: add OIDC columns to users
|
||||||
|
const usersInfo2 = db.prepare('PRAGMA table_info(users)').all();
|
||||||
|
if (!usersInfo2.some(c => c.name === 'oidc_sub')) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE users ADD COLUMN oidc_sub TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN oidc_issuer TEXT;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc ON users(oidc_issuer, oidc_sub)
|
||||||
|
WHERE oidc_sub IS NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
// Migration: create settings table
|
// Migration: create settings table
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
|||||||
+182
-1
@@ -123,7 +123,13 @@ router.get('/me', (req, res) => {
|
|||||||
if (!req.session || !req.session.userId) {
|
if (!req.session || !req.session.userId) {
|
||||||
return res.status(401).json({ error: 'Not authenticated.' });
|
return res.status(401).json({ error: 'Not authenticated.' });
|
||||||
}
|
}
|
||||||
res.json({ id: req.session.userId, username: req.session.username, role: req.session.role });
|
const user = db.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(req.session.userId);
|
||||||
|
res.json({
|
||||||
|
id: req.session.userId,
|
||||||
|
username: req.session.username,
|
||||||
|
role: req.session.role,
|
||||||
|
oidc_linked: !!(user && user.oidc_sub),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/auth/change-password — any logged-in user can change their own password
|
// POST /api/auth/change-password — any logged-in user can change their own password
|
||||||
@@ -200,5 +206,180 @@ router.post('/reset-password', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── OIDC helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getOidcSettings() {
|
||||||
|
const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'oidc_%'").all();
|
||||||
|
const s = Object.fromEntries(rows.map(r => [r.key.replace('oidc_', ''), r.value || '']));
|
||||||
|
return {
|
||||||
|
enabled: s.enabled === '1',
|
||||||
|
discovery_url: s.discovery_url || '',
|
||||||
|
client_id: s.client_id || '',
|
||||||
|
client_secret: s.client_secret || '',
|
||||||
|
redirect_uri: s.redirect_uri || '',
|
||||||
|
button_label: s.button_label || 'Sign in with SSO',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOidcClient(settings) {
|
||||||
|
const { Issuer } = require('openid-client');
|
||||||
|
const issuer = await Issuer.discover(settings.discovery_url);
|
||||||
|
return new issuer.Client({
|
||||||
|
client_id: settings.client_id,
|
||||||
|
client_secret: settings.client_secret,
|
||||||
|
redirect_uris: [settings.redirect_uri],
|
||||||
|
response_types: ['code'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/auth/oidc/config — public, returns whether OIDC is enabled + button label
|
||||||
|
router.get('/oidc/config', (req, res) => {
|
||||||
|
const s = getOidcSettings();
|
||||||
|
res.json({ enabled: s.enabled, button_label: s.button_label });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/oidc/authorize — initiates the OIDC flow (redirect to provider)
|
||||||
|
router.get('/oidc/authorize', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = getOidcSettings();
|
||||||
|
if (!settings.enabled) return res.status(400).json({ error: 'OIDC is not enabled.' });
|
||||||
|
|
||||||
|
const { generators } = require('openid-client');
|
||||||
|
const client = await getOidcClient(settings);
|
||||||
|
|
||||||
|
const code_verifier = generators.codeVerifier();
|
||||||
|
const code_challenge = generators.codeChallenge(code_verifier);
|
||||||
|
const state = generators.state();
|
||||||
|
const nonce = generators.nonce();
|
||||||
|
|
||||||
|
req.session.oidc = { code_verifier, state, nonce };
|
||||||
|
|
||||||
|
const authUrl = client.authorizationUrl({
|
||||||
|
scope: 'openid email profile',
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure session is persisted before redirecting (saveUninitialized is false)
|
||||||
|
req.session.save(() => res.redirect(authUrl));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[oidc] authorize error:', err.message);
|
||||||
|
res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO login.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/oidc/callback — handles the provider redirect
|
||||||
|
router.get('/oidc/callback', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = getOidcSettings();
|
||||||
|
if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.'));
|
||||||
|
|
||||||
|
const oidcSession = req.session.oidc;
|
||||||
|
if (!oidcSession) return res.redirect('/#oidc-error=' + encodeURIComponent('Session expired. Please try again.'));
|
||||||
|
|
||||||
|
const client = await getOidcClient(settings);
|
||||||
|
const params = client.callbackParams(req);
|
||||||
|
|
||||||
|
const tokenSet = await client.callback(settings.redirect_uri, params, {
|
||||||
|
code_verifier: oidcSession.code_verifier,
|
||||||
|
state: oidcSession.state,
|
||||||
|
nonce: oidcSession.nonce,
|
||||||
|
});
|
||||||
|
|
||||||
|
const claims = tokenSet.claims();
|
||||||
|
const sub = claims.sub;
|
||||||
|
const issuer = claims.iss;
|
||||||
|
|
||||||
|
delete req.session.oidc;
|
||||||
|
|
||||||
|
// Self-service linking flow
|
||||||
|
if (oidcSession.linking && oidcSession.linkUserId) {
|
||||||
|
const existing = db.prepare(
|
||||||
|
'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?'
|
||||||
|
).get(issuer, sub, oidcSession.linkUserId);
|
||||||
|
if (existing) {
|
||||||
|
return res.redirect('/#oidc-error=' + encodeURIComponent('This identity is already linked to another account.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(sub, issuer, oidcSession.linkUserId);
|
||||||
|
return res.redirect('/#oidc-linked');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login flow — look up user by OIDC identity
|
||||||
|
const user = db.prepare(
|
||||||
|
'SELECT id, username, role FROM users WHERE oidc_issuer = ? AND oidc_sub = ?'
|
||||||
|
).get(issuer, sub);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.redirect('/#oidc-error=' + encodeURIComponent(
|
||||||
|
'No account is linked to this identity. Ask an admin to link your account, or sign in with your password and link it yourself.'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.userId = user.id;
|
||||||
|
req.session.username = user.username;
|
||||||
|
req.session.role = user.role;
|
||||||
|
|
||||||
|
// Load account access into session (mirrors login behavior)
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
const accts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(user.id);
|
||||||
|
req.session.accounts = accts;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect('/');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[oidc] callback error:', err.message);
|
||||||
|
res.redirect('/#oidc-error=' + encodeURIComponent('SSO login failed. Please try again.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/oidc/link — logged-in user initiates linking flow
|
||||||
|
router.get('/oidc/link', async (req, res) => {
|
||||||
|
if (!req.session || !req.session.userId) {
|
||||||
|
return res.redirect('/#oidc-error=' + encodeURIComponent('You must be signed in to link your account.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = getOidcSettings();
|
||||||
|
if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.'));
|
||||||
|
|
||||||
|
const { generators } = require('openid-client');
|
||||||
|
const client = await getOidcClient(settings);
|
||||||
|
|
||||||
|
const code_verifier = generators.codeVerifier();
|
||||||
|
const code_challenge = generators.codeChallenge(code_verifier);
|
||||||
|
const state = generators.state();
|
||||||
|
const nonce = generators.nonce();
|
||||||
|
|
||||||
|
req.session.oidc = { code_verifier, state, nonce, linking: true, linkUserId: req.session.userId };
|
||||||
|
|
||||||
|
const authUrl = client.authorizationUrl({
|
||||||
|
scope: 'openid email profile',
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
|
||||||
|
req.session.save(() => res.redirect(authUrl));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[oidc] link error:', err.message);
|
||||||
|
res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO linking.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/oidc/unlink — logged-in user removes their own OIDC link
|
||||||
|
router.post('/oidc/unlink', (req, res) => {
|
||||||
|
if (!req.session || !req.session.userId) {
|
||||||
|
return res.status(401).json({ error: 'Not authenticated.' });
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(req.session.userId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.validatePassword = validatePassword;
|
module.exports.validatePassword = validatePassword;
|
||||||
|
|||||||
@@ -36,4 +36,35 @@ router.put('/smtp', (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/settings/oidc
|
||||||
|
router.get('/oidc', (req, res) => {
|
||||||
|
const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'oidc_%'").all();
|
||||||
|
const s = Object.fromEntries(rows.map(r => [r.key.replace('oidc_', ''), r.value || '']));
|
||||||
|
res.json({
|
||||||
|
enabled: s.enabled === '1',
|
||||||
|
discovery_url: s.discovery_url || '',
|
||||||
|
client_id: s.client_id || '',
|
||||||
|
redirect_uri: s.redirect_uri || '',
|
||||||
|
button_label: s.button_label || 'Sign in with SSO',
|
||||||
|
has_secret: !!(rows.find(r => r.key === 'oidc_client_secret') || {}).value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/settings/oidc
|
||||||
|
router.put('/oidc', (req, res) => {
|
||||||
|
const { enabled, discovery_url, client_id, client_secret, redirect_uri, button_label } = req.body;
|
||||||
|
const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
|
||||||
|
db.transaction(() => {
|
||||||
|
upsert.run('oidc_enabled', enabled ? '1' : '0');
|
||||||
|
upsert.run('oidc_discovery_url', discovery_url || '');
|
||||||
|
upsert.run('oidc_client_id', client_id || '');
|
||||||
|
upsert.run('oidc_redirect_uri', redirect_uri || '');
|
||||||
|
upsert.run('oidc_button_label', button_label || 'Sign in with SSO');
|
||||||
|
if (client_secret !== undefined && client_secret !== '') {
|
||||||
|
upsert.run('oidc_client_secret', client_secret);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+20
-3
@@ -11,7 +11,7 @@ const { validatePassword } = require('./auth');
|
|||||||
router.use(requireAuth, requireAdmin);
|
router.use(requireAuth, requireAdmin);
|
||||||
|
|
||||||
function userWithAccounts(id) {
|
function userWithAccounts(id) {
|
||||||
const user = db.prepare('SELECT id, username, email, role, created_at FROM users WHERE id = ?').get(id);
|
const user = db.prepare('SELECT id, username, email, role, oidc_sub, oidc_issuer, created_at FROM users WHERE id = ?').get(id);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
user.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(id);
|
user.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(id);
|
||||||
return user;
|
return user;
|
||||||
@@ -19,7 +19,7 @@ function userWithAccounts(id) {
|
|||||||
|
|
||||||
// GET /api/users
|
// GET /api/users
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const users = db.prepare('SELECT id, username, email, role, created_at FROM users ORDER BY id ASC').all();
|
const users = db.prepare('SELECT id, username, email, role, oidc_sub, oidc_issuer, created_at FROM users ORDER BY id ASC').all();
|
||||||
users.forEach(u => {
|
users.forEach(u => {
|
||||||
u.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(u.id);
|
u.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(u.id);
|
||||||
});
|
});
|
||||||
@@ -60,7 +60,7 @@ router.put('/:id', async (req, res) => {
|
|||||||
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.params.id);
|
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.params.id);
|
||||||
if (!user) return res.status(404).json({ error: 'User not found.' });
|
if (!user) return res.status(404).json({ error: 'User not found.' });
|
||||||
|
|
||||||
const { username, password, role, accounts, email } = req.body;
|
const { username, password, role, accounts, email, oidc_sub, oidc_issuer } = req.body;
|
||||||
|
|
||||||
if (role && !['admin', 'editor', 'viewer'].includes(role)) {
|
if (role && !['admin', 'editor', 'viewer'].includes(role)) {
|
||||||
return res.status(400).json({ error: 'Invalid role.' });
|
return res.status(400).json({ error: 'Invalid role.' });
|
||||||
@@ -94,6 +94,23 @@ router.put('/:id', async (req, res) => {
|
|||||||
.run(hash, req.params.id);
|
.run(hash, req.params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDC linking — admin can set or clear oidc_sub/oidc_issuer
|
||||||
|
if (oidc_sub !== undefined) {
|
||||||
|
const newSub = oidc_sub ? oidc_sub.trim() : null;
|
||||||
|
const newIssuer = oidc_issuer ? oidc_issuer.trim() : null;
|
||||||
|
if (newSub && !newIssuer) {
|
||||||
|
return res.status(400).json({ error: 'OIDC issuer is required when setting OIDC subject.' });
|
||||||
|
}
|
||||||
|
if (newSub) {
|
||||||
|
const existing = db.prepare(
|
||||||
|
'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?'
|
||||||
|
).get(newIssuer, newSub, req.params.id);
|
||||||
|
if (existing) return res.status(409).json({ error: 'This OIDC identity is already linked to another user.' });
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(newSub, newSub ? newIssuer : null, req.params.id);
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(accounts)) {
|
if (Array.isArray(accounts)) {
|
||||||
db.prepare('DELETE FROM user_accounts WHERE user_id = ?').run(req.params.id);
|
db.prepare('DELETE FROM user_accounts WHERE user_id = ?').run(req.params.id);
|
||||||
const effectiveRole = role || user.role;
|
const effectiveRole = role || user.role;
|
||||||
|
|||||||
Reference in New Issue
Block a user