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:
parent
e4f1f58730
commit
36a65156e3
22
HISTORY.md
22
HISTORY.md
|
|
@ -1,5 +1,27 @@
|
|||
# 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
|
||||
|
||||
### 🔧 Changed
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import AppNavigation from '@/components/layout/Sidebar';
|
|||
import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
|
||||
import CommandPalette from '@/components/CommandPalette';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import NotFoundPage from '@/pages/NotFoundPage';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
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="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Top-level catch-all — covers public paths that don't exist */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</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">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -241,24 +241,29 @@ export default function TrackerPage() {
|
|||
size="icon" variant="ghost"
|
||||
onClick={() => navigate(-1)}
|
||||
className="h-7 w-7 hover:bg-white/5"
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
onClick={goToday}
|
||||
className="h-7 px-3 text-xs font-medium hover:bg-white/5"
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
<span className="min-w-[7.5rem] px-1 text-center text-xs font-semibold tabular-nums select-none">
|
||||
{MONTHS[month - 1]} {year}
|
||||
</span>
|
||||
<Button
|
||||
size="icon" variant="ghost"
|
||||
onClick={() => navigate(1)}
|
||||
className="h-7 w-7 hover:bg-white/5"
|
||||
aria-label="Next month"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm" variant="outline"
|
||||
onClick={goToday}
|
||||
className="h-9 px-3 text-xs"
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2619,6 +2619,28 @@ function runMigrations() {
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.35.1",
|
||||
"version": "0.36.0",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ const { backupOperationLimiter } = require('../middleware/rateLimiter');
|
|||
|
||||
// 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) {
|
||||
const status = err.status || 500;
|
||||
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
|
||||
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;
|
||||
if (!password || password.length < 8)
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
|
||||
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' });
|
||||
|
||||
try {
|
||||
const hash = await hashPassword(password);
|
||||
db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?")
|
||||
.run(hash, req.params.id);
|
||||
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(req.params.id);
|
||||
.run(hash, targetId);
|
||||
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId);
|
||||
|
||||
logAudit({
|
||||
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 },
|
||||
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
|
||||
// changing your own role mid-session.
|
||||
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;
|
||||
if (!['admin', 'user'].includes(role)) {
|
||||
return res.status(400).json({ error: 'role must be "admin" or "user"' });
|
||||
}
|
||||
|
||||
const targetId = Number(req.params.id);
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
|
||||
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
|
||||
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 targetId = Number(req.params.id);
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
|
||||
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' });
|
||||
}
|
||||
|
||||
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 user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId);
|
||||
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
|
||||
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 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 (req.user?.id === user.id) return res.status(400).json({ error: 'You cannot delete your own account.' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -52,6 +52,19 @@ const crypto = require('crypto');
|
|||
const { Issuer } = require('openid-client');
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -90,7 +103,7 @@ function normalizeTokenAuthMethod(value) {
|
|||
function getOidcConfig() {
|
||||
const issuerUrl = settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL');
|
||||
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 tokenAuthMethod = settingOrEnv('oidc_token_auth_method', 'OIDC_TOKEN_AUTH_METHOD', 'client_secret_basic');
|
||||
|
||||
|
|
@ -116,7 +129,7 @@ function getOidcConfig() {
|
|||
function getOidcConfigStatus() {
|
||||
const issuerUrl = settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL');
|
||||
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 missing = [];
|
||||
|
|
@ -229,7 +242,7 @@ function buildSubmittedOidcConfig(body = {}) {
|
|||
issuerUrl,
|
||||
clientId,
|
||||
clientSecret: clientSecret === '__saved__'
|
||||
? (getSetting('oidc_client_secret') || process.env.OIDC_CLIENT_SECRET || '')
|
||||
? (getOidcClientSecret() || process.env.OIDC_CLIENT_SECRET || '')
|
||||
: clientSecret,
|
||||
tokenEndpointAuthMethod: tokenAuthMethod === 'client_secret_post'
|
||||
? 'client_secret_post'
|
||||
|
|
@ -344,7 +357,7 @@ function applyAuthModeSettings(body = {}) {
|
|||
}
|
||||
if (oidc_client_secret_clear === true) setSetting('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_admin_group !== undefined) setSetting('oidc_admin_group', String(oidc_admin_group).slice(0, 200).trim());
|
||||
|
|
|
|||
Loading…
Reference in New Issue