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:
+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