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
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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.' });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { 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());
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue