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
164 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|