Private
Public Access
1
0

Phase 9: Admin & DevOps — health check, rate limiting, logging, site admin, Docker healthcheck

- Health check endpoint (GET /api/health) with DB probe and uptime
- In-memory sliding window rate limiting on login (10/60s) and register (5/3600s)
- Structured logging with pino (request timing in hooks, Socket.IO events in server.js)
- Site admin dashboard with tenant stats, CRUD, and requireSiteAdmin guard
- Tenant admin enhanced with tag and category management UI
- Docker HEALTHCHECK in Dockerfile and docker-compose.yml
- Backup documentation (docs/backup.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-26 01:24:24 -05:00
parent 7559102479
commit 0bba161c12
19 changed files with 932 additions and 21 deletions
+5
View File
@@ -37,6 +37,11 @@ RUN mkdir -p /app/uploads
EXPOSE 3000 EXPOSE 3000
RUN apk add --no-cache curl
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
COPY docker-entrypoint.sh ./ COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh RUN chmod +x docker-entrypoint.sh
+6
View File
@@ -30,6 +30,12 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/api/health']
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
volumes: volumes:
- uploads:/app/uploads - uploads:/app/uploads
+83
View File
@@ -0,0 +1,83 @@
# Pounce — Backup & Restore
Pounce runs as the **kanban** Portainer stack with:
- App on port **3200** (container 3000)
- PostgreSQL on port **3201** (container 5432)
- Named volumes: `kanban_pgdata`, `kanban_uploads`
## Database Backup (pg_dump)
### Via docker exec (recommended)
```bash
# Find the db container name
docker ps --filter "label=com.docker.compose.project=kanban" --format '{{.Names}}' | grep db
# Dump to a local file
docker exec <db-container> pg_dump -U kanban kanban > pounce-backup-$(date +%Y%m%d).sql
```
### Via host port
```bash
PGPASSWORD=kanban pg_dump -h 192.168.3.65 -p 3201 -U kanban kanban > pounce-backup-$(date +%Y%m%d).sql
```
### Compressed backup
```bash
docker exec <db-container> pg_dump -U kanban -Fc kanban > pounce-backup-$(date +%Y%m%d).dump
```
## Uploads Volume Backup
```bash
# Find the volume mount path
docker volume inspect kanban_uploads --format '{{.Mountpoint}}'
# Copy files out
docker cp <app-container>:/app/uploads ./uploads-backup-$(date +%Y%m%d)
```
## Restore
### Database restore (plain SQL)
```bash
# Drop and recreate (will lose existing data)
docker exec -i <db-container> psql -U kanban -c "DROP DATABASE kanban; CREATE DATABASE kanban;"
docker exec -i <db-container> psql -U kanban kanban < pounce-backup-20250101.sql
```
### Database restore (custom format)
```bash
docker exec -i <db-container> pg_restore -U kanban -d kanban --clean --if-exists < pounce-backup-20250101.dump
```
### Uploads restore
```bash
docker cp ./uploads-backup-20250101/. <app-container>:/app/uploads/
```
### After restore
Restart the app container so Prisma picks up the restored data:
```bash
docker restart <app-container>
```
## Automated Backups (cron example)
```bash
# /etc/cron.d/pounce-backup — daily at 3 AM
0 3 * * * root docker exec kanban-db-1 pg_dump -U kanban kanban | gzip > /backups/pounce-$(date +\%Y\%m\%d).sql.gz
```
Retain the last 14 daily backups:
```bash
find /backups -name 'pounce-*.sql.gz' -mtime +14 -delete
```
+132
View File
@@ -17,6 +17,7 @@
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"express": "^5.1.0", "express": "^5.1.0",
"marked": "^15.0.0", "marked": "^15.0.0",
"pino": "^10.3.1",
"socket.io": "^4.8.0", "socket.io": "^4.8.0",
"socket.io-client": "^4.8.0", "socket.io-client": "^4.8.0",
"svelte-dnd-action": "^0.9.52", "svelte-dnd-action": "^0.9.52",
@@ -498,6 +499,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -1578,6 +1585,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.27", "version": "10.4.27",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
@@ -3184,6 +3200,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -3262,6 +3287,43 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/pkg-types": { "node_modules/pkg-types": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
@@ -3337,6 +3399,22 @@
} }
} }
}, },
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -3382,6 +3460,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -3431,6 +3515,15 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -3535,6 +3628,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -3783,6 +3885,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3792,6 +3903,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -3904,6 +4024,18 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tinyexec": { "node_modules/tinyexec": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+1
View File
@@ -28,6 +28,7 @@
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"express": "^5.1.0", "express": "^5.1.0",
"marked": "^15.0.0", "marked": "^15.0.0",
"pino": "^10.3.1",
"socket.io": "^4.8.0", "socket.io": "^4.8.0",
"socket.io-client": "^4.8.0", "socket.io-client": "^4.8.0",
"svelte-dnd-action": "^0.9.52", "svelte-dnd-action": "^0.9.52",
+7 -4
View File
@@ -2,8 +2,11 @@ import express from 'express';
import { createServer } from 'http'; import { createServer } from 'http';
import { Server } from 'socket.io'; import { Server } from 'socket.io';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import pino from 'pino';
import { handler } from './build/handler.js'; import { handler } from './build/handler.js';
const logger = pino({ name: 'pounce' });
const app = express(); const app = express();
const server = createServer(app); const server = createServer(app);
@@ -64,7 +67,7 @@ io.use(async (socket, next) => {
} }
next(); next();
} catch (err) { } catch (err) {
console.error('Socket auth error:', err); logger.error({ err }, 'Socket auth error');
socket.data.user = null; socket.data.user = null;
next(); next();
} }
@@ -73,7 +76,7 @@ io.use(async (socket, next) => {
// ─── Socket.IO connection handler ──────────────────────────── // ─── Socket.IO connection handler ────────────────────────────
io.on('connection', (socket) => { io.on('connection', (socket) => {
const user = socket.data.user; const user = socket.data.user;
console.log(`Socket connected: ${socket.id} (user: ${user?.name ?? 'anon'})`); logger.info({ socketId: socket.id, user: user?.name ?? 'anon' }, 'Socket connected');
// Join user-specific room for notifications // Join user-specific room for notifications
if (user) { if (user) {
@@ -122,7 +125,7 @@ io.on('connection', (socket) => {
}); });
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.log(`Socket disconnected: ${socket.id}`); logger.info({ socketId: socket.id }, 'Socket disconnected');
for (const boardId of joinedBoards) { for (const boardId of joinedBoards) {
if (presence.has(boardId)) { if (presence.has(boardId)) {
presence.get(boardId).delete(socket.id); presence.get(boardId).delete(socket.id);
@@ -152,5 +155,5 @@ const port = process.env.PORT || 3000;
const host = process.env.HOST || '0.0.0.0'; const host = process.env.HOST || '0.0.0.0';
server.listen(port, host, () => { server.listen(port, host, () => {
console.log(`Server running on http://${host}:${port}`); logger.info({ host, port }, 'Server running');
}); });
+12 -1
View File
@@ -1,8 +1,10 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { resolveTenant } from '$lib/server/tenant.js'; import { resolveTenant } from '$lib/server/tenant.js';
import { validateSession, sessionCookieName } from '$lib/server/auth.js'; import { validateSession, sessionCookieName } from '$lib/server/auth.js';
import { logger } from '$lib/server/logger.js';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const start = Date.now();
// 1. Resolve tenant from hostname // 1. Resolve tenant from hostname
const hostname = event.url.hostname; const hostname = event.url.hostname;
const tenant = await resolveTenant(hostname); const tenant = await resolveTenant(hostname);
@@ -45,5 +47,14 @@ export const handle: Handle = async ({ event, resolve }) => {
// 3. Read theme preference // 3. Read theme preference
event.locals.theme = event.cookies.get('theme') || undefined; event.locals.theme = event.cookies.get('theme') || undefined;
return resolve(event); const response = await resolve(event);
logger.info({
method: event.request.method,
path: event.url.pathname,
status: response.status,
duration_ms: Date.now() - start
}, 'request');
return response;
}; };
+9 -1
View File
@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { canViewBoard, canEditBoard, canDeleteBoard, canManageMembers, isTenantAdmin } from './permissions.js'; import { canViewBoard, canEditBoard, canDeleteBoard, canManageMembers, isSiteAdmin, isTenantAdmin } from './permissions.js';
type TxClient = Prisma.TransactionClient; type TxClient = Prisma.TransactionClient;
@@ -50,6 +50,14 @@ export function requireAuth(user: SessionUser | null): asserts user is SessionUs
if (!user) throw error(401, 'Not authenticated'); if (!user) throw error(401, 'Not authenticated');
} }
/**
* Asserts user is a site admin. Throws 403 if not.
*/
export function requireSiteAdmin(user: SessionUser | null): asserts user is SessionUser {
requireAuth(user);
if (!isSiteAdmin(user)) throw error(403, 'Site admin access required');
}
/** /**
* Asserts user is a tenant admin. Throws 403 if not. * Asserts user is a tenant admin. Throws 403 if not.
*/ */
+10
View File
@@ -0,0 +1,10 @@
import pino from 'pino';
export const logger = pino({ name: 'pounce' });
export function createRequestLogger(event: { request: Request; url: URL }) {
return logger.child({
method: event.request.method,
path: event.url.pathname
});
}
+57
View File
@@ -0,0 +1,57 @@
const hits = new Map<string, number[]>();
export interface RateLimitResult {
ok: boolean;
remaining: number;
retryAfter?: number;
}
export function rateLimit(
ip: string,
opts: { window: number; max: number }
): RateLimitResult {
const now = Date.now();
const windowStart = now - opts.window;
let timestamps = hits.get(ip);
if (timestamps) {
timestamps = timestamps.filter((t) => t > windowStart);
hits.set(ip, timestamps);
} else {
timestamps = [];
hits.set(ip, timestamps);
}
if (timestamps.length >= opts.max) {
const oldest = timestamps[0];
const retryAfter = Math.ceil((oldest + opts.window - now) / 1000);
return { ok: false, remaining: 0, retryAfter };
}
timestamps.push(now);
return { ok: true, remaining: opts.max - timestamps.length };
}
/** Pre-configured: 10 login attempts per 60s */
export function loginLimiter(ip: string): RateLimitResult {
return rateLimit(ip, { window: 60_000, max: 10 });
}
/** Pre-configured: 5 registrations per 3600s */
export function registerLimiter(ip: string): RateLimitResult {
return rateLimit(ip, { window: 3_600_000, max: 5 });
}
// Periodic cleanup of stale entries every 60s
setInterval(() => {
const now = Date.now();
const maxWindow = 3_600_000; // largest window we use
for (const [ip, timestamps] of hits) {
const fresh = timestamps.filter((t) => t > now - maxWindow);
if (fresh.length === 0) {
hits.delete(ip);
} else {
hits.set(ip, fresh);
}
}
}, 60_000).unref();
+12 -4
View File
@@ -8,8 +8,8 @@ export const load: PageServerLoad = async ({ locals }) => {
if (!tenant) throw error(400, 'Unknown tenant'); if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user); requireTenantAdmin(locals.user);
const { users, boards } = await withTenant(tenant.id, async (tx) => { const { users, boards, tags, categories } = await withTenant(tenant.id, async (tx) => {
const [users, boards] = await Promise.all([ const [users, boards, tags, categories] = await Promise.all([
tx.user.findMany({ tx.user.findMany({
where: { tenantId: tenant.id }, where: { tenantId: tenant.id },
select: { select: {
@@ -31,10 +31,18 @@ export const load: PageServerLoad = async ({ locals }) => {
_count: { select: { members: true } } _count: { select: { members: true } }
}, },
orderBy: { updatedAt: 'desc' } orderBy: { updatedAt: 'desc' }
}),
tx.tag.findMany({
where: { tenantId: tenant.id },
orderBy: { name: 'asc' }
}),
tx.category.findMany({
where: { tenantId: tenant.id },
orderBy: { name: 'asc' }
}) })
]); ]);
return { users, boards }; return { users, boards, tags, categories };
}); });
return { users, boards, tenantName: tenant.name }; return { users, boards, tags, categories, tenantName: tenant.name };
}; };
+228 -9
View File
@@ -1,23 +1,114 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.js';
let { data } = $props(); let { data } = $props();
let users = $state(data.users.map((u: any) => ({ ...u }))); let users = $state(data.users.map((u: any) => ({ ...u })));
let tags = $state(data.tags.map((t: any) => ({ ...t })));
let categories = $state(data.categories.map((c: any) => ({ ...c })));
// Tag form
let newTagName = $state('');
let newTagColor = $state('#3b82f6');
let editingTagId = $state<string | null>(null);
let editTagName = $state('');
let editTagColor = $state('');
// Category form
let newCategoryName = $state('');
let editingCategoryId = $state<string | null>(null);
let editCategoryName = $state('');
async function changeRole(userId: string, tenantRole: string) { async function changeRole(userId: string, tenantRole: string) {
const res = await fetch(`/api/admin/users/${userId}`, { const result = await api(`/api/admin/users/${userId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tenantRole }) body: JSON.stringify({ tenantRole })
}); });
if (!res.ok) { if (result.ok) {
const json = await res.json(); users = users.map((u: any) => (u.id === userId ? { ...u, tenantRole: result.data.user.tenantRole } : u));
alert(json.error || json.message || 'Failed to change role'); }
return;
} }
const json = await res.json(); // ─── Tags ─────────────────────────────────────────────
users = users.map((u: any) => (u.id === userId ? { ...u, tenantRole: json.user.tenantRole } : u)); async function createTag() {
if (!newTagName.trim()) return;
const result = await api('/api/tags', {
method: 'POST',
body: JSON.stringify({ name: newTagName.trim(), color: newTagColor })
});
if (result.ok) {
tags = [...tags, result.data.tag].sort((a: any, b: any) => a.name.localeCompare(b.name));
newTagName = '';
newTagColor = '#3b82f6';
toast.success('Tag created');
}
}
function startEditTag(tag: any) {
editingTagId = tag.id;
editTagName = tag.name;
editTagColor = tag.color;
}
async function saveTag(id: string) {
const result = await api(`/api/tags/${id}`, {
method: 'PATCH',
body: JSON.stringify({ name: editTagName.trim(), color: editTagColor })
});
if (result.ok) {
tags = tags.map((t: any) => (t.id === id ? result.data.tag : t));
editingTagId = null;
toast.success('Tag updated');
}
}
async function deleteTag(id: string) {
const result = await api(`/api/tags/${id}`, { method: 'DELETE' });
if (result.ok) {
tags = tags.filter((t: any) => t.id !== id);
toast.success('Tag deleted');
}
}
// ─── Categories ───────────────────────────────────────
async function createCategory() {
if (!newCategoryName.trim()) return;
const result = await api('/api/categories', {
method: 'POST',
body: JSON.stringify({ name: newCategoryName.trim() })
});
if (result.ok) {
categories = [...categories, result.data.category].sort((a: any, b: any) => a.name.localeCompare(b.name));
newCategoryName = '';
toast.success('Category created');
}
}
function startEditCategory(cat: any) {
editingCategoryId = cat.id;
editCategoryName = cat.name;
}
async function saveCategory(id: string) {
const result = await api(`/api/categories/${id}`, {
method: 'PATCH',
body: JSON.stringify({ name: editCategoryName.trim() })
});
if (result.ok) {
categories = categories.map((c: any) => (c.id === id ? result.data.category : c));
editingCategoryId = null;
toast.success('Category updated');
}
}
async function deleteCategory(id: string) {
const result = await api(`/api/categories/${id}`, { method: 'DELETE' });
if (result.ok) {
categories = categories.filter((c: any) => c.id !== id);
toast.success('Category deleted');
}
} }
function formatDate(d: string) { function formatDate(d: string) {
@@ -74,7 +165,7 @@
</section> </section>
<!-- Boards --> <!-- Boards -->
<section> <section class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Boards</h2> <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Boards</h2>
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden"> <div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
<table class="w-full text-sm"> <table class="w-full text-sm">
@@ -115,4 +206,132 @@
</table> </table>
</div> </div>
</section> </section>
<!-- Tags -->
<section class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Tags</h2>
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
<!-- Create tag -->
<form
onsubmit={(e) => { e.preventDefault(); createTag(); }}
class="flex items-end gap-2 mb-4"
>
<div class="flex-1">
<label for="new-tag-name" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
id="new-tag-name"
type="text"
bind:value={newTagName}
placeholder="New tag..."
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-1.5 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<div>
<label for="new-tag-color" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Color</label>
<input
id="new-tag-color"
type="color"
bind:value={newTagColor}
class="h-[34px] w-12 cursor-pointer rounded border border-gray-300 dark:border-gray-600 p-0.5"
/>
</div>
<button
type="submit"
disabled={!newTagName.trim()}
class="rounded-lg bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
Add
</button>
</form>
<!-- Tag list -->
{#if tags.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No tags yet</p>
{:else}
<div class="space-y-2">
{#each tags as tag (tag.id)}
<div class="flex items-center gap-2 rounded-lg bg-gray-50 dark:bg-gray-800 px-3 py-2">
{#if editingTagId === tag.id}
<input
type="color"
bind:value={editTagColor}
class="h-6 w-8 cursor-pointer rounded border border-gray-300 dark:border-gray-600 p-0"
/>
<input
type="text"
bind:value={editTagName}
class="flex-1 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-sm focus:border-[var(--color-primary)] focus:outline-none"
/>
<button onclick={() => saveTag(tag.id)} class="text-xs text-[var(--color-primary)] hover:underline">Save</button>
<button onclick={() => (editingTagId = null)} class="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">Cancel</button>
{:else}
<span
class="inline-block h-4 w-4 rounded-full flex-shrink-0"
style="background-color: {tag.color}"
></span>
<span class="flex-1 text-sm text-gray-900 dark:text-gray-100">{tag.name}</span>
<button onclick={() => startEditTag(tag)} class="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">Edit</button>
<button onclick={() => deleteTag(tag.id)} class="text-xs text-red-500 hover:text-red-600">Delete</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</section>
<!-- Categories -->
<section>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Categories</h2>
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
<!-- Create category -->
<form
onsubmit={(e) => { e.preventDefault(); createCategory(); }}
class="flex items-end gap-2 mb-4"
>
<div class="flex-1">
<label for="new-category-name" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
id="new-category-name"
type="text"
bind:value={newCategoryName}
placeholder="New category..."
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-1.5 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<button
type="submit"
disabled={!newCategoryName.trim()}
class="rounded-lg bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
Add
</button>
</form>
<!-- Category list -->
{#if categories.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No categories yet</p>
{:else}
<div class="space-y-2">
{#each categories as cat (cat.id)}
<div class="flex items-center gap-2 rounded-lg bg-gray-50 dark:bg-gray-800 px-3 py-2">
{#if editingCategoryId === cat.id}
<input
type="text"
bind:value={editCategoryName}
class="flex-1 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-sm focus:border-[var(--color-primary)] focus:outline-none"
/>
<button onclick={() => saveCategory(cat.id)} class="text-xs text-[var(--color-primary)] hover:underline">Save</button>
<button onclick={() => (editingCategoryId = null)} class="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">Cancel</button>
{:else}
<span class="flex-1 text-sm text-gray-900 dark:text-gray-100">{cat.name}</span>
<button onclick={() => startEditCategory(cat)} class="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">Edit</button>
<button onclick={() => deleteCategory(cat.id)} class="text-xs text-red-500 hover:text-red-600">Delete</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</section>
</div> </div>
+30
View File
@@ -0,0 +1,30 @@
import type { PageServerLoad } from './$types.js';
import { requireSiteAdmin } from '$lib/server/guards.js';
import { withBypassRLS } from '$lib/server/rls.js';
export const load: PageServerLoad = async ({ locals }) => {
requireSiteAdmin(locals.user);
const { stats, tenants } = await withBypassRLS(async (tx) => {
const [tenantCount, userCount, boardCount, cardCount, tenants] = await Promise.all([
tx.tenant.count(),
tx.user.count(),
tx.board.count(),
tx.card.count(),
tx.tenant.findMany({
include: {
_count: { select: { users: true, boards: true } },
hostnames: { select: { hostname: true, isPrimary: true } }
},
orderBy: { createdAt: 'desc' }
})
]);
return {
stats: { tenants: tenantCount, users: userCount, boards: boardCount, cards: cardCount },
tenants
};
});
return { stats, tenants };
};
+210
View File
@@ -0,0 +1,210 @@
<script lang="ts">
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.js';
let { data } = $props();
let tenants = $state(data.tenants.map((t: any) => ({ ...t })));
let stats = $state(data.stats);
// Create tenant form
let showCreate = $state(false);
let newName = $state('');
let newSlug = $state('');
let newHostname = $state('');
let creating = $state(false);
// Delete confirmation
let deletingId = $state<string | null>(null);
async function createTenant() {
if (!newName.trim() || !newSlug.trim()) return;
creating = true;
const result = await api('/api/admin/site/tenants', {
method: 'POST',
body: JSON.stringify({
name: newName.trim(),
slug: newSlug.trim(),
hostname: newHostname.trim() || undefined
})
});
creating = false;
if (result.ok) {
tenants = [result.data.tenant, ...tenants];
stats = { ...stats, tenants: stats.tenants + 1 };
newName = '';
newSlug = '';
newHostname = '';
showCreate = false;
toast.success('Tenant created');
}
}
async function deleteTenant(id: string) {
const result = await api(`/api/admin/site/tenants/${id}`, { method: 'DELETE' });
if (result.ok) {
tenants = tenants.filter((t: any) => t.id !== id);
stats = { ...stats, tenants: stats.tenants - 1 };
deletingId = null;
toast.success('Tenant deleted');
}
}
function formatDate(d: string) {
return new Date(d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function autoSlug() {
newSlug = newName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
</script>
<svelte:head>
<title>Site Admin - Pounce</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-4 py-8">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">Site Administration</h1>
<!-- Stats Cards -->
<div class="grid grid-cols-2 gap-4 mb-8 sm:grid-cols-4">
{#each [
{ label: 'Tenants', value: stats.tenants },
{ label: 'Users', value: stats.users },
{ label: 'Boards', value: stats.boards },
{ label: 'Cards', value: stats.cards }
] as stat}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-5 text-center">
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">{stat.value.toLocaleString()}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">{stat.label}</div>
</div>
{/each}
</div>
<!-- Tenants -->
<section>
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Tenants</h2>
<button
onclick={() => (showCreate = !showCreate)}
class="rounded-lg bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90"
>
{showCreate ? 'Cancel' : 'New Tenant'}
</button>
</div>
<!-- Create Form -->
{#if showCreate}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4 mb-4">
<div class="grid gap-3 sm:grid-cols-3">
<div>
<label for="tenant-name" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
id="tenant-name"
type="text"
bind:value={newName}
oninput={autoSlug}
placeholder="Acme Corp"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<div>
<label for="tenant-slug" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Slug</label>
<input
id="tenant-slug"
type="text"
bind:value={newSlug}
placeholder="acme-corp"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<div>
<label for="tenant-hostname" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Hostname (optional)</label>
<input
id="tenant-hostname"
type="text"
bind:value={newHostname}
placeholder="acme.example.com"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
</div>
<div class="mt-3 flex justify-end">
<button
onclick={createTenant}
disabled={creating || !newName.trim() || !newSlug.trim()}
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Tenant'}
</button>
</div>
</div>
{/if}
<!-- Tenants Table -->
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 dark:border-gray-700 text-left text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Slug</th>
<th class="px-4 py-3">Hostnames</th>
<th class="px-4 py-3 text-center">Users</th>
<th class="px-4 py-3 text-center">Boards</th>
<th class="px-4 py-3">Created</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50 dark:divide-gray-800">
{#each tenants as tenant (tenant.id)}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">{tenant.name}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono text-xs">{tenant.slug}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 text-xs">
{#each tenant.hostnames as h}
<span class="inline-block rounded bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 mr-1">{h.hostname}</span>
{/each}
</td>
<td class="px-4 py-3 text-center text-gray-600 dark:text-gray-400">{tenant._count.users}</td>
<td class="px-4 py-3 text-center text-gray-600 dark:text-gray-400">{tenant._count.boards}</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDate(tenant.createdAt)}</td>
<td class="px-4 py-3 text-right">
{#if deletingId === tenant.id}
<span class="text-xs text-gray-500 dark:text-gray-400 mr-2">Are you sure?</span>
<button
onclick={() => deleteTenant(tenant.id)}
class="text-red-600 hover:text-red-700 text-xs font-medium mr-2"
>
Yes, delete
</button>
<button
onclick={() => (deletingId = null)}
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 text-xs"
>
Cancel
</button>
{:else}
<button
onclick={() => (deletingId = tenant.id)}
class="text-red-500 hover:text-red-600 text-xs"
>
Delete
</button>
{/if}
</td>
</tr>
{/each}
{#if tenants.length === 0}
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">No tenants</td>
</tr>
{/if}
</tbody>
</table>
</div>
</section>
</div>
@@ -0,0 +1,54 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireSiteAdmin } from '$lib/server/guards.js';
import { withBypassRLS } from '$lib/server/rls.js';
export const GET: RequestHandler = async ({ locals }) => {
requireSiteAdmin(locals.user);
const tenants = await withBypassRLS(async (tx) => {
return tx.tenant.findMany({
include: {
_count: { select: { users: true, boards: true } },
hostnames: { select: { hostname: true, isPrimary: true } }
},
orderBy: { createdAt: 'desc' }
});
});
return json({ tenants });
};
export const POST: RequestHandler = async ({ request, locals }) => {
requireSiteAdmin(locals.user);
const body = await request.json();
const { name, slug, hostname } = body;
if (!name || !slug) {
return json({ error: 'Name and slug are required' }, { status: 400 });
}
const tenant = await withBypassRLS(async (tx) => {
const existing = await tx.tenant.findUnique({ where: { slug } });
if (existing) return null;
return tx.tenant.create({
data: {
name,
slug,
hostnames: hostname ? { create: { hostname, isPrimary: true } } : undefined
},
include: {
_count: { select: { users: true, boards: true } },
hostnames: { select: { hostname: true, isPrimary: true } }
}
});
});
if (!tenant) {
return json({ error: 'A tenant with this slug already exists' }, { status: 409 });
}
return json({ tenant }, { status: 201 });
};
@@ -0,0 +1,45 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireSiteAdmin } from '$lib/server/guards.js';
import { withBypassRLS } from '$lib/server/rls.js';
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
requireSiteAdmin(locals.user);
const body = await request.json();
const { name } = body;
if (!name) {
return json({ error: 'Name is required' }, { status: 400 });
}
const tenant = await withBypassRLS(async (tx) => {
const existing = await tx.tenant.findUnique({ where: { id: params.tenantId } });
if (!existing) return null;
return tx.tenant.update({
where: { id: params.tenantId },
data: { name }
});
});
if (!tenant) throw error(404, 'Tenant not found');
return json({ tenant });
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
requireSiteAdmin(locals.user);
const deleted = await withBypassRLS(async (tx) => {
const existing = await tx.tenant.findUnique({ where: { id: params.tenantId } });
if (!existing) return false;
await tx.tenant.delete({ where: { id: params.tenantId } });
return true;
});
if (!deleted) throw error(404, 'Tenant not found');
return json({ ok: true });
};
+7 -1
View File
@@ -3,11 +3,17 @@ import type { RequestHandler } from './$types.js';
import { verifyPassword, createSession, sessionCookieName, sessionCookieOptions } from '$lib/server/auth.js'; import { verifyPassword, createSession, sessionCookieName, sessionCookieOptions } from '$lib/server/auth.js';
import { loginSchema } from '$lib/server/validation.js'; import { loginSchema } from '$lib/server/validation.js';
import { withTenant } from '$lib/server/rls.js'; import { withTenant } from '$lib/server/rls.js';
import { loginLimiter } from '$lib/server/ratelimit.js';
export const POST: RequestHandler = async ({ request, locals, cookies }) => { export const POST: RequestHandler = async ({ request, locals, cookies, getClientAddress }) => {
const tenant = locals.tenant; const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant'); if (!tenant) throw error(400, 'Unknown tenant');
const limit = loginLimiter(getClientAddress());
if (!limit.ok) {
return json({ error: 'Too many login attempts', retryAfter: limit.retryAfter }, { status: 429 });
}
const body = await request.json(); const body = await request.json();
const result = loginSchema.safeParse(body); const result = loginSchema.safeParse(body);
if (!result.success) { if (!result.success) {
+7 -1
View File
@@ -3,11 +3,17 @@ import type { RequestHandler } from './$types.js';
import { hashPassword, createSession, sessionCookieName, sessionCookieOptions } from '$lib/server/auth.js'; import { hashPassword, createSession, sessionCookieName, sessionCookieOptions } from '$lib/server/auth.js';
import { registerSchema } from '$lib/server/validation.js'; import { registerSchema } from '$lib/server/validation.js';
import { withTenant } from '$lib/server/rls.js'; import { withTenant } from '$lib/server/rls.js';
import { registerLimiter } from '$lib/server/ratelimit.js';
export const POST: RequestHandler = async ({ request, locals, cookies }) => { export const POST: RequestHandler = async ({ request, locals, cookies, getClientAddress }) => {
const tenant = locals.tenant; const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant'); if (!tenant) throw error(400, 'Unknown tenant');
const limit = registerLimiter(getClientAddress());
if (!limit.ok) {
return json({ error: 'Too many registration attempts', retryAfter: limit.retryAfter }, { status: 429 });
}
const body = await request.json(); const body = await request.json();
const result = registerSchema.safeParse(body); const result = registerSchema.safeParse(body);
if (!result.success) { if (!result.success) {
+17
View File
@@ -0,0 +1,17 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { prisma } from '$lib/server/db.js';
export const GET: RequestHandler = async () => {
let db = false;
try {
await prisma.$queryRaw`SELECT 1`;
db = true;
} catch {
// DB unreachable
}
const body = { status: db ? 'ok' : 'degraded', db, uptime: process.uptime() };
return json(body, { status: db ? 200 : 503 });
};