diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index 618ff1ee5..2ed1ab6ce 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -191,6 +191,16 @@ test.describe("account sharing", () => { '[data-slot="grant-invite-identifier"]', ); const submit = ownerPage.locator('[data-slot="grant-invite-submit"]'); + + // Nothing under the scope question is preselected now, so the form blocks + // until the owner picks one. This invitation is unscoped, so choose the + // whole record before asserting the button can enable. + const wholeRecord = ownerPage.locator( + '[data-slot="grant-invite-scope-option"][data-scope="all"]', + ); + await wholeRecord.click(); + await expect(wholeRecord).toHaveAttribute("data-selected", "true"); + await expect(async () => { await identifier.fill(E2E_USER.username); await expect(submit).toBeEnabled({ timeout: 1000 }); diff --git a/e2e/delegated-writes.spec.ts b/e2e/delegated-writes.spec.ts index 2957dc2d7..d55d40d49 100644 --- a/e2e/delegated-writes.spec.ts +++ b/e2e/delegated-writes.spec.ts @@ -106,6 +106,15 @@ test.describe("delegated writes", () => { '[data-slot="grant-invite-identifier"]', ); const submit = ownerPage.locator('[data-slot="grant-invite-submit"]'); + + // The scope question preselects nothing, so the form blocks until the owner + // picks one. This invitation is unscoped — choose the whole record. + const wholeRecord = ownerPage.locator( + '[data-slot="grant-invite-scope-option"][data-scope="all"]', + ); + await wholeRecord.click(); + await expect(wholeRecord).toHaveAttribute("data-selected", "true"); + // The controlled input keeps the submit disabled until React has attached, // so retry the pair rather than waiting a fixed time and hoping. await expect(async () => { diff --git a/messages/de.json b/messages/de.json index 55b078172..3b18d9658 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9771,6 +9771,7 @@ "scopeAggregateNote": "Die Übersicht auf dem Dashboard, der Health Score und die Tageszusammenfassung erscheinen nur, wenn die gesamte Akte geteilt ist.", "scopeDocumentsNote": "Ein Dokument kann alles enthalten. Teile diesen Bereich nur, wenn die Person alles davon lesen darf.", "scopeEmptyHint": "Wähle mindestens einen Bereich aus oder teile wieder die gesamte Akte.", + "scopeChooseHint": "Wähle, ob die gesamte Akte oder nur einzelne Bereiche geteilt werden.", "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", diff --git a/messages/en.json b/messages/en.json index dd0ba4ac5..de7446b0a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9771,6 +9771,7 @@ "scopeAggregateNote": "The dashboard overview, health score and daily digest only appear when the entire record is shared.", "scopeDocumentsNote": "A document can contain anything. Share this only if they may read all of it.", "scopeEmptyHint": "Pick at least one section, or go back to sharing the entire record.", + "scopeChooseHint": "Choose whether to share the entire record or only selected sections.", "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", diff --git a/messages/es.json b/messages/es.json index a0e7aae78..a3e5fe307 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9771,6 +9771,7 @@ "scopeAggregateNote": "El resumen del panel, la puntuación de salud y el resumen diario solo aparecen cuando se comparte todo el historial.", "scopeDocumentsNote": "Un documento puede contener cualquier cosa. Comparte esta sección solo si esa persona puede leerlo todo.", "scopeEmptyHint": "Elige al menos una sección o vuelve a compartir todo el historial.", + "scopeChooseHint": "Elige si compartir todo el historial o solo algunas secciones.", "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", diff --git a/messages/fr.json b/messages/fr.json index 8ec4f749c..2d1cd9291 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9771,6 +9771,7 @@ "scopeAggregateNote": "La vue d'ensemble du tableau de bord, le score de santé et le résumé quotidien n'apparaissent que si le dossier entier est partagé.", "scopeDocumentsNote": "Un document peut contenir n'importe quoi. Ne partagez cette rubrique que si la personne peut tout lire.", "scopeEmptyHint": "Choisissez au moins une rubrique, ou revenez au partage du dossier complet.", + "scopeChooseHint": "Choisissez de partager le dossier complet ou seulement certaines rubriques.", "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", diff --git a/messages/it.json b/messages/it.json index 152f252b4..dcbfe6ead 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9771,6 +9771,7 @@ "scopeAggregateNote": "La panoramica della dashboard, il punteggio di salute e il riepilogo giornaliero compaiono solo se viene condivisa l'intera cartella.", "scopeDocumentsNote": "Un documento può contenere qualsiasi cosa. Condividi questa sezione solo se quella persona può leggere tutto.", "scopeEmptyHint": "Scegli almeno una sezione oppure torna a condividere l'intera cartella.", + "scopeChooseHint": "Scegli se condividere l'intera cartella o solo alcune sezioni.", "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", diff --git a/messages/pl.json b/messages/pl.json index 592b485bd..4250a0e17 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9771,6 +9771,7 @@ "scopeAggregateNote": "Przegląd na pulpicie, wynik zdrowia i dzienne podsumowanie pojawiają się tylko wtedy, gdy udostępniona jest cała kartoteka.", "scopeDocumentsNote": "Dokument może zawierać wszystko. Udostępnij tę sekcję tylko wtedy, gdy ta osoba może przeczytać całość.", "scopeEmptyHint": "Wybierz co najmniej jedną sekcję albo wróć do udostępniania całej kartoteki.", + "scopeChooseHint": "Wybierz, czy udostępnić całą kartotekę, czy tylko wybrane sekcje.", "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", diff --git a/src/components/settings/access/__tests__/grant-invite-scope.test.tsx b/src/components/settings/access/__tests__/grant-invite-scope.test.tsx index b66bde634..8ff438423 100644 --- a/src/components/settings/access/__tests__/grant-invite-scope.test.tsx +++ b/src/components/settings/access/__tests__/grant-invite-scope.test.tsx @@ -19,7 +19,9 @@ * * Mutation checks, run and recorded in the stream's report: * - render the eight checkboxes unconditionally (drop the `narrowed` gate) → - * "the entire record is the default and the sections stay closed" goes red; + * "preselects neither scope and leaves the sections closed" goes red; + * - preselect a scope (`narrowed` defaulting to a boolean) → the same test + * goes red on the `data-selected="false"` assertions; * - render the scope fieldset for MANAGE too → "manage collapses the whole * question" goes red. */ @@ -55,14 +57,15 @@ describe("choosing what a grant opens", () => { expect(html).toContain('data-scope="some"'); }); - it("makes the entire record the default and leaves the sections closed", () => { - // The shape of the whole design: narrowing is an act somebody chooses, so - // the default costs nothing and the eight checkboxes are not on screen - // until they are wanted. A form that showed them all at once would make - // the common case look like unfinished work. + it("preselects neither scope and leaves the sections closed", () => { + // Nothing is nudged: whole record and narrowing are both a choice the owner + // makes. An all-checked or whole-record default reads as the form answering + // the question for them. The eight checkboxes stay off screen until the + // narrow branch is chosen, and the form blocks until a scope is picked. const html = render(); - expect(html).toContain('data-scope="all" data-selected="true"'); + expect(html).toContain('data-scope="all" data-selected="false"'); expect(html).toContain('data-scope="some" data-selected="false"'); + expect(html).toContain('data-slot="grant-invite-scope-choose"'); expect(html).not.toContain('data-slot="grant-invite-sections"'); expect(html).not.toContain('data-slot="grant-invite-section"'); }); @@ -149,7 +152,16 @@ describe("what the form would send", () => { // the copy above says what a person is agreeing to, this says what leaves // the browser. - it("sends nothing scoped for the whole record", () => { + it("blocks until a scope is chosen, and does not fall through to whole record", () => { + // `null` is the unset state. It must NOT resolve to scope-absent: the wire + // reads an absent scope as the entire record, so an untouched fieldset that + // omitted scope would silently mint a whole-record grant. Unset blocks. + expect( + resolveInviteScope({ access: "READ", narrowed: null, sections: [] }), + ).toEqual({ mode: "unset", scope: null, blocked: true }); + }); + + it("sends nothing scoped for the whole record once it is explicitly chosen", () => { expect( resolveInviteScope({ access: "READ", narrowed: false, sections: [] }), ).toEqual({ mode: "all", scope: null, blocked: false }); diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx index 2909d0711..f655652a4 100644 --- a/src/components/settings/access/grant-invite-card.tsx +++ b/src/components/settings/access/grant-invite-card.tsx @@ -42,12 +42,12 @@ import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; * * **What they may do** stays what it was, one radio per level, narrowest * preselected, each with the sentence that says what it actually does * rather than the word for it. - * * **What they may open** is two radios, whole record preselected. Narrowing - * is the branch somebody chooses, and only then do the eight sections - * appear. A person who wants the default reads two lines and presses send; - * a person who wants to narrow sees at most eight named subjects with a - * sentence each, which is a thing a person can hold in their head. Nobody - * ever sees nineteen checkboxes. + * * **What they may open** is two radios with nothing preselected. Whole + * record and narrowing are both a choice the owner makes, so neither is + * nudged: an untouched fieldset blocks the form rather than defaulting to + * the whole record. Choosing to narrow reveals the eight sections; a person + * who wants everything picks whole record and presses send. Nobody ever + * sees nineteen checkboxes. * * The two honest limits are stated inside the expanded state, where the * decision is actually being made, rather than in help text somebody would @@ -84,9 +84,11 @@ export function GrantInviteCard() { // READ preselected. The narrower level is the one somebody should have to // choose their way out of, not into. const [access, setAccess] = useState("READ"); - // The entire record preselected, for the same reason and with the opposite - // sign: narrowing is the deliberate act, so it is the one that costs a click. - const [narrowed, setNarrowed] = useState(false); + // Nothing preselected: neither "the entire record" nor "only sections" is + // chosen until the owner picks one. `null` is that unset state, and it blocks + // the form — a whole-record grant is a real decision, not the thing that + // happens when somebody leaves the fieldset alone. + const [narrowed, setNarrowed] = useState(null); const [sections, setSections] = useState([]); const [error, setError] = useState(null); const [invited, setInvited] = useState(null); @@ -133,7 +135,7 @@ export function GrantInviteCard() { setIdentifier(""); setExpiresOn(""); setAccess("READ"); - setNarrowed(false); + setNarrowed(null); setSections([]); setInvited(grant.account.username); }, @@ -224,7 +226,7 @@ export function GrantInviteCard() {
{ setNarrowed(false); setError(null); @@ -234,7 +236,7 @@ export function GrantInviteCard() { /> { setNarrowed(true); setError(null); @@ -243,6 +245,16 @@ export function GrantInviteCard() { capability={t("recordSharing.invite.scopeSomeCapability")} />
+ {choice.mode === "unset" && ( + // Nothing chosen: say what the two options decide, rather than + // sending a request or silently defaulting to the whole record. +

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

+ )} {choice.mode === "pick" && (
sections.includes(domain)); // A narrowed invitation with nothing ticked is not a narrow grant, it is a