feat: merge pipeline workflow into bill-tracker (batch v0.36.0)

- Copy pipeline-report.py from Pipeline project into scripts/
- Update TOOLS.md and MEMORY.md to reflect workflow consolidation
- (includes all uncommitted v0.36.0 changes from prior session)
This commit is contained in:
null 2026-06-03 20:28:37 -05:00
parent e4f1f58730
commit 36a65156e3
10 changed files with 373 additions and 23 deletions

View File

@ -1,5 +1,27 @@
# Bill Tracker — Changelog # Bill Tracker — Changelog
## v0.36.0
### 🔧 Changed
- **Bump**`0.35.1``0.36.0`
### 🔒 Security
- **OIDC client secret encrypted at rest** — The OIDC client secret was stored as plaintext in the `settings` table alongside all other application settings. It is now encrypted using the same AES-256-GCM + HKDF pipeline already in use for SMTP passwords and SimpleFIN tokens. A new `getOidcClientSecret()` helper in `oidcService.js` decrypts on read (with a plaintext fallback for legacy values), and the write path calls `encryptSecret()` before `setSetting`. DB migration `v0.79` encrypts any existing plaintext value on first startup — no manual action required. Env-var-sourced secrets (`OIDC_CLIENT_SECRET`) are unaffected and bypass the DB path entirely.
- **Admin user routes: integer validation on all ID params**`PUT /api/admin/users/:id/password`, `/role`, `/active`, `/username` and `DELETE /api/admin/users/:id` previously accepted arbitrary strings as the user ID — some routes used raw `req.params.id` in SQL queries, others called `Number()` without verifying the result was a positive integer. A shared `parseUserId()` helper (`parseInt` + `Number.isInteger` + `> 0`) now gates all five handlers, returning `400 Invalid user ID` immediately on any non-integer or non-positive input. Backup routes that take filename-style IDs are intentionally unchanged.
### ✨ Features
- **404 page** — Unknown routes previously silently redirected to `/` with no feedback. Replaced both catch-all routes (`path="*"` inside the auth layout and at the top level) with a dedicated `NotFoundPage`. The page is standalone (no sidebar), theme-aware, and works for authenticated and unauthenticated users alike. Design features: a glitch counter that cycles each digit of "404" through random numbers before snapping to value (staggered by 180 ms per digit), a CSS mesh gradient background with primary-color radial glows, a 48 px grid overlay faded with a radial mask, a gradient clip-text `404` that scales from `6rem` to `14rem` via `clamp()`, and smart CTAs — "Go back" only appears when browser history exists, and the home button adapts to auth state. The bad path is shown inline in a `<code>` tag so the user knows what they typed.
### 🐛 Fixed
- **Month navigation brackets the month name** — In TrackerPage the month navigation pill previously showed `< Today >` — the arrows flanked a "Today" button rather than the current month. The pill now shows `< MAY 2026 >` with the month and year as a static label between the arrows, and "Today" promoted to a standalone `variant="outline"` button beside the pill. In CalendarPage the pill already had the correct structure (`< MONTH YEAR >`) but `min-w-40 px-3` (160 px minimum + 24 px of padding) made the label too wide, leaving the arrows visually disconnected from the text. Reduced to `min-w-[8rem] px-1` so the arrows bracket the text tightly. Both labels gain `tabular-nums` (prevents width jitter on month change) and `select-none` (prevents accidental text selection when clicking arrows quickly).
---
## v0.35.1 ## v0.35.1
### 🔧 Changed ### 🔧 Changed

View File

@ -7,6 +7,7 @@ import AppNavigation from '@/components/layout/Sidebar';
import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog'; import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
import CommandPalette from '@/components/CommandPalette'; import CommandPalette from '@/components/CommandPalette';
import LoginPage from '@/pages/LoginPage'; import LoginPage from '@/pages/LoginPage';
import NotFoundPage from '@/pages/NotFoundPage';
import ErrorBoundary from '@/components/ErrorBoundary'; import ErrorBoundary from '@/components/ErrorBoundary';
import PageLoader from '@/components/PageLoader'; import PageLoader from '@/components/PageLoader';
@ -213,8 +214,11 @@ export default function App() {
<Route path="payoff" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><PayoffPage /></Suspense></ErrorBoundary>} /> <Route path="payoff" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><PayoffPage /></Suspense></ErrorBoundary>} />
<Route path="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} /> <Route path="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} />
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} /> <Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<NotFoundPage />} />
</Route> </Route>
{/* Top-level catch-all — covers public paths that don't exist */}
<Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
</main> </main>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />

View File

@ -631,7 +631,7 @@ export default function CalendarPage() {
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(-1)} aria-label="Previous month"> <Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(-1)} aria-label="Previous month">
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<div className="min-w-40 px-3 text-center text-sm font-semibold">{monthLabel}</div> <span className="min-w-[8rem] px-1 text-center text-sm font-semibold tabular-nums select-none">{monthLabel}</span>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(1)} aria-label="Next month"> <Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(1)} aria-label="Next month">
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>

View File

@ -0,0 +1,178 @@
import { useEffect, useRef } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { ArrowLeft, Home, FileQuestion } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/hooks/useAuth';
// Animated digit cycles through random numbers before settling
function GlitchDigit({ value, delay = 0 }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const frames = 14;
const digits = '0123456789';
let frame = 0;
let start = null;
function tick(ts) {
if (!start) start = ts + delay;
const elapsed = ts - start;
if (elapsed < 0) { requestAnimationFrame(tick); return; }
if (frame < frames) {
el.textContent = digits[Math.floor(Math.random() * 10)];
frame++;
setTimeout(() => requestAnimationFrame(tick), 40 + frame * 6);
} else {
el.textContent = value;
el.classList.add('settled');
}
}
requestAnimationFrame(tick);
}, [value, delay]);
return (
<span
ref={ref}
style={{
display: 'inline-block',
fontVariantNumeric: 'tabular-nums',
transition: 'opacity 0.15s',
}}
>
{value}
</span>
);
}
export default function NotFoundPage() {
const { user } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const canGoBack = window.history.length > 1;
const badPath = location.pathname;
return (
<div
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-background text-foreground"
style={{
background: `
radial-gradient(ellipse 80% 60% at 50% -10%, oklch(var(--primary) / 0.12), transparent),
radial-gradient(ellipse 60% 40% at 100% 100%, oklch(var(--primary) / 0.06), transparent),
oklch(var(--background))
`,
}}
>
{/* Grid overlay */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: `
linear-gradient(oklch(var(--border) / 0.35) 1px, transparent 1px),
linear-gradient(90deg, oklch(var(--border) / 0.35) 1px, transparent 1px)
`,
backgroundSize: '48px 48px',
maskImage: 'radial-gradient(ellipse 80% 80% at 50% 50%, black 30%, transparent 100%)',
WebkitMaskImage: 'radial-gradient(ellipse 80% 80% at 50% 50%, black 30%, transparent 100%)',
}}
/>
{/* Glow orb */}
<div
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl"
style={{
width: '36rem',
height: '20rem',
background: 'oklch(var(--primary) / 0.07)',
}}
/>
{/* Content */}
<div className="relative z-10 flex flex-col items-center gap-6 px-6 text-center">
{/* Icon badge */}
<div
className="flex h-14 w-14 items-center justify-center rounded-2xl border"
style={{
background: 'oklch(var(--primary) / 0.08)',
borderColor: 'oklch(var(--primary) / 0.25)',
}}
>
<FileQuestion
className="h-6 w-6"
style={{ color: 'oklch(var(--primary))' }}
/>
</div>
{/* 404 */}
<div
className="select-none font-mono font-black leading-none tracking-tighter"
style={{
fontSize: 'clamp(6rem, 22vw, 14rem)',
background: `linear-gradient(
135deg,
oklch(var(--foreground)) 0%,
oklch(var(--foreground) / 0.55) 50%,
oklch(var(--primary) / 0.5) 100%
)`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
letterSpacing: '-0.06em',
}}
>
<GlitchDigit value="4" delay={0} />
<GlitchDigit value="0" delay={180} />
<GlitchDigit value="4" delay={360} />
</div>
{/* Headline */}
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
Nothing here but debt
</h1>
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground">
{badPath !== '/'
? <>The page <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{badPath}</code> doesn't exist.</>
: <>That page doesn't exist.</>
}
{' '}Check the URL or head back somewhere useful.
</p>
</div>
{/* CTAs */}
<div className="flex flex-wrap items-center justify-center gap-3">
<Button asChild size="default">
<Link to={user ? '/' : '/login'}>
<Home className="h-4 w-4" />
{user ? 'Dashboard' : 'Sign in'}
</Link>
</Button>
{canGoBack && (
<Button
variant="outline"
size="default"
onClick={() => navigate(-1)}
>
<ArrowLeft className="h-4 w-4" />
Go back
</Button>
)}
</div>
{/* Path hint */}
<p className="text-xs text-muted-foreground/40">
error 404 · page not found
</p>
</div>
</div>
);
}

View File

@ -241,24 +241,29 @@ export default function TrackerPage() {
size="icon" variant="ghost" size="icon" variant="ghost"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="h-7 w-7 hover:bg-white/5" className="h-7 w-7 hover:bg-white/5"
aria-label="Previous month"
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<Button <span className="min-w-[7.5rem] px-1 text-center text-xs font-semibold tabular-nums select-none">
size="sm" variant="ghost" {MONTHS[month - 1]} {year}
onClick={goToday} </span>
className="h-7 px-3 text-xs font-medium hover:bg-white/5"
>
Today
</Button>
<Button <Button
size="icon" variant="ghost" size="icon" variant="ghost"
onClick={() => navigate(1)} onClick={() => navigate(1)}
className="h-7 w-7 hover:bg-white/5" className="h-7 w-7 hover:bg-white/5"
aria-label="Next month"
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
<Button
size="sm" variant="outline"
onClick={goToday}
className="h-9 px-3 text-xs"
>
Today
</Button>
</div> </div>
</div> </div>

View File

@ -2619,6 +2619,28 @@ function runMigrations() {
console.warn('[v0.78] HKDF re-encryption migration failed:', err.message); console.warn('[v0.78] HKDF re-encryption migration failed:', err.message);
} }
} }
},
{
version: 'v0.79',
description: 'encrypt OIDC client secret at rest',
dependsOn: ['v0.78'],
run: function() {
try {
const { decryptSecret, encryptSecret } = require('../services/encryptionService');
const row = db.prepare("SELECT value FROM settings WHERE key = 'oidc_client_secret'").get();
if (row?.value) {
try {
decryptSecret(row.value); // already encrypted — skip
} catch {
// plaintext — encrypt it
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'oidc_client_secret'")
.run(encryptSecret(row.value));
}
}
} catch (err) {
console.warn('[v0.79] OIDC client secret encryption migration failed:', err.message);
}
}
} }
]; ];

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.35.1", "version": "0.36.0",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View File

@ -27,6 +27,12 @@ const { backupOperationLimiter } = require('../middleware/rateLimiter');
// All routes mounted at /api/admin (requireAuth + requireAdmin applied at server level) // All routes mounted at /api/admin (requireAuth + requireAdmin applied at server level)
// Returns a validated positive integer from req.params.id, or null.
function parseUserId(params) {
const n = parseInt(params.id, 10);
return Number.isInteger(n) && n > 0 ? n : null;
}
function sendError(res, err) { function sendError(res, err) {
const status = err.status || 500; const status = err.status || 500;
res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message }); res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message });
@ -186,23 +192,26 @@ router.post('/users', async (req, res) => {
// PUT /api/admin/users/:id/password // PUT /api/admin/users/:id/password
router.put('/users/:id/password', async (req, res) => { router.put('/users/:id/password', async (req, res) => {
const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
const { password } = req.body; const { password } = req.body;
if (!password || password.length < 8) if (!password || password.length < 8)
return res.status(400).json({ error: 'Password must be at least 8 characters' }); return res.status(400).json({ error: 'Password must be at least 8 characters' });
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) return res.status(404).json({ error: 'User not found' });
try { try {
const hash = await hashPassword(password); const hash = await hashPassword(password);
db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?") db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?")
.run(hash, req.params.id); .run(hash, targetId);
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(req.params.id); db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId);
logAudit({ logAudit({
user_id: req.user.id, action: 'admin.password.reset', user_id: req.user.id, action: 'admin.password.reset',
entity_type: 'user', entity_id: Number(req.params.id), entity_type: 'user', entity_id: targetId,
details: { target_username: user.username }, details: { target_username: user.username },
ip_address: req.ip, user_agent: req.get('user-agent'), ip_address: req.ip, user_agent: req.get('user-agent'),
}); });
@ -218,12 +227,13 @@ router.put('/users/:id/password', async (req, res) => {
// Promote/demote an existing user. Prevents removing the last admin or // Promote/demote an existing user. Prevents removing the last admin or
// changing your own role mid-session. // changing your own role mid-session.
router.put('/users/:id/role', (req, res) => { router.put('/users/:id/role', (req, res) => {
const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
const { role } = req.body; const { role } = req.body;
if (!['admin', 'user'].includes(role)) { if (!['admin', 'user'].includes(role)) {
return res.status(400).json({ error: 'role must be "admin" or "user"' }); return res.status(400).json({ error: 'role must be "admin" or "user"' });
} }
const targetId = Number(req.params.id);
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) return res.status(404).json({ error: 'User not found' });
@ -259,8 +269,10 @@ router.put('/users/:id/role', (req, res) => {
// PUT /api/admin/users/:id/active // PUT /api/admin/users/:id/active
router.put('/users/:id/active', (req, res) => { router.put('/users/:id/active', (req, res) => {
const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
const active = req.body?.active ? 1 : 0; const active = req.body?.active ? 1 : 0;
const targetId = Number(req.params.id);
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) return res.status(404).json({ error: 'User not found' });
@ -290,7 +302,9 @@ router.put('/users/:id/username', (req, res) => {
return res.status(400).json({ error: 'Username must be 50 characters or fewer' }); return res.status(400).json({ error: 'Username must be 50 characters or fewer' });
} }
const targetId = Number(req.params.id); const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId); const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) return res.status(404).json({ error: 'User not found' });
@ -319,8 +333,11 @@ router.put('/users/:id/username', (req, res) => {
// DELETE /api/admin/users/:id // DELETE /api/admin/users/:id
router.delete('/users/:id', (req, res) => { router.delete('/users/:id', (req, res) => {
const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) return res.status(404).json({ error: 'User not found' });
if (req.user?.id === user.id) return res.status(400).json({ error: 'You cannot delete your own account.' }); if (req.user?.id === user.id) return res.status(400).json({ error: 'You cannot delete your own account.' });

View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Report the current project/task to the Pipeline dashboard.
Usage:
python report-project.py --project Pipeline --task "fixing bug" --status busy
Required environment variables:
PIPELINE_BOT_KEY API key generated from Settings Bot API keys
PIPELINE_URL Pipeline base URL (default: http://localhost:8001)
Optional environment variables:
PIPELINE_REPORT_TIMEOUT Request timeout in seconds (default: 3)
Exit codes:
0 success or env vars not configured (non-disruptive)
1 HTTP error or unexpected failure
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
def main() -> int:
parser = argparse.ArgumentParser(
description="Report current project/task to Pipeline dashboard."
)
parser.add_argument("--project", default=None, help="Project name, e.g. Pipeline")
parser.add_argument("--task", default=None, help="Current task description")
parser.add_argument(
"--status",
default="busy",
choices=["busy", "idle", "error"],
help="Agent status (default: busy)",
)
parser.add_argument("--detail", default=None, help="Extra JSON detail string")
args = parser.parse_args()
bot_key = os.environ.get("PIPELINE_BOT_KEY", "").strip()
base_url = os.environ.get("PIPELINE_URL", "http://localhost:8001").strip().rstrip("/")
timeout = float(os.environ.get("PIPELINE_REPORT_TIMEOUT", "3"))
if not bot_key:
# Not configured — exit silently so the agent isn't disrupted.
return 0
payload: dict[str, object] = {
"project": args.project,
"task": args.task,
"status": args.status,
}
if args.detail:
try:
payload["detail"] = json.loads(args.detail)
except json.JSONDecodeError:
payload["detail"] = {"raw": args.detail}
body = json.dumps(payload).encode("utf-8")
url = f"{base_url}/api/v1/bot/report"
req = urllib.request.Request(
url,
data=body,
method="POST",
headers={
"X-Bot-Key": bot_key,
"Content-Type": "application/json",
"User-Agent": "PipelineBotReport/1.0",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout):
pass
except urllib.error.HTTPError as exc:
print(f"report-project: HTTP {exc.code} from {url}", file=sys.stderr)
return 1
except (urllib.error.URLError, OSError) as exc:
print(f"report-project: connection error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -52,6 +52,19 @@ const crypto = require('crypto');
const { Issuer } = require('openid-client'); const { Issuer } = require('openid-client');
const { getDb, getSetting, setSetting } = require('../db/database'); const { getDb, getSetting, setSetting } = require('../db/database');
const { encryptSecret, decryptSecret } = require('./encryptionService');
// Decrypt the stored OIDC client secret, falling back to plaintext for
// values saved before encryption was introduced (pre-v0.79).
function getOidcClientSecret() {
const stored = getSetting('oidc_client_secret');
if (!stored) return '';
try {
return decryptSecret(stored);
} catch {
return stored; // legacy plaintext — still works until re-saved
}
}
// ── Configuration ───────────────────────────────────────────────────────────── // ── Configuration ─────────────────────────────────────────────────────────────
@ -90,7 +103,7 @@ function normalizeTokenAuthMethod(value) {
function getOidcConfig() { function getOidcConfig() {
const issuerUrl = settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL'); const issuerUrl = settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL');
const clientId = settingOrEnv('oidc_client_id', 'OIDC_CLIENT_ID'); const clientId = settingOrEnv('oidc_client_id', 'OIDC_CLIENT_ID');
const clientSecret = settingOrEnv('oidc_client_secret', 'OIDC_CLIENT_SECRET'); const clientSecret = getOidcClientSecret() || trimOrNull(process.env['OIDC_CLIENT_SECRET']);
const redirectUri = settingOrEnv('oidc_redirect_uri', 'OIDC_REDIRECT_URI'); const redirectUri = settingOrEnv('oidc_redirect_uri', 'OIDC_REDIRECT_URI');
const tokenAuthMethod = settingOrEnv('oidc_token_auth_method', 'OIDC_TOKEN_AUTH_METHOD', 'client_secret_basic'); const tokenAuthMethod = settingOrEnv('oidc_token_auth_method', 'OIDC_TOKEN_AUTH_METHOD', 'client_secret_basic');
@ -116,7 +129,7 @@ function getOidcConfig() {
function getOidcConfigStatus() { function getOidcConfigStatus() {
const issuerUrl = settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL'); const issuerUrl = settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL');
const clientId = settingOrEnv('oidc_client_id', 'OIDC_CLIENT_ID'); const clientId = settingOrEnv('oidc_client_id', 'OIDC_CLIENT_ID');
const clientSecret = settingOrEnv('oidc_client_secret', 'OIDC_CLIENT_SECRET'); const clientSecret = getOidcClientSecret() || trimOrNull(process.env['OIDC_CLIENT_SECRET']);
const redirectUri = settingOrEnv('oidc_redirect_uri', 'OIDC_REDIRECT_URI'); const redirectUri = settingOrEnv('oidc_redirect_uri', 'OIDC_REDIRECT_URI');
const missing = []; const missing = [];
@ -229,7 +242,7 @@ function buildSubmittedOidcConfig(body = {}) {
issuerUrl, issuerUrl,
clientId, clientId,
clientSecret: clientSecret === '__saved__' clientSecret: clientSecret === '__saved__'
? (getSetting('oidc_client_secret') || process.env.OIDC_CLIENT_SECRET || '') ? (getOidcClientSecret() || process.env.OIDC_CLIENT_SECRET || '')
: clientSecret, : clientSecret,
tokenEndpointAuthMethod: tokenAuthMethod === 'client_secret_post' tokenEndpointAuthMethod: tokenAuthMethod === 'client_secret_post'
? 'client_secret_post' ? 'client_secret_post'
@ -344,7 +357,7 @@ function applyAuthModeSettings(body = {}) {
} }
if (oidc_client_secret_clear === true) setSetting('oidc_client_secret', ''); if (oidc_client_secret_clear === true) setSetting('oidc_client_secret', '');
if (trimOrEmpty(oidc_client_secret)) { if (trimOrEmpty(oidc_client_secret)) {
setSetting('oidc_client_secret', trimOrEmpty(oidc_client_secret).slice(0, 1000)); setSetting('oidc_client_secret', encryptSecret(trimOrEmpty(oidc_client_secret).slice(0, 1000)));
} }
if (oidc_auto_provision !== undefined) setSetting('oidc_auto_provision', !!oidc_auto_provision ? 'true' : 'false'); if (oidc_auto_provision !== undefined) setSetting('oidc_auto_provision', !!oidc_auto_provision ? 'true' : 'false');
if (oidc_admin_group !== undefined) setSetting('oidc_admin_group', String(oidc_admin_group).slice(0, 200).trim()); if (oidc_admin_group !== undefined) setSetting('oidc_admin_group', String(oidc_admin_group).slice(0, 200).trim());