Add Site-Admin quota override management UI (#58)
All checks were successful
CD / Build and push images (push) Successful in 3m17s
CI / Lint, typecheck, test (push) Successful in 2m34s
CI / Auth e2e pack (push) Successful in 3m27s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Opus 4.8 2026-07-10 00:14:28 +02:00
parent f5f1310eb2
commit 6d3db7db38
13 changed files with 785 additions and 1 deletions

View File

@ -198,6 +198,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/public.spec.ts 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 - name: Reset login rate limit before offline pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -1,8 +1,15 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { QuotasModule } from '../quotas/quotas.module';
import { UsersModule } from '../users/users.module';
import { AdminSettingsController } from './admin.controller'; import { AdminSettingsController } from './admin.controller';
import { QuotaAdminController } from './quota-admin.controller';
import { QuotaAdminService } from './quota-admin.service';
@Module({ @Module({
controllers: [AdminSettingsController], imports: [QuotasModule, UsersModule],
controllers: [AdminSettingsController, QuotaAdminController],
providers: [QuotaAdminService],
}) })
export class AdminModule {} export class AdminModule {}

View File

@ -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<QuotaSubjectView> {
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<QuotaSubjectView> {
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<QuotaSubjectView> {
return this.quotaAdmin.clearOverride(request.user!, asSubject(type), id, key);
}
}

View File

@ -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<string> {
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<QuotaSubjectView> =>
(
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);
});
});

View File

@ -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<QuotaSubjectView> {
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<QuotaSubjectView> {
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<QuotaSubjectView> {
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<Partial<Record<QuotaKey, number>>> {
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 };
}
}

View File

@ -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<string> {
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();
}
});

View File

@ -7,6 +7,7 @@ import deLabels from '@dorfteich/shared/i18n/de/labels.json';
import deLinks from '@dorfteich/shared/i18n/de/links.json'; import deLinks from '@dorfteich/shared/i18n/de/links.json';
import deMembers from '@dorfteich/shared/i18n/de/members.json'; import deMembers from '@dorfteich/shared/i18n/de/members.json';
import dePublic from '@dorfteich/shared/i18n/de/public.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 deSearch from '@dorfteich/shared/i18n/de/search.json';
import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json';
import enAccess from '@dorfteich/shared/i18n/en/access.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 enLinks from '@dorfteich/shared/i18n/en/links.json';
import enMembers from '@dorfteich/shared/i18n/en/members.json'; import enMembers from '@dorfteich/shared/i18n/en/members.json';
import enPublic from '@dorfteich/shared/i18n/en/public.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 enSearch from '@dorfteich/shared/i18n/en/search.json';
import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import enSettings from '@dorfteich/shared/i18n/en/settings.json';
import i18n from 'i18next'; import i18n from 'i18next';
@ -46,6 +48,7 @@ void i18n
links: enLinks, links: enLinks,
members: enMembers, members: enMembers,
public: enPublic, public: enPublic,
quotas: enQuotas,
search: enSearch, search: enSearch,
}, },
de: { de: {
@ -59,6 +62,7 @@ void i18n
links: deLinks, links: deLinks,
members: deMembers, members: deMembers,
public: dePublic, public: dePublic,
quotas: deQuotas,
search: deSearch, search: deSearch,
}, },
}, },

View File

@ -5,15 +5,22 @@ import { useTranslation } from 'react-i18next';
import { Field, FormError, FormSuccess } from '../components/forms'; import { Field, FormError, FormSuccess } from '../components/forms';
import { apiGet, apiPatch } from '../lib/api'; import { apiGet, apiPatch } from '../lib/api';
import { QuotaManager } from './QuotaManager';
interface InstanceSettings { interface InstanceSettings {
'auth.registrationMode': 'open' | 'closed'; 'auth.registrationMode': 'open' | 'closed';
'instance.name': string; 'instance.name': string;
'instance.defaultLocale': 'de' | 'en'; '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 { export function AdminSettingsPage(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
const { t: tQuotas } = useTranslation('quotas');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
@ -66,6 +73,39 @@ export function AdminSettingsPage(): React.JSX.Element {
</button> </button>
</form> </form>
</section> </section>
<section className="settings-section">
<h2>{tQuotas('defaults.title')}</h2>
<form onSubmit={onSubmit} noValidate>
{(
[
'quota.editorsPerPond',
'quota.readersPerPond',
'quota.additionalPonds',
'quota.storageBytes',
'quota.maxFileBytes',
] as const
).map((key) => (
<Field key={key} label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[key]}`)}>
<input type="number" min={0} {...form.register(key, { valueAsNumber: true })} />
</Field>
))}
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{tQuotas('defaults.save')}
</button>
</form>
</section>
<QuotaManager />
</> </>
); );
} }
/** 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;

View File

@ -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<QuotaSubject>('user');
const [q, setQ] = useState('');
const [subject, setSubject] = useState<QuotaSubjectView | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(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<void> => {
setError(null);
setSubject(null);
try {
const hit = await apiGet<{ id: string }>(
`/admin/quotas/lookup?type=${type}&q=${encodeURIComponent(q.trim())}`,
);
apply(await apiGet<QuotaSubjectView>(`/admin/quotas/${type}/${hit.id}`));
} catch (err) {
setError(err instanceof ApiError && err.status === 404 ? t('overrides.notFound') : 'error');
}
};
const set = async (key: string): Promise<void> => {
if (!subject) return;
const value = Number(drafts[key]);
if (!Number.isFinite(value) || value < 0) return;
apply(
await apiPut<QuotaSubjectView>(`/admin/quotas/${subject.type}/${subject.id}/${key}`, {
value,
}),
);
};
const clear = async (key: string): Promise<void> => {
if (!subject) return;
apply(await apiDelete<QuotaSubjectView>(`/admin/quotas/${subject.type}/${subject.id}/${key}`));
};
return (
<section className="settings-section quota-manager">
<h2>{t('overrides.title')}</h2>
<div className="quota-manager__lookup">
<select
className="quota-manager__type"
value={type}
onChange={(e) => setType(e.target.value as QuotaSubject)}
>
<option value="user">{t('overrides.user')}</option>
<option value="pond">{t('overrides.pond')}</option>
</select>
<input
className="quota-manager__query"
value={q}
placeholder={t('overrides.query')}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && void find()}
/>
<button type="button" className="button" onClick={() => void find()}>
{t('overrides.find')}
</button>
</div>
{error && (
<p className="form-banner form-banner--error" role="alert">
{error === 'error' ? t('overrides.notFound') : error}
</p>
)}
{subject && (
<>
<p className="quota-manager__subject">{subject.label}</p>
<table className="table quota-manager__table">
<thead>
<tr>
<th>{t('overrides.key')}</th>
<th>{t('overrides.default')}</th>
<th>{t('overrides.override')}</th>
<th>{t('overrides.effective')}</th>
<th>{t('overrides.usage')}</th>
</tr>
</thead>
<tbody>
{subject.lines.map((line) => (
<QuotaRow
key={line.key}
line={line}
draft={drafts[line.key] ?? ''}
onDraft={(v) => setDrafts((d) => ({ ...d, [line.key]: v }))}
onSet={() => void set(line.key)}
onClear={() => void clear(line.key)}
/>
))}
</tbody>
</table>
</>
)}
</section>
);
}
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 (
<tr className={`quota-row${overQuota ? ' quota-row--over' : ''}`} data-key={line.key}>
<td>{t(`keys.${line.key}`)}</td>
<td>{line.instanceDefault}</td>
<td className="quota-row__override">
<input
type="number"
min={0}
className="quota-row__input"
value={draft}
onChange={(e) => onDraft(e.target.value)}
/>
<button type="button" className="linklike" onClick={onSet}>
{t('overrides.set')}
</button>
{line.override !== null && (
<button type="button" className="linklike" onClick={onClear}>
{t('overrides.clear')}
</button>
)}
</td>
<td className="quota-row__effective">{line.effective}</td>
<td className="quota-row__usage">
{line.usage ?? '—'}
{overQuota && <span className="quota-row__flag"> · {t('overrides.overQuota')}</span>}
</td>
</tr>
);
}

View File

@ -1693,3 +1693,34 @@ button {
.inspector__reason { .inspector__reason {
color: var(--color-text-muted); 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);
}

View File

@ -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)"
}
}

View File

@ -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)"
}
}

View File

@ -3,6 +3,8 @@
* three-level ladder: pond override user override instance default * three-level ladder: pond override user override instance default
* the most specific wins, mirroring the permission philosophy. * the most specific wins, mirroring the permission philosophy.
*/ */
import { z } from 'zod';
export const QUOTA_KEYS = [ export const QUOTA_KEYS = [
'editors_per_pond', 'editors_per_pond',
'readers_per_pond', 'readers_per_pond',
@ -12,3 +14,32 @@ export const QUOTA_KEYS = [
] as const; ] as const;
export type QuotaKey = (typeof QUOTA_KEYS)[number]; 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<typeof setQuotaOverrideSchema>;