From 6780e54036be65f88a3c9599e85f4f206e17b2e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 20:56:31 +0200 Subject: [PATCH 1/2] Let somebody actually set the date a shared access lapses on The invite route has accepted expiresAt, stored it and annotated on it since the grant model shipped. The card sent a hardcoded null, so the column existed, the validator accepted it, the wide event reported on it, and no person could set one. The release note said an invitation could carry a lapse date. The form now offers the date, floored at tomorrow because a grant that lapses today confers nothing. A chosen day maps to the END of that day in the browser's own zone, so "until the 30th" means the 30th rather than the midnight that starts it, which is the reading anybody picking a date has in mind. Empty stays the default and still means no lapse date. Both ends are pinned rather than one: the control is asserted in the rendered form, and the mapping is exported so its contract holds without a click, which is this repository's convention for component tests. --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../__tests__/grant-invite-expiry.test.tsx | 65 +++++++++++++++++++ .../settings/access/grant-invite-card.tsx | 43 +++++++++++- 8 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/components/settings/access/__tests__/grant-invite-expiry.test.tsx diff --git a/messages/de.json b/messages/de.json index cf0d5543b..68ee1bc1b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9424,6 +9424,8 @@ "identifierLabel": "Benutzername oder E-Mail", "identifierPlaceholder": "Der Name, mit dem sie sich anmeldet", "identifierHint": "Die Person braucht ein Konto auf dieser Instanz. An eine E-Mail-Adresse ohne Registrierung lässt sich keine Einladung schicken.", + "expiresLabel": "Zugriff endet am (optional)", + "expiresHint": "Leer lassen für Zugriff, der gilt, bis ihn jemand beendet. Ein Datum wird bei jeder Anfrage geprüft, der Zugriff endet also am Ende dieses Tages von selbst.", "submit": "Einladung senden", "submitting": "Wird gesendet…", "sent": "Einladung an {name} gesendet.", diff --git a/messages/en.json b/messages/en.json index 277ac0349..eaa97402e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9424,6 +9424,8 @@ "identifierLabel": "Username or e-mail", "identifierPlaceholder": "The name they sign in with", "identifierHint": "They need an account on this instance. Invitations cannot be sent to an e-mail address that has not registered.", + "expiresLabel": "Access ends on (optional)", + "expiresHint": "Leave this empty for access that lasts until somebody ends it. A date here is checked on every request, so access stops on its own at the end of that day.", "submit": "Send invitation", "submitting": "Sending…", "sent": "Invitation sent to {name}.", diff --git a/messages/es.json b/messages/es.json index e8b81c7b3..6b6cc1188 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9424,6 +9424,8 @@ "identifierLabel": "Usuario o correo electrónico", "identifierPlaceholder": "El nombre con el que inicia sesión", "identifierHint": "Necesita una cuenta en esta instancia. No se pueden enviar invitaciones a una dirección que no se haya registrado.", + "expiresLabel": "El acceso termina el (opcional)", + "expiresHint": "Déjalo vacío para un acceso que dure hasta que alguien lo termine. Una fecha aquí se comprueba en cada petición, así que el acceso se detiene solo al final de ese día.", "submit": "Enviar invitación", "submitting": "Enviando…", "sent": "Invitación enviada a {name}.", diff --git a/messages/fr.json b/messages/fr.json index 3f24e137c..6c6f91a58 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9424,6 +9424,8 @@ "identifierLabel": "Nom d'utilisateur ou e-mail", "identifierPlaceholder": "Le nom avec lequel elle se connecte", "identifierHint": "Cette personne a besoin d'un compte sur cette instance. Une invitation ne peut pas être envoyée à une adresse non inscrite.", + "expiresLabel": "L'accès prend fin le (facultatif)", + "expiresHint": "Laissez ce champ vide pour un accès qui dure jusqu'à ce que quelqu'un y mette fin. Une date ici est vérifiée à chaque requête, l'accès s'arrête donc de lui-même à la fin de cette journée.", "submit": "Envoyer l'invitation", "submitting": "Envoi…", "sent": "Invitation envoyée à {name}.", diff --git a/messages/it.json b/messages/it.json index 029d65e4d..d6e1847c4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9424,6 +9424,8 @@ "identifierLabel": "Nome utente o e-mail", "identifierPlaceholder": "Il nome con cui accede", "identifierHint": "Serve un account su questa istanza. Non è possibile inviare inviti a un indirizzo non registrato.", + "expiresLabel": "L'accesso termina il (facoltativo)", + "expiresHint": "Lascia vuoto per un accesso che dura finché qualcuno non lo termina. Una data qui viene controllata a ogni richiesta, quindi l'accesso si interrompe da solo alla fine di quel giorno.", "submit": "Invia invito", "submitting": "Invio…", "sent": "Invito inviato a {name}.", diff --git a/messages/pl.json b/messages/pl.json index 3ef29e205..7ca42645b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9424,6 +9424,8 @@ "identifierLabel": "Nazwa użytkownika lub e-mail", "identifierPlaceholder": "Nazwa, którą się loguje", "identifierHint": "Ta osoba potrzebuje konta na tej instancji. Nie można wysłać zaproszenia na niezarejestrowany adres.", + "expiresLabel": "Dostęp kończy się (opcjonalnie)", + "expiresHint": "Zostaw puste, aby dostęp trwał, dopóki ktoś go nie zakończy. Data jest sprawdzana przy każdym żądaniu, więc dostęp kończy się sam z końcem tego dnia.", "submit": "Wyślij zaproszenie", "submitting": "Wysyłanie…", "sent": "Zaproszenie wysłane do {name}.", diff --git a/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx new file mode 100644 index 000000000..89b59ba23 --- /dev/null +++ b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx @@ -0,0 +1,65 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; + +vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useInviteGrant: () => ({ mutate: vi.fn(), isPending: false }), + }; +}); + +import { GrantInviteCard, endOfDayIso } from "../grant-invite-card"; + +/** + * The lapse date, at both ends. + * + * `expiresAt` was accepted by the invite route, stored, and annotated on from + * the day the grant model shipped, while the card sent a hardcoded `null`. A + * release note said an invitation could carry a lapse date and no one could + * set one. That is the two-ended failure this repository keeps rediscovering, + * so both ends are pinned here: the control exists in the rendered form, and + * the value it produces is the END of the chosen day rather than the midnight + * that starts it. + * + * Project convention is SSR-only, no `@testing-library/react`; the mapping is + * exported so its contract can be pinned without a click, and the submit path + * is covered by the sharing browser spec. + */ +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +describe("grant invitation lapse date", () => { + it("offers a date control on the invitation form", () => { + const html = render(); + expect(html).toContain('data-slot="grant-invite-expires"'); + expect(html).toContain('type="date"'); + }); + + it("maps a chosen day to the end of that day, not its midnight", () => { + const iso = endOfDayIso("2026-09-30"); + expect(iso).not.toBeNull(); + const end = new Date(iso as string); + // Read back in the same zone the mapping used, so the assertion holds + // wherever the suite runs rather than only under the pinned UTC clock. + expect(end.getFullYear()).toBe(2026); + expect(end.getMonth()).toBe(8); + expect(end.getDate()).toBe(30); + expect(end.getHours()).toBe(23); + expect(end.getMinutes()).toBe(59); + }); + + it("treats an empty field as no lapse date", () => { + expect(endOfDayIso("")).toBeNull(); + }); + + it("refuses a value that is not a date rather than sending an invalid instant", () => { + expect(endOfDayIso("not-a-date")).toBeNull(); + }); +}); diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx index 1affa9a96..e54573446 100644 --- a/src/components/settings/access/grant-invite-card.tsx +++ b/src/components/settings/access/grant-invite-card.tsx @@ -34,6 +34,7 @@ export function GrantInviteCard() { const { t } = useTranslations(); const invite = useInviteGrant(); const [identifier, setIdentifier] = useState(""); + const [expiresOn, setExpiresOn] = useState(""); const [error, setError] = useState(null); const [invited, setInvited] = useState(null); @@ -44,10 +45,11 @@ export function GrantInviteCard() { setError(null); setInvited(null); invite.mutate( - { identifier: value, expiresAt: null }, + { identifier: value, expiresAt: endOfDayIso(expiresOn) }, { onSuccess: (grant) => { setIdentifier(""); + setExpiresOn(""); setInvited(grant.account.username); }, onError: (err) => setError(t(inviteErrorKey(err))), @@ -80,6 +82,22 @@ export function GrantInviteCard() { {t("recordSharing.invite.identifierHint")}

+
+ + setExpiresOn(e.target.value)} + /> +

+ {t("recordSharing.invite.expiresHint")} +

+