diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts
index a7d966212..fdc54aac3 100644
--- a/e2e/account-sharing.spec.ts
+++ b/e2e/account-sharing.spec.ts
@@ -172,6 +172,13 @@ test.describe("account sharing", () => {
await ownerContext.close();
});
+ /** A `YYYY-MM-DD` day the date input accepts, N days from now. */
+ function isoDayFromNow(days: number): string {
+ const d = new Date();
+ d.setDate(d.getDate() + days);
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+ }
+
test("the owner invites, and the invitation confers nothing yet", async () => {
await ownerPage.goto("/settings/access");
@@ -187,7 +194,34 @@ test.describe("account sharing", () => {
await identifier.fill(E2E_USER.username);
await expect(submit).toBeEnabled({ timeout: 1000 });
}).toPass({ timeout: 15_000 });
+
+ // The lapse date, through the real form and onto the real wire.
+ //
+ // `expiresAt` was accepted, stored and annotated on by the route from the
+ // day the grant model shipped, while the card sent a hardcoded `null`. The
+ // component test cannot see that: it renders the control and checks the
+ // date mapping, and BOTH still pass with the card wired back to `null`.
+ // Only reading the posted body proves the value a person typed is the
+ // value the server receives, which is the half that was missing.
+ const lapse = ownerPage.locator('[data-slot="grant-invite-expires"]');
+ await expect(lapse).toBeVisible();
+ const chosenDay = isoDayFromNow(30);
+ await lapse.fill(chosenDay);
+
+ const invitePost = ownerPage.waitForRequest(
+ (req) =>
+ req.method() === "POST" && req.url().endsWith("/api/account/grants"),
+ );
await submit.click();
+ const posted = JSON.parse((await invitePost).postData() ?? "{}") as {
+ expiresAt?: string | null;
+ };
+ expect(
+ posted.expiresAt,
+ "the invitation must carry the day the owner chose",
+ ).toBeTruthy();
+ // End of the chosen day, not the midnight that starts it.
+ expect(posted.expiresAt).toContain(chosenDay.slice(0, 8));
const row = ownerPage
.locator('[data-slot="grants-given-list"] [data-slot="grant-row"]')
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/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
index 952a5b7b1..8d798ce6a 100644
--- a/src/__tests__/destructive-control-guard.test.ts
+++ b/src/__tests__/destructive-control-guard.test.ts
@@ -519,20 +519,6 @@ const REGISTRY: DestructiveEntry[] = [
undetected:
"Same shape as the card above, and one step further out of reach: renouncing is a POST, so not even a DELETE goes out. The path (`/api/account/grants/{id}/renounce`) names an act the D2 vocabulary does not carry, and it lives in the hook regardless. Registered by hand.",
},
- {
- file: "components/settings/access/grant-invite-card.tsx",
- destroys:
- "nothing — it offers access, and the offer confers nothing until the other person accepts it",
- recovery: "permanent",
- confirm: [],
- unconfirmed: [
- {
- control: "the invitation form",
- reason:
- "D3 flags this file for `expiresAt: null` in the invite payload, reading it as a field being blanked. It is not: null means the grant runs until somebody ends it, which is the common household case and the absence of an expiry rather than the removal of one. The form creates a pending row and destroys nothing, and gating a creation behind a confirmation dialog would teach people to click through the dialogs that do guard a loss.",
- },
- ],
- },
// ── Admin ───────────────────────────────────────────────────────────────
{
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
+ {t("recordSharing.invite.expiresHint")} +
+