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 \
|
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%';" | \
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
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 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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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 {
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
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 —
|
* 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>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user