From 36a65156e371d4c9c2ca4b84c81ca5618bce781e Mon Sep 17 00:00:00 2001 From: null Date: Wed, 3 Jun 2026 20:28:37 -0500 Subject: [PATCH] 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) --- HISTORY.md | 22 +++++ client/App.jsx | 6 +- client/pages/CalendarPage.jsx | 2 +- client/pages/NotFoundPage.jsx | 178 ++++++++++++++++++++++++++++++++++ client/pages/TrackerPage.jsx | 19 ++-- db/database.js | 22 +++++ package.json | 2 +- routes/admin.js | 35 +++++-- scripts/pipeline-report.py | 89 +++++++++++++++++ services/oidcService.js | 21 +++- 10 files changed, 373 insertions(+), 23 deletions(-) create mode 100644 client/pages/NotFoundPage.jsx create mode 100644 scripts/pipeline-report.py diff --git a/HISTORY.md b/HISTORY.md index b5c09c9..f3e62da 100644 --- a/HISTORY.md +++ b/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 `` 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 diff --git a/client/App.jsx b/client/App.jsx index 8e05037..a40d7a2 100644 --- a/client/App.jsx +++ b/client/App.jsx @@ -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() { }>} /> }>} /> }>} /> - } /> + } /> + + {/* Top-level catch-all โ€” covers public paths that don't exist */} + } /> diff --git a/client/pages/CalendarPage.jsx b/client/pages/CalendarPage.jsx index 37b0e72..1c1c966 100644 --- a/client/pages/CalendarPage.jsx +++ b/client/pages/CalendarPage.jsx @@ -631,7 +631,7 @@ export default function CalendarPage() { -
{monthLabel}
+ {monthLabel} diff --git a/client/pages/NotFoundPage.jsx b/client/pages/NotFoundPage.jsx new file mode 100644 index 0000000..ab7945e --- /dev/null +++ b/client/pages/NotFoundPage.jsx @@ -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 ( + + {value} + + ); +} + +export default function NotFoundPage() { + const { user } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const canGoBack = window.history.length > 1; + const badPath = location.pathname; + + return ( +
+ {/* Grid overlay */} + +
diff --git a/db/database.js b/db/database.js index 66cb67e..a7fd4bf 100644 --- a/db/database.js +++ b/db/database.js @@ -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); + } + } } ]; diff --git a/package.json b/package.json index 7a24f87..a5f1c49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.35.1", + "version": "0.36.0", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/admin.js b/routes/admin.js index 2052a2a..5528955 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -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.' }); diff --git a/scripts/pipeline-report.py b/scripts/pipeline-report.py new file mode 100644 index 0000000..5e8d723 --- /dev/null +++ b/scripts/pipeline-report.py @@ -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()) diff --git a/services/oidcService.js b/services/oidcService.js index abbe798..6791fa3 100644 --- a/services/oidcService.js +++ b/services/oidcService.js @@ -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());