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
141 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|