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:
@@ -37,6 +37,11 @@ RUN mkdir -p /app/uploads
|
||||
|
||||
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 ./
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000/api/health']
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
Generated
+132
@@ -17,6 +17,7 @@
|
||||
"dompurify": "^3.3.1",
|
||||
"express": "^5.1.0",
|
||||
"marked": "^15.0.0",
|
||||
"pino": "^10.3.1",
|
||||
"socket.io": "^4.8.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"svelte-dnd-action": "^0.9.52",
|
||||
@@ -498,6 +499,12 @@
|
||||
"@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": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -1578,6 +1585,15 @@
|
||||
"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": {
|
||||
"version": "10.4.27",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||
@@ -3184,6 +3200,15 @@
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -3262,6 +3287,43 @@
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"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": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -3382,6 +3460,12 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -3431,6 +3515,15 @@
|
||||
"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": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -3535,6 +3628,15 @@
|
||||
"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": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
@@ -3783,6 +3885,15 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -3792,6 +3903,15 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -3904,6 +4024,18 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"dompurify": "^3.3.1",
|
||||
"express": "^5.1.0",
|
||||
"marked": "^15.0.0",
|
||||
"pino": "^10.3.1",
|
||||
"socket.io": "^4.8.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"svelte-dnd-action": "^0.9.52",
|
||||
|
||||
@@ -2,8 +2,11 @@ import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import pino from 'pino';
|
||||
import { handler } from './build/handler.js';
|
||||
|
||||
const logger = pino({ name: 'pounce' });
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
@@ -64,7 +67,7 @@ io.use(async (socket, next) => {
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error('Socket auth error:', err);
|
||||
logger.error({ err }, 'Socket auth error');
|
||||
socket.data.user = null;
|
||||
next();
|
||||
}
|
||||
@@ -73,7 +76,7 @@ io.use(async (socket, next) => {
|
||||
// ─── Socket.IO connection handler ────────────────────────────
|
||||
io.on('connection', (socket) => {
|
||||
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
|
||||
if (user) {
|
||||
@@ -122,7 +125,7 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Socket disconnected: ${socket.id}`);
|
||||
logger.info({ socketId: socket.id }, 'Socket disconnected');
|
||||
for (const boardId of joinedBoards) {
|
||||
if (presence.has(boardId)) {
|
||||
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';
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`Server running on http://${host}:${port}`);
|
||||
logger.info({ host, port }, 'Server running');
|
||||
});
|
||||
|
||||
+12
-1
@@ -1,8 +1,10 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { resolveTenant } from '$lib/server/tenant.js';
|
||||
import { validateSession, sessionCookieName } from '$lib/server/auth.js';
|
||||
import { logger } from '$lib/server/logger.js';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const start = Date.now();
|
||||
// 1. Resolve tenant from hostname
|
||||
const hostname = event.url.hostname;
|
||||
const tenant = await resolveTenant(hostname);
|
||||
@@ -45,5 +47,14 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
// 3. Read theme preference
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
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;
|
||||
|
||||
@@ -50,6 +50,14 @@ export function requireAuth(user: SessionUser | null): asserts user is SessionUs
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -8,8 +8,8 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
requireTenantAdmin(locals.user);
|
||||
|
||||
const { users, boards } = await withTenant(tenant.id, async (tx) => {
|
||||
const [users, boards] = await Promise.all([
|
||||
const { users, boards, tags, categories } = await withTenant(tenant.id, async (tx) => {
|
||||
const [users, boards, tags, categories] = await Promise.all([
|
||||
tx.user.findMany({
|
||||
where: { tenantId: tenant.id },
|
||||
select: {
|
||||
@@ -31,10 +31,18 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
_count: { select: { members: true } }
|
||||
},
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -1,23 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/utils/api.js';
|
||||
import { toast } from '$lib/stores/toasts.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
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) {
|
||||
const res = await fetch(`/api/admin/users/${userId}`, {
|
||||
const result = await api(`/api/admin/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenantRole })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
alert(json.error || json.message || 'Failed to change role');
|
||||
return;
|
||||
if (result.ok) {
|
||||
users = users.map((u: any) => (u.id === userId ? { ...u, tenantRole: result.data.user.tenantRole } : u));
|
||||
}
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
users = users.map((u: any) => (u.id === userId ? { ...u, tenantRole: json.user.tenantRole } : u));
|
||||
// ─── Tags ─────────────────────────────────────────────
|
||||
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) {
|
||||
@@ -74,7 +165,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Boards -->
|
||||
<section>
|
||||
<section class="mb-8">
|
||||
<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">
|
||||
<table class="w-full text-sm">
|
||||
@@ -115,4 +206,132 @@
|
||||
</table>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -3,11 +3,17 @@ import type { RequestHandler } from './$types.js';
|
||||
import { verifyPassword, createSession, sessionCookieName, sessionCookieOptions } from '$lib/server/auth.js';
|
||||
import { loginSchema } from '$lib/server/validation.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;
|
||||
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 result = loginSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
|
||||
@@ -3,11 +3,17 @@ import type { RequestHandler } from './$types.js';
|
||||
import { hashPassword, createSession, sessionCookieName, sessionCookieOptions } from '$lib/server/auth.js';
|
||||
import { registerSchema } from '$lib/server/validation.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;
|
||||
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 result = registerSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user