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
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:
parent
f5f1310eb2
commit
6d3db7db38
@ -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%';" | \
|
||||
|
||||
@ -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 {}
|
||||
|
||||
70
apps/api/src/admin/quota-admin.controller.ts
Normal file
70
apps/api/src/admin/quota-admin.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
129
apps/api/src/admin/quota-admin.e2e.db.test.ts
Normal file
129
apps/api/src/admin/quota-admin.e2e.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
168
apps/api/src/admin/quota-admin.service.ts
Normal file
168
apps/api/src/admin/quota-admin.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
57
apps/web/e2e/admin-quotas.spec.ts
Normal file
57
apps/web/e2e/admin-quotas.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@ -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<unknown>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
@ -66,6 +73,39 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
</button>
|
||||
</form>
|
||||
</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;
|
||||
|
||||
163
apps/web/src/pages/QuotaManager.tsx
Normal file
163
apps/web/src/pages/QuotaManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
37
packages/shared/i18n/de/quotas.json
Normal file
37
packages/shared/i18n/de/quotas.json
Normal 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)"
|
||||
}
|
||||
}
|
||||
37
packages/shared/i18n/en/quotas.json
Normal file
37
packages/shared/i18n/en/quotas.json
Normal 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)"
|
||||
}
|
||||
}
|
||||
@ -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<typeof setQuotaOverrideSchema>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user