From 6d3db7db383942007607c793c8c558aac9dc11a1 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Fri, 10 Jul 2026 00:14:28 +0200 Subject: [PATCH] Add Site-Admin quota override management UI (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Site Admins tune quotas per user and per pond on the three-level ladder (pond override → user override → instance default, data-model.md §Quotas). - api `admin/`: a `QuotaAdminService` + Site-Admin-gated endpoints under `/admin/quotas` — look up a user (username/e-mail) or pond (slug), list every quota's override / instance default / effective value (resolved through the existing QuotaService, the single consumption path, so a change takes effect immediately) plus current usage, and set/clear a per-subject override. Every change is audit-logged. A pond's effective values resolve on its own override then its owner's, matching the consumption checks. - web: the Admin area gains a 'Quotas' surface — the instance defaults move into a proper number-input form (was raw settings, #19), and a per-subject panel looks a user/pond up, shows the ladder with usage, flags subjects over their effective limit, and sets/clears overrides. New `quotas` i18n namespace (de+en). - tests: `quota-admin.e2e.db.test.ts` (override → effective changes at once and QuotaService sees it; clear → falls back to the default; lookup; Site-Admin gating); a browser `admin-quotas` pack proving an override raised in the UI immediately lets a user create another shared pond (issue #22 consumption). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .gitea/workflows/ci.yml | 10 ++ apps/api/src/admin/admin.module.ts | 9 +- apps/api/src/admin/quota-admin.controller.ts | 70 ++++++++ apps/api/src/admin/quota-admin.e2e.db.test.ts | 129 ++++++++++++++ apps/api/src/admin/quota-admin.service.ts | 168 ++++++++++++++++++ apps/web/e2e/admin-quotas.spec.ts | 57 ++++++ apps/web/src/i18n/index.ts | 4 + apps/web/src/pages/AdminSettingsPage.tsx | 40 +++++ apps/web/src/pages/QuotaManager.tsx | 163 +++++++++++++++++ apps/web/src/styles/base.css | 31 ++++ packages/shared/i18n/de/quotas.json | 37 ++++ packages/shared/i18n/en/quotas.json | 37 ++++ packages/shared/src/quotas.ts | 31 ++++ 13 files changed, 785 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/admin/quota-admin.controller.ts create mode 100644 apps/api/src/admin/quota-admin.e2e.db.test.ts create mode 100644 apps/api/src/admin/quota-admin.service.ts create mode 100644 apps/web/e2e/admin-quotas.spec.ts create mode 100644 apps/web/src/pages/QuotaManager.tsx create mode 100644 packages/shared/i18n/de/quotas.json create mode 100644 packages/shared/i18n/en/quotas.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 519ec93..d982107 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -198,6 +198,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/public.spec.ts + - name: Reset login rate limit before admin-quotas pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run admin-quotas pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/admin-quotas.spec.ts + - name: Reset login rate limit before offline pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts index ba6509e..add7a8c 100644 --- a/apps/api/src/admin/admin.module.ts +++ b/apps/api/src/admin/admin.module.ts @@ -1,8 +1,15 @@ import { Module } from '@nestjs/common'; +import { QuotasModule } from '../quotas/quotas.module'; +import { UsersModule } from '../users/users.module'; + import { AdminSettingsController } from './admin.controller'; +import { QuotaAdminController } from './quota-admin.controller'; +import { QuotaAdminService } from './quota-admin.service'; @Module({ - controllers: [AdminSettingsController], + imports: [QuotasModule, UsersModule], + controllers: [AdminSettingsController, QuotaAdminController], + providers: [QuotaAdminService], }) export class AdminModule {} diff --git a/apps/api/src/admin/quota-admin.controller.ts b/apps/api/src/admin/quota-admin.controller.ts new file mode 100644 index 0000000..f2e3d5c --- /dev/null +++ b/apps/api/src/admin/quota-admin.controller.ts @@ -0,0 +1,70 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Param, + Put, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { + QuotaSubject, + QuotaSubjectView, + SetQuotaOverrideInput, + setQuotaOverrideSchema, +} from '@dorfteich/shared'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { SiteAdminGuard } from './site-admin.guard'; +import { QuotaAdminService } from './quota-admin.service'; + +function asSubject(type: string): QuotaSubject { + if (type !== 'user' && type !== 'pond') throw new BadRequestException({ code: 'bad_request' }); + return type; +} + +/** Site-Admin quota override management (issue #58). */ +@Controller('admin/quotas') +@UseGuards(SiteAdminGuard) +export class QuotaAdminController { + constructor(private readonly quotaAdmin: QuotaAdminService) {} + + /** Resolve a user (username/e-mail) or pond (slug) to id + label. */ + @Get('lookup') + async lookup( + @Query('type') type: string, + @Query('q') q: string, + ): Promise<{ id: string; label: string }> { + return this.quotaAdmin.lookup(asSubject(type), q ?? ''); + } + + @Get(':type/:id') + async subject(@Param('type') type: string, @Param('id') id: string): Promise { + return this.quotaAdmin.subject(asSubject(type), id); + } + + @Put(':type/:id/:key') + async set( + @Param('type') type: string, + @Param('id') id: string, + @Param('key') key: string, + @Body(new ZodValidationPipe(setQuotaOverrideSchema)) input: SetQuotaOverrideInput, + @Req() request: AuthedRequest, + ): Promise { + return this.quotaAdmin.setOverride(request.user!, asSubject(type), id, key, input.value); + } + + @Delete(':type/:id/:key') + async clear( + @Param('type') type: string, + @Param('id') id: string, + @Param('key') key: string, + @Req() request: AuthedRequest, + ): Promise { + return this.quotaAdmin.clearOverride(request.user!, asSubject(type), id, key); + } +} diff --git a/apps/api/src/admin/quota-admin.e2e.db.test.ts b/apps/api/src/admin/quota-admin.e2e.db.test.ts new file mode 100644 index 0000000..bbf538f --- /dev/null +++ b/apps/api/src/admin/quota-admin.e2e.db.test.ts @@ -0,0 +1,129 @@ +import { INestApplication } from '@nestjs/common'; +import { QuotaSubjectView } from '@dorfteich/shared'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { QuotaService } from '../quotas/quota.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +/** + * Site-Admin quota override management end to end (issue #58): setting an + * override changes the effective value immediately; clearing it falls back to + * the instance default; the endpoint is Site-Admin-only. + */ +describe.skipIf(!hasTestDb)('quota override admin (e2e, issue #58)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'kontingente sind kein zufall 1'; + let adminCookie: string; + let userCookie: string; + let targetId: string; + let targetUsername: string; + + const api = () => request(app.getHttpServer()); + + async function makeUser( + handle: string, + siteAdmin: boolean, + ): Promise<{ id: string; username: string }> { + const users = app.get(UsersService); + const username = `quota-${handle}-${suffix}`; + const user = await users.createUser({ + username, + email: `${username}@example.org`, + displayName: `Quota ${handle}`, + password, + locale: 'en', + }); + await users.markEmailVerified(user.id); + if (siteAdmin) + await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } }); + return { id: user.id, username }; + } + + async function login(username: string): Promise { + return sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200), + ); + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + const admin = await makeUser('admin', true); + const target = await makeUser('target', false); + targetId = target.id; + targetUsername = target.username; + adminCookie = await login(admin.username); + userCookie = await login(target.username); + }); + + afterAll(async () => { + await prisma.quotaOverride.deleteMany({ where: { subjectId: targetId } }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + await app.close(); + }); + + const view = async (): Promise => + ( + await api() + .get(`/api/v1/admin/quotas/user/${targetId}`) + .set('Cookie', adminCookie) + .expect(200) + ).body as QuotaSubjectView; + + it('shows the ladder for a user with no override', async () => { + const v = await view(); + const ponds = v.lines.find((l) => l.key === 'additional_ponds')!; + expect(ponds).toMatchObject({ override: null, instanceDefault: 0, effective: 0, usage: 0 }); + }); + + it('setting an override changes the effective value immediately', async () => { + await api() + .put(`/api/v1/admin/quotas/user/${targetId}/additional_ponds`) + .set('Cookie', adminCookie) + .send({ value: 3 }) + .expect(200); + const ponds = (await view()).lines.find((l) => l.key === 'additional_ponds')!; + expect(ponds).toMatchObject({ override: 3, effective: 3 }); + // The QuotaService — the only consumption path — sees it at once. + expect(await app.get(QuotaService).getEffective('additional_ponds', { userId: targetId })).toBe( + 3, + ); + }); + + it('clearing an override falls back to the instance default', async () => { + await api() + .delete(`/api/v1/admin/quotas/user/${targetId}/additional_ponds`) + .set('Cookie', adminCookie) + .expect(200); + const ponds = (await view()).lines.find((l) => l.key === 'additional_ponds')!; + expect(ponds).toMatchObject({ override: null, effective: 0 }); + }); + + it('looks a user up by username', async () => { + const res = await api() + .get(`/api/v1/admin/quotas/lookup?type=user&q=${targetUsername}`) + .set('Cookie', adminCookie) + .expect(200); + expect((res.body as { id: string }).id).toBe(targetId); + }); + + it('is Site-Admin only', async () => { + await api().get(`/api/v1/admin/quotas/user/${targetId}`).set('Cookie', userCookie).expect(403); + await api() + .put(`/api/v1/admin/quotas/user/${targetId}/additional_ponds`) + .set('Cookie', userCookie) + .send({ value: 9 }) + .expect(403); + }); +}); diff --git a/apps/api/src/admin/quota-admin.service.ts b/apps/api/src/admin/quota-admin.service.ts new file mode 100644 index 0000000..f3afb23 --- /dev/null +++ b/apps/api/src/admin/quota-admin.service.ts @@ -0,0 +1,168 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + QUOTA_KEYS, + QuotaKey, + QuotaLineView, + QuotaSubject, + QuotaSubjectView, +} from '@dorfteich/shared'; +import { User } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; + +import { PrismaService } from '../prisma/prisma.service'; +import { QuotaService } from '../quotas/quota.service'; +import { UsersService } from '../users/users.service'; + +/** + * Site-Admin quota override management (issue #58): view/set/clear per-user and + * per-pond overrides on the three-level ladder (pond → user → instance default, + * data-model.md §Quotas). The QuotaService stays the single resolution path — + * this only reads its effective values and writes `quota_overrides` rows, so an + * override takes effect at the next consumption check immediately. Every change + * is audit-logged. Site-Admin gating is the controller's job. + */ +@Injectable() +export class QuotaAdminService { + constructor( + private readonly prisma: PrismaService, + private readonly quotas: QuotaService, + private readonly users: UsersService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(QuotaAdminService.name); + } + + /** Resolve a user (by username/e-mail) or pond (by slug) to id + label. */ + async lookup(type: QuotaSubject, q: string): Promise<{ id: string; label: string }> { + if (type === 'user') { + const user = await this.users.findByUsernameOrEmail(q); + if (!user) throw new NotFoundException({ code: 'member_not_found' }); + return { id: user.id, label: user.username }; + } + const pond = await this.prisma.pond.findFirst({ where: { slug: q.trim(), deletedAt: null } }); + if (!pond) throw new NotFoundException(); + return { id: pond.id, label: pond.name }; + } + + async subject(type: QuotaSubject, id: string): Promise { + const { label, scope } = await this.subjectScope(type, id); + const overrides = await this.prisma.quotaOverride.findMany({ + where: { subjectType: type === 'user' ? 'USER' : 'POND', subjectId: id }, + }); + const overrideOf = new Map(overrides.map((o) => [o.quotaKey, Number(o.value)])); + const usage = await this.usageFor(type, id); + + const lines: QuotaLineView[] = await Promise.all( + QUOTA_KEYS.map(async (key) => ({ + key, + override: overrideOf.get(key) ?? null, + instanceDefault: await this.quotas.getEffective(key, {}), + effective: await this.quotas.getEffective(key, scope), + usage: usage[key] ?? null, + })), + ); + return { type, id, label, lines }; + } + + async setOverride( + actor: User, + type: QuotaSubject, + id: string, + key: string, + value: number, + ): Promise { + const quotaKey = this.assertKey(key); + await this.subjectScope(type, id); // 404s an unknown subject + const subjectType = type === 'user' ? 'USER' : 'POND'; + await this.prisma.quotaOverride.upsert({ + where: { subjectType_subjectId_quotaKey: { subjectType, subjectId: id, quotaKey } }, + create: { subjectType, subjectId: id, quotaKey, value }, + update: { value }, + }); + this.logger.info({ actor: actor.id, type, id, quotaKey, value }, 'audit: quota override set'); + return this.subject(type, id); + } + + async clearOverride( + actor: User, + type: QuotaSubject, + id: string, + key: string, + ): Promise { + const quotaKey = this.assertKey(key); + await this.subjectScope(type, id); + await this.prisma.quotaOverride.deleteMany({ + where: { subjectType: type === 'user' ? 'USER' : 'POND', subjectId: id, quotaKey }, + }); + this.logger.info({ actor: actor.id, type, id, quotaKey }, 'audit: quota override cleared'); + return this.subject(type, id); + } + + private assertKey(key: string): QuotaKey { + if (!(QUOTA_KEYS as readonly string[]).includes(key)) { + throw new BadRequestException({ code: 'bad_request' }); + } + return key as QuotaKey; + } + + /** The subject's label and the ladder scope its effective values resolve on. */ + private async subjectScope( + type: QuotaSubject, + id: string, + ): Promise<{ label: string; scope: { userId?: string; pondId?: string } }> { + if (type === 'user') { + const user = await this.prisma.user.findUnique({ + where: { id }, + select: { username: true }, + }); + if (!user) throw new NotFoundException({ code: 'member_not_found' }); + return { label: user.username, scope: { userId: id } }; + } + const pond = await this.prisma.pond.findFirst({ + where: { id, deletedAt: null }, + select: { name: true, ownerId: true }, + }); + if (!pond) throw new NotFoundException(); + // A pond resolves on its own override, then its owner's, then the default. + return { label: pond.name, scope: { pondId: id, userId: pond.ownerId } }; + } + + /** Current usage for the metered dimensions (soft warning in the UI). */ + private async usageFor( + type: QuotaSubject, + id: string, + ): Promise>> { + if (type === 'pond') { + const [usage, editors, readers] = await Promise.all([ + this.prisma.pondUsage.findUnique({ where: { pondId: id } }), + this.prisma.roleGrant.count({ + where: { + pondId: id, + role: 'EDITOR', + scopeType: 'POND', + subjectType: 'USER', + effect: 'ALLOW', + }, + }), + this.prisma.roleGrant.count({ + where: { + pondId: id, + role: 'READER', + scopeType: 'POND', + subjectType: 'USER', + effect: 'ALLOW', + }, + }), + ]); + return { + storage_bytes: Number(usage?.storageBytesUsed ?? 0), + editors_per_pond: editors, + readers_per_pond: readers, + }; + } + const owned = await this.prisma.pond.count({ + where: { ownerId: id, type: 'SHARED', deletedAt: null }, + }); + return { additional_ponds: owned }; + } +} diff --git a/apps/web/e2e/admin-quotas.spec.ts b/apps/web/e2e/admin-quotas.spec.ts new file mode 100644 index 0000000..8d10f51 --- /dev/null +++ b/apps/web/e2e/admin-quotas.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; +import type { BrowserContext } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +/** + * Site-Admin quota override management (issue #58): an override set through the + * admin UI takes effect immediately — a user at their `additional_ponds` limit + * can create one more shared pond once the override is raised above their + * current usage. Written to be independent of pre-existing state. + */ +async function userId(ctx: BrowserContext): Promise { + return ((await (await ctx.request.get('/api/v1/auth/me')).json()) as { id: string }).id; +} + +test('a quota override set in the admin UI takes effect immediately', async ({ browser }) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const user = await contextForUser(browser, BASE_URL, 'fixture-editor'); + const uid = await userId(user); + const override = `/api/v1/admin/quotas/user/${uid}/additional_ponds`; + + // Baseline: no override → the default (0) applies, so the user is at/over + // limit and cannot create another shared pond. + await admin.request.delete(override); + const owned = ( + (await (await admin.request.get(`/api/v1/admin/quotas/user/${uid}`)).json()) as { + lines: { key: string; usage: number | null }[]; + } + ).lines.find((l) => l.key === 'additional_ponds')!.usage!; + const before = await user.request.post('/api/v1/ponds', { data: { name: `Q ${Date.now()}` } }); + expect(before.status()).toBe(403); + + const page = await admin.newPage(); + await page.goto('/admin'); + await page.locator('.quota-manager__type').selectOption('user'); + await page.locator('.quota-manager__query').fill('fixture-editor'); + await page.getByRole('button', { name: /find|suchen/i }).click(); + + const row = page.locator('.quota-row[data-key="additional_ponds"]'); + await expect(row).toBeVisible(); + await row.locator('.quota-row__input').fill(String(owned + 1)); // room for exactly one more + await row.getByRole('button', { name: /^(set|setzen)$/i }).click(); + await expect(row.locator('.quota-row__effective')).toHaveText(String(owned + 1)); + + try { + const after = await user.request.post('/api/v1/ponds', { data: { name: `Q ${Date.now()}` } }); + expect(after.status()).toBe(201); + const pond = (await after.json()) as { id: string }; + await user.request.delete(`/api/v1/ponds/${pond.id}`); + } finally { + await admin.request.delete(override); // repeatable + await admin.close(); + await user.close(); + } +}); diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 4cc63b2..4e84648 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -7,6 +7,7 @@ import deLabels from '@dorfteich/shared/i18n/de/labels.json'; import deLinks from '@dorfteich/shared/i18n/de/links.json'; import deMembers from '@dorfteich/shared/i18n/de/members.json'; import dePublic from '@dorfteich/shared/i18n/de/public.json'; +import deQuotas from '@dorfteich/shared/i18n/de/quotas.json'; import deSearch from '@dorfteich/shared/i18n/de/search.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import enAccess from '@dorfteich/shared/i18n/en/access.json'; @@ -18,6 +19,7 @@ import enLabels from '@dorfteich/shared/i18n/en/labels.json'; import enLinks from '@dorfteich/shared/i18n/en/links.json'; import enMembers from '@dorfteich/shared/i18n/en/members.json'; import enPublic from '@dorfteich/shared/i18n/en/public.json'; +import enQuotas from '@dorfteich/shared/i18n/en/quotas.json'; import enSearch from '@dorfteich/shared/i18n/en/search.json'; import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import i18n from 'i18next'; @@ -46,6 +48,7 @@ void i18n links: enLinks, members: enMembers, public: enPublic, + quotas: enQuotas, search: enSearch, }, de: { @@ -59,6 +62,7 @@ void i18n links: deLinks, members: deMembers, public: dePublic, + quotas: deQuotas, search: deSearch, }, }, diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index 0084bf9..d903847 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -5,15 +5,22 @@ import { useTranslation } from 'react-i18next'; import { Field, FormError, FormSuccess } from '../components/forms'; import { apiGet, apiPatch } from '../lib/api'; +import { QuotaManager } from './QuotaManager'; interface InstanceSettings { 'auth.registrationMode': 'open' | 'closed'; 'instance.name': string; 'instance.defaultLocale': 'de' | 'en'; + 'quota.editorsPerPond': number; + 'quota.readersPerPond': number; + 'quota.additionalPonds': number; + 'quota.storageBytes': number; + 'quota.maxFileBytes': number; } export function AdminSettingsPage(): React.JSX.Element { const { t } = useTranslation(); + const { t: tQuotas } = useTranslation('quotas'); const queryClient = useQueryClient(); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); @@ -66,6 +73,39 @@ export function AdminSettingsPage(): React.JSX.Element { + +
+

{tQuotas('defaults.title')}

+
+ {( + [ + 'quota.editorsPerPond', + 'quota.readersPerPond', + 'quota.additionalPonds', + 'quota.storageBytes', + 'quota.maxFileBytes', + ] as const + ).map((key) => ( + + + + ))} + +
+
+ + ); } + +/** Map the instance-setting key to the shared quota key its label lives under. */ +const SETTING_TO_QUOTA_KEY = { + 'quota.editorsPerPond': 'editors_per_pond', + 'quota.readersPerPond': 'readers_per_pond', + 'quota.additionalPonds': 'additional_ponds', + 'quota.storageBytes': 'storage_bytes', + 'quota.maxFileBytes': 'max_file_bytes', +} as const; diff --git a/apps/web/src/pages/QuotaManager.tsx b/apps/web/src/pages/QuotaManager.tsx new file mode 100644 index 0000000..20dbd3e --- /dev/null +++ b/apps/web/src/pages/QuotaManager.tsx @@ -0,0 +1,163 @@ +import type { QuotaLineView, QuotaSubject, QuotaSubjectView } from '@dorfteich/shared'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ApiError, apiDelete, apiGet, apiPut } from '../lib/api'; + +/** + * Site-Admin quota override management (issue #58): look up a user or pond, see + * every quota's effective value on the ladder, and set or clear a per-subject + * override — which the QuotaService picks up at the next consumption check. The + * usage column flags subjects currently over their effective limit. + */ +export function QuotaManager(): React.JSX.Element { + const { t } = useTranslation('quotas'); + const [type, setType] = useState('user'); + const [q, setQ] = useState(''); + const [subject, setSubject] = useState(null); + const [drafts, setDrafts] = useState>({}); + const [error, setError] = useState(null); + + const apply = (view: QuotaSubjectView): void => { + setSubject(view); + setDrafts( + Object.fromEntries( + view.lines.map((l) => [l.key, l.override === null ? '' : String(l.override)]), + ), + ); + }; + + const find = async (): Promise => { + setError(null); + setSubject(null); + try { + const hit = await apiGet<{ id: string }>( + `/admin/quotas/lookup?type=${type}&q=${encodeURIComponent(q.trim())}`, + ); + apply(await apiGet(`/admin/quotas/${type}/${hit.id}`)); + } catch (err) { + setError(err instanceof ApiError && err.status === 404 ? t('overrides.notFound') : 'error'); + } + }; + + const set = async (key: string): Promise => { + if (!subject) return; + const value = Number(drafts[key]); + if (!Number.isFinite(value) || value < 0) return; + apply( + await apiPut(`/admin/quotas/${subject.type}/${subject.id}/${key}`, { + value, + }), + ); + }; + + const clear = async (key: string): Promise => { + if (!subject) return; + apply(await apiDelete(`/admin/quotas/${subject.type}/${subject.id}/${key}`)); + }; + + return ( +
+

{t('overrides.title')}

+
+ + setQ(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && void find()} + /> + +
+ {error && ( +

+ {error === 'error' ? t('overrides.notFound') : error} +

+ )} + + {subject && ( + <> +

{subject.label}

+ + + + + + + + + + + + {subject.lines.map((line) => ( + setDrafts((d) => ({ ...d, [line.key]: v }))} + onSet={() => void set(line.key)} + onClear={() => void clear(line.key)} + /> + ))} + +
{t('overrides.key')}{t('overrides.default')}{t('overrides.override')}{t('overrides.effective')}{t('overrides.usage')}
+ + )} +
+ ); +} + +function QuotaRow({ + line, + draft, + onDraft, + onSet, + onClear, +}: { + line: QuotaLineView; + draft: string; + onDraft: (v: string) => void; + onSet: () => void; + onClear: () => void; +}): React.JSX.Element { + const { t } = useTranslation('quotas'); + const overQuota = line.usage !== null && line.usage > line.effective; + return ( + + {t(`keys.${line.key}`)} + {line.instanceDefault} + + onDraft(e.target.value)} + /> + + {line.override !== null && ( + + )} + + {line.effective} + + {line.usage ?? '—'} + {overQuota && · {t('overrides.overQuota')}} + + + ); +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 9a96139..2d45e96 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1693,3 +1693,34 @@ button { .inspector__reason { color: var(--color-text-muted); } + +/* Quota override manager (issue #58) */ +.quota-manager__lookup { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.quota-manager__subject { + font-weight: 600; + margin-bottom: var(--space-2); +} + +.quota-row__override { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.quota-row__input { + width: 8rem; +} + +.quota-row--over .quota-row__usage { + color: var(--color-danger); +} + +.quota-row__flag { + color: var(--color-danger); +} diff --git a/packages/shared/i18n/de/quotas.json b/packages/shared/i18n/de/quotas.json new file mode 100644 index 0000000..c307479 --- /dev/null +++ b/packages/shared/i18n/de/quotas.json @@ -0,0 +1,37 @@ +{ + "title": "Kontingente", + "defaults": { + "title": "Instanz-Standardwerte", + "editors_per_pond": "Bearbeiter pro Teich", + "readers_per_pond": "Leser pro Teich", + "additional_ponds": "Zusätzliche geteilte Teiche pro Person", + "storage_bytes": "Speicher pro Teich (Bytes)", + "max_file_bytes": "Max. Upload-Größe (Bytes)", + "save": "Standardwerte speichern", + "saved": "Standardwerte gespeichert." + }, + "overrides": { + "title": "Overrides pro Person / Teich", + "type": "Subjekt", + "user": "Person", + "pond": "Teich", + "query": "Benutzername, E-Mail oder Teich-Slug", + "find": "Suchen", + "notFound": "Kein passendes Subjekt.", + "key": "Kontingent", + "default": "Standard", + "override": "Override", + "effective": "Effektiv", + "usage": "Nutzung", + "set": "Setzen", + "clear": "Löschen", + "overQuota": "über Kontingent" + }, + "keys": { + "editors_per_pond": "Bearbeiter pro Teich", + "readers_per_pond": "Leser pro Teich", + "additional_ponds": "Zusätzliche geteilte Teiche", + "storage_bytes": "Speicher (Bytes)", + "max_file_bytes": "Max. Upload (Bytes)" + } +} diff --git a/packages/shared/i18n/en/quotas.json b/packages/shared/i18n/en/quotas.json new file mode 100644 index 0000000..52e06bf --- /dev/null +++ b/packages/shared/i18n/en/quotas.json @@ -0,0 +1,37 @@ +{ + "title": "Quotas", + "defaults": { + "title": "Instance defaults", + "editors_per_pond": "Editors per pond", + "readers_per_pond": "Readers per pond", + "additional_ponds": "Additional shared ponds per user", + "storage_bytes": "Storage per pond (bytes)", + "max_file_bytes": "Max upload size (bytes)", + "save": "Save defaults", + "saved": "Defaults saved." + }, + "overrides": { + "title": "Per-user / per-pond overrides", + "type": "Subject", + "user": "User", + "pond": "Pond", + "query": "Username, e-mail or pond slug", + "find": "Find", + "notFound": "No matching subject.", + "key": "Quota", + "default": "Default", + "override": "Override", + "effective": "Effective", + "usage": "Usage", + "set": "Set", + "clear": "Clear", + "overQuota": "over quota" + }, + "keys": { + "editors_per_pond": "Editors per pond", + "readers_per_pond": "Readers per pond", + "additional_ponds": "Additional shared ponds", + "storage_bytes": "Storage (bytes)", + "max_file_bytes": "Max upload (bytes)" + } +} diff --git a/packages/shared/src/quotas.ts b/packages/shared/src/quotas.ts index 369cc10..5cda35d 100644 --- a/packages/shared/src/quotas.ts +++ b/packages/shared/src/quotas.ts @@ -3,6 +3,8 @@ * three-level ladder: pond override → user override → instance default — * the most specific wins, mirroring the permission philosophy. */ +import { z } from 'zod'; + export const QUOTA_KEYS = [ 'editors_per_pond', 'readers_per_pond', @@ -12,3 +14,32 @@ export const QUOTA_KEYS = [ ] as const; export type QuotaKey = (typeof QUOTA_KEYS)[number]; + +/** Which subject a quota override applies to (data-model.md §Quotas). */ +export type QuotaSubject = 'user' | 'pond'; + +/** + * One quota dimension for a subject in the admin UI (issue #58): the effective + * value on the ladder (pond/user override → instance default), the override at + * this level if any, and current usage where the dimension is metered. + */ +export interface QuotaLineView { + key: QuotaKey; + /** The override set at this subject level, or `null` (falls back). */ + override: number | null; + instanceDefault: number; + effective: number; + /** Current usage for metered dimensions (storage/seats/ponds), else `null`. */ + usage: number | null; +} + +/** The resolved quota picture for one user or pond. */ +export interface QuotaSubjectView { + type: QuotaSubject; + id: string; + label: string; + lines: QuotaLineView[]; +} + +export const setQuotaOverrideSchema = z.object({ value: z.number().int().min(0) }); +export type SetQuotaOverrideInput = z.infer;