dorfteich/apps/web/src/pages/QuotaManager.tsx
Claude Opus 4.8 6d3db7db38
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
Add Site-Admin quota override management UI (#58)
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
2026-07-10 00:14:28 +02:00

164 lines
5.3 KiB
TypeScript

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>
);
}