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
63 lines
2.4 KiB
TypeScript
63 lines
2.4 KiB
TypeScript
/**
|
|
* Form model of the general + quota cards on the admin settings page.
|
|
*
|
|
* Field names MUST NOT contain dots: react-hook-form treats a dot in a
|
|
* field name as a nested-path separator. A field registered under its
|
|
* settings key ('instance.name') DISPLAYS fine — RHF's getter falls back
|
|
* to the literal flat key — but typing writes the value into a nested
|
|
* object ({ instance: { name } }), which the api's strict PATCH schema
|
|
* rejects, so nothing ever saved (issue #322). This mapping is the single
|
|
* place tying a dot-free field name to its dotted settings key; the
|
|
* converters below translate in both directions.
|
|
*/
|
|
|
|
export const GENERAL_FORM_FIELDS = {
|
|
instanceName: 'instance.name',
|
|
defaultLocale: 'instance.defaultLocale',
|
|
registrationMode: 'auth.registrationMode',
|
|
invitationsMaxOpenPerUser: 'invitations.maxOpenPerUser',
|
|
newPageClassification: 'classification.newPageDefault',
|
|
uploadPolicy: 'classification.uploadPolicy',
|
|
quotaEditorsPerPond: 'quota.editorsPerPond',
|
|
quotaReadersPerPond: 'quota.readersPerPond',
|
|
quotaAdditionalPonds: 'quota.additionalPonds',
|
|
quotaStorageBytes: 'quota.storageBytes',
|
|
quotaMaxFileBytes: 'quota.maxFileBytes',
|
|
} as const;
|
|
|
|
export type GeneralFormField = keyof typeof GENERAL_FORM_FIELDS;
|
|
export type GeneralFormSettingKey = (typeof GENERAL_FORM_FIELDS)[GeneralFormField];
|
|
|
|
export interface GeneralSettingsForm {
|
|
instanceName: string;
|
|
defaultLocale: 'de' | 'en';
|
|
registrationMode: 'open' | 'closed';
|
|
invitationsMaxOpenPerUser: number;
|
|
newPageClassification: 'unclassified' | 'vs_nfd';
|
|
uploadPolicy: 'warn' | 'block';
|
|
quotaEditorsPerPond: number;
|
|
quotaReadersPerPond: number;
|
|
quotaAdditionalPonds: number;
|
|
quotaStorageBytes: number;
|
|
quotaMaxFileBytes: number;
|
|
}
|
|
|
|
/** The settings this form reads and writes, keyed by their dotted names. */
|
|
export type GeneralFormSettings = Record<GeneralFormSettingKey, unknown>;
|
|
|
|
export function toFormValues(settings: GeneralFormSettings): GeneralSettingsForm {
|
|
return Object.fromEntries(
|
|
Object.entries(GENERAL_FORM_FIELDS).map(([field, key]) => [field, settings[key]]),
|
|
) as unknown as GeneralSettingsForm;
|
|
}
|
|
|
|
/** Flat dotted keys, exactly what PATCH /admin/settings expects. */
|
|
export function toSettingsPatch(input: GeneralSettingsForm): GeneralFormSettings {
|
|
return Object.fromEntries(
|
|
Object.entries(GENERAL_FORM_FIELDS).map(([field, key]) => [
|
|
key,
|
|
input[field as GeneralFormField],
|
|
]),
|
|
) as GeneralFormSettings;
|
|
}
|