dorfteich/apps/web/src/pages/InvitationsSection.tsx
Claude Fable 5 c2a4dde5cc
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m50s
CI / Build container images (pull_request) Successful in 3m57s
CI / Auth e2e pack (pull_request) Failing after 6m18s
CI / Import/export fidelity gate (pull_request) Has been skipped
Invitation flow with per-user quota (#332)
Any authenticated user can invite an e-mail address; the mailed
single-use token lets exactly one signup through even while
registration is closed. Open (pending, unexpired) invitations count
against the new instance setting invitations.maxOpenPerUser (default 5,
0 disables inviting) — plus a 20/day per-user rate limit so a
revoke-and-recreate loop cannot become a mail cannon. Only the SHA-256
token hash is stored (auth-tokens pattern); a failed signup (taken
username) un-redeems the token so the invitee can retry.

Surfaces: invitations section in the user settings (list, invite,
revoke, quota line; wide table in a focusable .table-scroll region),
signup page reads ?invitation=<token> (preview banner, e-mail prefill,
closed-mode gate opens only for a previewed-valid token), admin general
card gets the quota field (flat RHF name per #322; VS-NfD marked and
hideable).

Governance: audit actions invitation.created/revoked/accepted
(catalogue 1.10), VS-NfD profile entry (compliant: 0) + hardening-guide
row, i18n de+en including the invitation mail template.

Tests: api e2e-db (mail link, closed-mode single-use signup with
un-redeem on failure, quota + revoke frees slot, quota 0 = 403, auth
matrix), new web e2e pack invitations.spec.ts (full UI loop through
Mailpit, wired into ci.yml with its own rate-limit reset), a11y scan
waits for the new section. Full api suite (107 files / 607 tests),
auth/admin-settings/a11y packs green against a fresh local stack.

Closes #332
2026-08-05 12:44:20 +02:00

141 lines
5.1 KiB
TypeScript

import type { InvitationListView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Field, FormError } from '../components/forms';
import { apiDelete, apiGet, apiPost } from '../lib/api';
const INVITATIONS_QUERY_KEY = ['users', 'me', 'invitations'] as const;
/**
* Peer invitations in the user settings (issue #332): invite an e-mail
* address, see your invitations with their status, revoke open ones. The
* quota line shows how many of the instance-wide per-user allowance are
* in use; with a quota of 0 the section explains that inviting is off.
*/
export function InvitationsSection(): React.JSX.Element {
const { t } = useTranslation('invitations');
const queryClient = useQueryClient();
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false);
const list = useQuery({
queryKey: INVITATIONS_QUERY_KEY,
queryFn: () => apiGet<InvitationListView>('/invitations'),
});
const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
setError(null);
setSent(false);
setBusy(true);
try {
await apiPost('/invitations', { email });
setEmail('');
setSent(true);
await queryClient.invalidateQueries({ queryKey: INVITATIONS_QUERY_KEY });
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
};
const revoke = async (id: string): Promise<void> => {
setError(null);
await apiDelete(`/invitations/${id}`);
await queryClient.invalidateQueries({ queryKey: INVITATIONS_QUERY_KEY });
};
const data = list.data;
const disabled = data?.maxOpen === 0;
return (
<section className="settings-section invitations">
<h2>{t('section.title')}</h2>
{disabled ? (
<p>{t('section.disabled')}</p>
) : (
<>
<p className="invitations__intro">{t('section.intro')}</p>
{data && (
<p className="invitations__quota">
{t('section.quota', { open: data.open, max: data.maxOpen })}
</p>
)}
<form onSubmit={(e) => void submit(e)} noValidate className="invitations__form">
<FormError error={error} />
{/* Scoped status region: a bare getByRole('status') must stay
unambiguous for other specs (lesson from #304/legal). */}
<p className="invitations__sent" role="status">
{sent ? t('form.sent') : ''}
</p>
<Field label={t('form.email')}>
<input
type="email"
value={email}
required
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<button type="submit" className="button" disabled={busy || email.length === 0}>
{t('form.submit')}
</button>
</form>
{data && data.invitations.length === 0 && <p>{t('section.empty')}</p>}
{data && data.invitations.length > 0 && (
<div
className="table-scroll"
// A scroll container is only operable by keyboard once it is
// focusable; role+name keep it from being an unlabelled stop.
tabIndex={0}
role="region"
aria-label={t('section.title')}
>
<table className="table invitations__table">
<thead>
<tr>
<th>{t('columns.email')}</th>
<th>{t('columns.status')}</th>
<th>{t('columns.created')}</th>
<th>{t('columns.expires')}</th>
<th>{t('columns.actions')}</th>
</tr>
</thead>
<tbody>
{data.invitations.map((invitation) => (
<tr
key={invitation.id}
className="invitation-row"
data-email={invitation.email}
>
<td>{invitation.email}</td>
<td className="invitation-row__status">{t(`status.${invitation.status}`)}</td>
<td>{new Date(invitation.createdAt).toLocaleDateString()}</td>
<td>{new Date(invitation.expiresAt).toLocaleDateString()}</td>
<td>
{invitation.status === 'pending' && (
<button
type="button"
className="linklike invitation-row__revoke"
onClick={() => void revoke(invitation.id)}
>
{t('actions.revoke')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</section>
);
}