diff --git a/messages/de.json b/messages/de.json index 50224cb88..028a6958a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9585,7 +9585,8 @@ "errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen.", "errorScope": "So lassen sich diese Bereiche nicht teilen. Wähle mindestens einen Bereich aus oder teile die gesamte Akte.", "errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben, weil dabei dein zweiter Faktor abgefragt wird.", - "errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut." + "errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.", + "errorOffline": "Das hat den Server nicht erreicht. Deine Einladung steht noch hier — prüfe deine Verbindung und versuche es erneut." }, "given": { "title": "Wer meine Akte öffnen kann", @@ -9742,6 +9743,21 @@ "label": "Dokumente", "detail": "Alles, was im Dokumententresor liegt." } + }, + "actionError": { + "retry": "Erneut versuchen", + "offline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert — prüfe deine Verbindung und versuche es erneut.", + "rateLimited": "Zu viele Versuche hintereinander. Warte eine Minute und versuche es erneut.", + "acceptExpired": "Diese Einladung ist abgelaufen. Bitte um eine neue.", + "acceptWithdrawn": "Diese Einladung wurde zurückgezogen.", + "acceptNotPending": "Diese Einladung wurde bereits angenommen.", + "acceptNotFound": "Diese Einladung gibt es nicht mehr.", + "acceptFailed": "Die Einladung konnte nicht angenommen werden.", + "lastGuardian": "Diese Person kümmert sich als Einzige um dieses Profil, deshalb kann der Zugriff noch nicht enden. Füge zuerst eine weitere betreuende Person hinzu oder lösche das Profil.", + "alreadyEnded": "Dieser Zugriff ist bereits beendet.", + "notFound": "Diesen Zugriff gibt es nicht mehr.", + "revokeFailed": "Der Zugriff konnte nicht beendet werden.", + "renounceFailed": "Der Zugriff konnte nicht zurückgegeben werden." } } } diff --git a/messages/en.json b/messages/en.json index 255435074..ce085b6b8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9585,7 +9585,8 @@ "errorFailed": "The invitation could not be sent. Please try again.", "errorScope": "Those sections cannot be shared like that. Pick at least one section, or share the entire record.", "errorManageBrowserOnly": "Manage access can only be offered in a browser, because it asks for your second factor.", - "errorStepUp": "Confirm your second factor, then try again." + "errorStepUp": "Confirm your second factor, then try again.", + "errorOffline": "That did not reach the server. Your invitation is still here — check your connection and try again." }, "given": { "title": "Who can open my record", @@ -9742,6 +9743,21 @@ "label": "Documents", "detail": "Everything stored in the document vault." } + }, + "actionError": { + "retry": "Try again", + "offline": "That did not reach the server. Nothing has changed — check your connection and try again.", + "rateLimited": "Too many attempts in a row. Wait a minute and try again.", + "acceptExpired": "That invitation has lapsed. Ask for a new one.", + "acceptWithdrawn": "That invitation was withdrawn.", + "acceptNotPending": "That invitation has already been accepted.", + "acceptNotFound": "That invitation is no longer there.", + "acceptFailed": "The invitation could not be accepted.", + "lastGuardian": "This is the only person looking after that profile, so this access cannot end yet. Add another guardian first, or delete the profile.", + "alreadyEnded": "That access has already ended.", + "notFound": "That access is no longer there.", + "revokeFailed": "The access could not be ended.", + "renounceFailed": "The access could not be handed back." } } } diff --git a/messages/es.json b/messages/es.json index a1403afd0..f48a513ca 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9585,7 +9585,8 @@ "errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo.", "errorScope": "Esas secciones no se pueden compartir así. Elige al menos una sección o comparte todo el historial.", "errorManageBrowserOnly": "El acceso de gestión solo se puede ofrecer desde un navegador, porque pide tu segundo factor.", - "errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo." + "errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.", + "errorOffline": "Esto no llegó al servidor. Tu invitación sigue aquí: comprueba tu conexión e inténtalo de nuevo." }, "given": { "title": "Quién puede abrir mi historial", @@ -9742,6 +9743,21 @@ "label": "Documentos", "detail": "Todo lo que está guardado en el archivo de documentos." } + }, + "actionError": { + "retry": "Inténtalo de nuevo", + "offline": "Esto no llegó al servidor. No ha cambiado nada: comprueba tu conexión e inténtalo de nuevo.", + "rateLimited": "Demasiados intentos seguidos. Espera un minuto e inténtalo de nuevo.", + "acceptExpired": "Esa invitación ha caducado. Pide una nueva.", + "acceptWithdrawn": "Esa invitación fue retirada.", + "acceptNotPending": "Esa invitación ya se aceptó.", + "acceptNotFound": "Esa invitación ya no existe.", + "acceptFailed": "No se pudo aceptar la invitación.", + "lastGuardian": "Esta es la única persona que cuida de ese perfil, así que este acceso todavía no puede terminar. Añade otra persona responsable primero, o elimina el perfil.", + "alreadyEnded": "Ese acceso ya ha terminado.", + "notFound": "Ese acceso ya no existe.", + "revokeFailed": "No se pudo terminar el acceso.", + "renounceFailed": "No se pudo devolver el acceso." } } } diff --git a/messages/fr.json b/messages/fr.json index 3df592639..4c8d71687 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9585,7 +9585,8 @@ "errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer.", "errorScope": "Ces rubriques ne peuvent pas être partagées ainsi. Choisissez au moins une rubrique, ou partagez le dossier complet.", "errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur, car il demande votre deuxième facteur.", - "errorStepUp": "Confirmez votre deuxième facteur, puis réessayez." + "errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.", + "errorOffline": "Cela n'a pas atteint le serveur. Votre invitation est toujours là — vérifiez votre connexion et réessayez." }, "given": { "title": "Qui peut ouvrir mon dossier", @@ -9742,6 +9743,21 @@ "label": "Documents", "detail": "Tout ce qui est conservé dans le coffre à documents." } + }, + "actionError": { + "retry": "Réessayer", + "offline": "Cela n'a pas atteint le serveur. Rien n'a changé — vérifiez votre connexion et réessayez.", + "rateLimited": "Trop de tentatives d'affilée. Attendez une minute et réessayez.", + "acceptExpired": "Cette invitation a expiré. Demandez-en une nouvelle.", + "acceptWithdrawn": "Cette invitation a été retirée.", + "acceptNotPending": "Cette invitation a déjà été acceptée.", + "acceptNotFound": "Cette invitation n'existe plus.", + "acceptFailed": "L'invitation n'a pas pu être acceptée.", + "lastGuardian": "Cette personne est la seule à s'occuper de ce profil, donc cet accès ne peut pas encore prendre fin. Ajoutez d'abord une autre personne responsable, ou supprimez le profil.", + "alreadyEnded": "Cet accès a déjà pris fin.", + "notFound": "Cet accès n'existe plus.", + "revokeFailed": "L'accès n'a pas pu être terminé.", + "renounceFailed": "L'accès n'a pas pu être rendu." } } } diff --git a/messages/it.json b/messages/it.json index 9a5f4506e..28297a6e6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9585,7 +9585,8 @@ "errorFailed": "Non è stato possibile inviare l'invito. Riprova.", "errorScope": "Queste sezioni non si possono condividere così. Scegli almeno una sezione oppure condividi l'intera cartella.", "errorManageBrowserOnly": "L'accesso di gestione si può offrire solo da un browser, perché richiede il tuo secondo fattore.", - "errorStepUp": "Conferma il tuo secondo fattore e riprova." + "errorStepUp": "Conferma il tuo secondo fattore e riprova.", + "errorOffline": "La richiesta non ha raggiunto il server. Il tuo invito è ancora qui: controlla la connessione e riprova." }, "given": { "title": "Chi può aprire la mia cartella", @@ -9742,6 +9743,21 @@ "label": "Documenti", "detail": "Tutto ciò che è conservato nell'archivio documenti." } + }, + "actionError": { + "retry": "Riprova", + "offline": "La richiesta non ha raggiunto il server. Non è cambiato nulla: controlla la connessione e riprova.", + "rateLimited": "Troppi tentativi di fila. Aspetta un minuto e riprova.", + "acceptExpired": "Questo invito è scaduto. Chiedine uno nuovo.", + "acceptWithdrawn": "Questo invito è stato ritirato.", + "acceptNotPending": "Questo invito è già stato accettato.", + "acceptNotFound": "Questo invito non esiste più.", + "acceptFailed": "Non è stato possibile accettare l'invito.", + "lastGuardian": "Questa è l'unica persona che si occupa di quel profilo, quindi questo accesso non può ancora finire. Aggiungi prima un'altra persona responsabile, oppure elimina il profilo.", + "alreadyEnded": "Questo accesso è già terminato.", + "notFound": "Questo accesso non esiste più.", + "revokeFailed": "Non è stato possibile terminare l'accesso.", + "renounceFailed": "Non è stato possibile restituire l'accesso." } } } diff --git a/messages/pl.json b/messages/pl.json index aa0cf4af3..0577eed2d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9585,7 +9585,8 @@ "errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie.", "errorScope": "Tych sekcji nie da się udostępnić w ten sposób. Wybierz co najmniej jedną sekcję albo udostępnij całą kartotekę.", "errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce, ponieważ wymaga drugiego składnika logowania.", - "errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie." + "errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.", + "errorOffline": "To nie dotarło do serwera. Twoje zaproszenie nadal tu jest — sprawdź połączenie i spróbuj ponownie." }, "given": { "title": "Kto może otworzyć moją kartę", @@ -9742,6 +9743,21 @@ "label": "Dokumenty", "detail": "Wszystko, co jest przechowywane w sejfie dokumentów." } + }, + "actionError": { + "retry": "Spróbuj ponownie", + "offline": "To nie dotarło do serwera. Nic się nie zmieniło — sprawdź połączenie i spróbuj ponownie.", + "rateLimited": "Zbyt wiele prób pod rząd. Odczekaj minutę i spróbuj ponownie.", + "acceptExpired": "To zaproszenie wygasło. Poproś o nowe.", + "acceptWithdrawn": "To zaproszenie zostało wycofane.", + "acceptNotPending": "To zaproszenie zostało już przyjęte.", + "acceptNotFound": "Tego zaproszenia już nie ma.", + "acceptFailed": "Nie udało się przyjąć zaproszenia.", + "lastGuardian": "Ta osoba jako jedyna opiekuje się tym profilem, więc ten dostęp nie może się jeszcze zakończyć. Dodaj najpierw kolejną osobę opiekującą się profilem albo usuń profil.", + "alreadyEnded": "Ten dostęp już się zakończył.", + "notFound": "Tego dostępu już nie ma.", + "revokeFailed": "Nie udało się zakończyć dostępu.", + "renounceFailed": "Nie udało się zwrócić dostępu." } } } diff --git a/src/__tests__/sharing-recoverable-errors.test.tsx b/src/__tests__/sharing-recoverable-errors.test.tsx new file mode 100644 index 000000000..881a13555 --- /dev/null +++ b/src/__tests__/sharing-recoverable-errors.test.tsx @@ -0,0 +1,416 @@ +/** + * A refused sharing action says what happened, stays on screen, and can be + * tried again. + * + * ## The state this file exists to prevent + * + * Three of the four mutations on the shared-access panel had no failure + * rendering at all. `accept`, `revoke` and `renounce` fired, the request came + * back 409 or 410 or never left the machine, and the panel did nothing: the + * dialog had already closed, the list did not change, and the only signal was + * the absence of one. The two refusals that matter most were exactly the two + * that arrived silently — an invitation that had lapsed, and the last Guardian + * being told they may not walk away from a profile that would then have nobody + * looking after it. Both are recoverable, and neither was recoverable if + * nobody knew it had happened. + * + * ## Why the failure lives on the mutation and not in a `useState` + * + * A local `useState` set from `onError` is a second copy of a fact TanStack + * already holds, and the copy is the thing that goes stale: a re-render from + * an unrelated invalidation clears it, or a second failure overwrites the + * first row's message onto another row. `isError` / `error` / `variables` + * survive on the mutation until something explicitly resets it, and + * `variables` is what says WHICH row failed. That is also what makes this file + * possible — the panel's failure states are reachable from a static render, + * with no click, because they are props rather than the result of an + * interaction. + * + * ## What a static render can and cannot prove + * + * It proves the alert exists, is announced, is addressed to the right row, + * carries content contrast rather than muted meta, sits beside a retry control + * and leaves the row's own affordances in place. It does NOT prove the retry + * button re-fires the mutation with the same argument — there is no click + * here. That claim is the browser journey's, and the pure resolver below is + * where the mapping from a refusal to a sentence is actually pinned. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { ApiError } from "@/lib/api/api-fetch"; +import type { GrantList, GrantRow } from "@/lib/queries/use-account-grants"; + +const grantsRef: { value: GrantList } = { value: grantList() }; + +function grantList(partial: Partial = {}): GrantList { + return { given: [], received: [], retentionDays: 365, ...partial }; +} + +/** A mutation as the panel sees one: idle unless a case says otherwise. */ +interface FakeMutation { + mutate: ReturnType; + isPending: boolean; + isError: boolean; + error: unknown; + variables: string | undefined; +} + +function idle(): FakeMutation { + return { + mutate: vi.fn(), + isPending: false, + isError: false, + error: null, + variables: undefined, + }; +} + +function failed(error: unknown, grantId: string): FakeMutation { + return { ...idle(), isError: true, error, variables: grantId }; +} + +const acceptRef = { value: idle() }; +const revokeRef = { value: idle() }; +const renounceRef = { value: idle() }; + +vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useAccountGrants: () => ({ + data: grantsRef.value, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useAcceptGrant: () => acceptRef.value, + useRevokeGrant: () => revokeRef.value, + useRenounceGrant: () => renounceRef.value, + }; +}); + +vi.mock("@/hooks/use-account-switch", () => ({ + useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }), +})); + +import { + grantActionErrorKey, + type GrantAction, +} from "@/components/settings/access/grant-action-error"; +import { inviteErrorKey } from "@/components/settings/access/grant-invite-card"; +import { GrantsGivenCard } from "@/components/settings/access/grants-given-card"; +import { GrantsReceivedCard } from "@/components/settings/access/grants-received-card"; + +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +const BASE: GrantRow = { + id: "g1", + account: { id: "acct-1", username: "housemate", displayName: "Jo" }, + access: "MANAGE", + scope: null, + state: "ACTIVE", + invitedAt: "2026-01-02T10:00:00.000Z", + acceptedAt: "2026-01-02T11:00:00.000Z", + expiresAt: null, + lastUsedAt: null, + revokedAt: null, + revokedBy: null, +}; + +const PENDING: GrantRow = { ...BASE, state: "PENDING", acceptedAt: null }; + +function apiError(status: number, errorCode?: string): ApiError { + return new ApiError("refused", status, errorCode ? { errorCode } : undefined); +} + +function reset() { + acceptRef.value = idle(); + revokeRef.value = idle(); + renounceRef.value = idle(); + grantsRef.value = grantList(); +} + +describe("what a refused sharing action says", () => { + const cases: Array<{ + action: GrantAction; + err: unknown; + key: string; + why: string; + }> = [ + { + action: "accept", + err: apiError(410, "sharing.accept.expired"), + key: "recordSharing.actionError.acceptExpired", + why: "the invitation lapsed — the next step is to ask for a new one", + }, + { + action: "accept", + err: apiError(410, "sharing.accept.revoked"), + key: "recordSharing.actionError.acceptWithdrawn", + why: "withdrawn is a different fact from lapsed, and has no next step", + }, + { + action: "accept", + err: apiError(409, "sharing.accept.not_pending"), + key: "recordSharing.actionError.acceptNotPending", + why: "already accepted, most likely in another tab", + }, + { + action: "accept", + err: apiError(404, "sharing.accept.not_found"), + key: "recordSharing.actionError.acceptNotFound", + why: "the row is gone", + }, + { + action: "accept", + err: apiError(429), + key: "recordSharing.actionError.rateLimited", + why: "waiting is the recovery, so it must not read as a failure", + }, + { + action: "revoke", + err: apiError(409, "managed_profile.guardian.required"), + key: "recordSharing.actionError.lastGuardian", + why: "the refusal a managed profile depends on, and it names a way out", + }, + { + action: "renounce", + err: apiError(409, "managed_profile.guardian.required"), + key: "recordSharing.actionError.lastGuardian", + why: "the same refusal reached from the delegate's side", + }, + { + action: "revoke", + err: apiError(409, "sharing.revoke.already_ended"), + key: "recordSharing.actionError.alreadyEnded", + why: "nothing to recover — the access is already closed", + }, + { + action: "renounce", + err: apiError(409, "sharing.renounce.already_ended"), + key: "recordSharing.actionError.alreadyEnded", + why: "same, from the other side", + }, + { + action: "revoke", + err: apiError(404, "sharing.revoke.not_found"), + key: "recordSharing.actionError.notFound", + why: "the row is gone", + }, + { + action: "accept", + err: new TypeError("Failed to fetch"), + key: "recordSharing.actionError.offline", + why: "never reached the server — retrying is the whole recovery", + }, + { + action: "revoke", + err: new TypeError("Failed to fetch"), + key: "recordSharing.actionError.offline", + why: "same, and the row is unchanged because nothing was sent", + }, + { + action: "renounce", + err: new TypeError("Failed to fetch"), + key: "recordSharing.actionError.offline", + why: "same", + }, + { + action: "accept", + err: apiError(500), + key: "recordSharing.actionError.acceptFailed", + why: "an unclassified failure still says which act failed", + }, + { + action: "revoke", + err: apiError(500), + key: "recordSharing.actionError.revokeFailed", + why: "same", + }, + { + action: "renounce", + err: apiError(500), + key: "recordSharing.actionError.renounceFailed", + why: "same", + }, + ]; + + for (const { action, err, key, why } of cases) { + it(`${action}: ${why}`, () => { + expect(grantActionErrorKey(err, action)).toBe(key); + }); + } + + it("tells the refusals apart rather than collapsing them", () => { + // The failure this table is written against is one message for + // everything. Distinct codes must produce distinct sentences, and the + // count is asserted so a resolver that returned one key for all of them + // fails here rather than passing every row above by accident. + const distinct = new Set( + cases.map(({ err, action }) => grantActionErrorKey(err, action)), + ); + expect(distinct.size).toBeGreaterThan(8); + }); +}); + +describe("a refused invitation keeps what was typed", () => { + const INVITE_CARD = readFileSync( + join(process.cwd(), "src/components/settings/access/grant-invite-card.tsx"), + "utf8", + ); + + it("separates a request that never arrived from one the server refused", () => { + // "The invitation could not be sent" invites somebody to change the form, + // and after a dropped connection the form is fine. + expect(inviteErrorKey(new TypeError("Failed to fetch"))).toBe( + "recordSharing.invite.errorOffline", + ); + expect(inviteErrorKey(apiError(404))).toBe( + "recordSharing.invite.errorNoAccount", + ); + expect(inviteErrorKey(apiError(409, "sharing.invite.duplicate"))).toBe( + "recordSharing.invite.errorDuplicate", + ); + }); + + it("clears the form only where the invitation actually went", () => { + // A static render cannot press the button, so the claim is made about the + // code path instead: every field reset lives inside `onSuccess`. A reset + // that migrated into `onError` — or out of the callbacks entirely — would + // wipe a typed identifier and eight ticked sections on a failure the + // person is being asked to retry. + const start = INVITE_CARD.indexOf("onSuccess:"); + const split = INVITE_CARD.indexOf("onError:", start); + // The options object ends at the first ` },` after the error arm, so + // the slice stops at the mutation rather than running into the JSX below, + // where a `setNarrowed(false)` on a radio would satisfy the matcher for + // the wrong reason. + const end = INVITE_CARD.indexOf("\n },", split); + const onSuccess = INVITE_CARD.slice(start, split); + const onError = INVITE_CARD.slice(split, end); + + // Positive controls: both slices really contain the callback they name. + expect(onSuccess).toContain("setInvited"); + expect(onError).toContain("inviteErrorKey"); + + for (const reset of [ + 'setIdentifier("")', + 'setExpiresOn("")', + 'setAccess("READ")', + "setNarrowed(false)", + "setSections([])", + ]) { + expect(onSuccess, reset).toContain(reset); + expect(onError, reset).not.toContain(reset); + } + }); +}); + +describe("the refusal stays on screen, addressed to its own row", () => { + it("announces a refused acceptance and leaves the invitation acceptable", () => { + reset(); + grantsRef.value = grantList({ received: [PENDING] }); + acceptRef.value = failed(apiError(410, "sharing.accept.expired"), "g1"); + + const html = render(); + + // Announced, not merely printed. + expect(html).toContain('data-slot="grant-action-error"'); + expect(html).toContain('role="alert"'); + expect(html).toContain("That invitation has lapsed"); + // Addressed to the row it belongs to, so a second invitation below it + // does not wear somebody else's failure. + expect(html).toContain('data-grant-error-for="g1"'); + // The state survives: the row is still there and still offers the act. + expect(html).toContain('data-grant-id="g1"'); + expect(html).toContain('data-slot="grant-accept"'); + // And there is a way to try again. + expect(html).toContain('data-slot="grant-action-retry"'); + }); + + it("does not paint the failure onto a row that did not fail", () => { + reset(); + grantsRef.value = grantList({ + received: [PENDING, { ...PENDING, id: "g2" }], + }); + acceptRef.value = failed(apiError(410, "sharing.accept.expired"), "g1"); + + const html = render(); + + expect(html.match(/data-slot="grant-action-error"/g)).toHaveLength(1); + expect(html).toContain('data-grant-error-for="g1"'); + expect(html).not.toContain('data-grant-error-for="g2"'); + }); + + it("names the last-Guardian refusal and the way out of it", () => { + reset(); + grantsRef.value = grantList({ given: [BASE] }); + revokeRef.value = failed( + apiError(409, "managed_profile.guardian.required"), + "g1", + ); + + const html = render(); + + expect(html).toContain('data-slot="grant-action-error"'); + // The sentence has to carry the recovery. "Could not end access" is true + // and useless; the owner needs to know a second guardian is the fix. + expect(html).toContain("Add another guardian first"); + // The row keeps its control, so the owner can act on the advice and + // return to the same button. + expect(html).toContain('data-slot="grant-revoke"'); + }); + + it("reaches the same refusal from the delegate's side", () => { + reset(); + grantsRef.value = grantList({ received: [BASE] }); + renounceRef.value = failed( + apiError(409, "managed_profile.guardian.required"), + "g1", + ); + + const html = render(); + + expect(html).toContain('data-slot="grant-action-error"'); + expect(html).toContain("Add another guardian first"); + expect(html).toContain('data-slot="grant-renounce"'); + }); + + it("keeps material copy in content contrast, not muted meta", () => { + reset(); + grantsRef.value = grantList({ received: [PENDING] }); + acceptRef.value = failed(new TypeError("Failed to fetch"), "g1"); + + const html = render(); + const alert = /<[^>]*data-slot="grant-action-error"[^>]*>/.exec(html)?.[0]; + + expect(alert).toBeDefined(); + // A refusal somebody has to act on is content. UI-STANDARDS §3 reserves + // muted for meta, and a sentence nobody reads is a sentence nobody acts + // on. + expect(alert).toContain("text-destructive"); + expect(alert).not.toContain("text-muted-foreground"); + }); + + it("shows nothing at all while every mutation is idle", () => { + reset(); + grantsRef.value = grantList({ given: [BASE], received: [PENDING] }); + + expect(render()).not.toContain( + 'data-slot="grant-action-error"', + ); + expect(render()).not.toContain( + 'data-slot="grant-action-error"', + ); + }); +}); diff --git a/src/components/settings/access/grant-action-error.tsx b/src/components/settings/access/grant-action-error.tsx new file mode 100644 index 000000000..62e95682f --- /dev/null +++ b/src/components/settings/access/grant-action-error.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ApiError } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.37.0 — what a refused accept, revoke or renounce says, and where it says + * it. + * + * ## The state this replaces + * + * These three mutations had no failure rendering at all. They fired, the + * server answered 409 or 410, or the request never left the machine, and the + * panel did nothing — the confirm dialog had already closed, the list did not + * change, and the only signal was the absence of one. The two refusals that + * matter most were the two that arrived most quietly: an invitation that had + * lapsed, and the last Guardian being refused a renunciation that would leave + * a managed profile with nobody looking after it. Both are recoverable, and + * neither is recoverable by somebody who was not told. + * + * ## Every arm names its own recovery + * + * A refusal is only worth rendering if it changes what the person does next, + * so the sentences are split by what the next step is rather than by status + * code. "Lapsed" leads to asking for a new invitation; "withdrawn" leads + * nowhere and says so; the last-Guardian refusal names the two ways out the + * lifecycle actually offers. The offline arm is the one where retrying IS the + * recovery, so it is separated from a server refusal even though both surface + * as a failed mutation: a request that never left the machine changed nothing, + * and telling somebody their access "could not be ended" when it was never + * asked is a different and worse sentence. + * + * `ApiError` carries the server's stable `meta.errorCode`; anything that is + * not an `ApiError` never reached a response at all, which is what makes the + * offline test a type check rather than a status check. + */ +export type GrantAction = "accept" | "revoke" | "renounce"; + +/** The generic sentence for each act, when nothing more specific applies. */ +const FAILED_KEY: Record = { + accept: "recordSharing.actionError.acceptFailed", + revoke: "recordSharing.actionError.revokeFailed", + renounce: "recordSharing.actionError.renounceFailed", +}; + +/** + * The message key for a refused grant action. + * + * Pure and exported so the mapping can be pinned without a click: the panel's + * error states are otherwise only reachable by driving a browser, and a + * mapping nobody can test is a mapping that drifts. + */ +export function grantActionErrorKey(err: unknown, action: GrantAction): string { + // Not an `ApiError` means no response came back — a dropped connection, a + // sleeping laptop, a server that is not there. Nothing was decided, so the + // sentence says so and points at the retry rather than reporting a refusal + // the server never issued. + if (!(err instanceof ApiError)) return "recordSharing.actionError.offline"; + + const code = + typeof err.meta?.errorCode === "string" ? err.meta.errorCode : null; + + switch (code) { + // The one refusal that protects a person rather than a row. It reaches + // both sides — the owner withdrawing the last Guardian's access and the + // last Guardian handing it back — and it is the same fact either way, so + // it is the same sentence. + case "managed_profile.guardian.required": + return "recordSharing.actionError.lastGuardian"; + case "sharing.accept.expired": + return "recordSharing.actionError.acceptExpired"; + case "sharing.accept.revoked": + return "recordSharing.actionError.acceptWithdrawn"; + case "sharing.accept.not_pending": + return "recordSharing.actionError.acceptNotPending"; + case "sharing.accept.not_found": + return "recordSharing.actionError.acceptNotFound"; + case "sharing.revoke.already_ended": + case "sharing.renounce.already_ended": + return "recordSharing.actionError.alreadyEnded"; + case "sharing.revoke.not_found": + case "sharing.renounce.not_found": + return "recordSharing.actionError.notFound"; + default: + break; + } + + // Waiting is the recovery, so it must not read as a failure. The accept + // route rate-limits without an error code, hence the status. + if (err.status === 429) return "recordSharing.actionError.rateLimited"; + + return FAILED_KEY[action]; +} + +/** + * The refusal, on the row it belongs to. + * + * `role="alert"` because it appears after the act rather than with the page: + * a person who pressed a button and got nothing has already looked away, and + * a screen reader that does not announce it is the same silence the panel had + * before. `text-destructive` because this is content somebody has to act on — + * UI-STANDARDS §3 reserves muted for meta, and a refusal rendered as meta is a + * refusal nobody reads. + * + * `data-grant-error-for` carries the row id so a panel holding two failed rows + * cannot show one row's message against another's, and so a browser journey + * can assert which row was refused rather than that something somewhere went + * red. + */ +export function GrantActionAlert({ + grantId, + message, + onRetry, + retrying = false, +}: { + grantId: string; + message: string; + onRetry: () => void; + retrying?: boolean; +}) { + const { t } = useTranslations(); + return ( +
+ {message} + +
+ ); +} diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx index be3a92285..9c34a61b3 100644 --- a/src/components/settings/access/grant-invite-card.tsx +++ b/src/components/settings/access/grant-invite-card.tsx @@ -654,10 +654,15 @@ function useInvitationOwnerTimezone(): string { * field" and "you already have your own record". */ export function inviteErrorKey(err: unknown): string { + // Nothing that is not an `ApiError` ever reached a response, so no + // invitation was refused — the request did not arrive. Said as its own case + // because "the invitation could not be sent" invites somebody to change the + // form, and the form is fine. The typed identifier, the level and the ticked + // sections all survive a failed mutation, so the retry is the same button. + if (!(err instanceof ApiError)) return "recordSharing.invite.errorOffline"; + const code = - err instanceof ApiError && typeof err.meta?.errorCode === "string" - ? err.meta.errorCode - : null; + typeof err.meta?.errorCode === "string" ? err.meta.errorCode : null; switch (code) { case "sharing.invite.self": return "recordSharing.invite.errorSelf"; diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx index f32e0c5d1..f90b87db0 100644 --- a/src/components/settings/access/grants-given-card.tsx +++ b/src/components/settings/access/grants-given-card.tsx @@ -2,6 +2,10 @@ import { Loader2, Users } from "lucide-react"; +import { + GrantActionAlert, + grantActionErrorKey, +} from "@/components/settings/access/grant-action-error"; import { GrantRowItem } from "@/components/settings/access/grant-row"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -75,6 +79,7 @@ export function GrantsGivenCard() { key={grant.id} grant={grant} side="given" + notice={renderFailure(grant)} actions={renderRevoke(grant)} /> ))} @@ -84,6 +89,28 @@ export function GrantsGivenCard() { ); + /** + * A refused revoke, on the row it was refused for. + * + * The confirm dialog has already closed by the time the server answers, so + * without this a refusal is invisible: the list looks the same, and the one + * refusal that matters — a managed profile that would be left with nobody + * looking after it — arrived as silence. Read off the mutation rather than + * copied into local state, so `variables` is what decides which row wears + * it. + */ + function renderFailure(grant: GrantRow) { + if (!revoke.isError || revoke.variables !== grant.id) return null; + return ( + revoke.mutate(grant.id)} + /> + ); + } + function renderRevoke(grant: GrantRow) { // Only a live grant can be ended. An already-ended row keeps its history // and loses its button, rather than offering an act that would 409. diff --git a/src/components/settings/access/grants-received-card.tsx b/src/components/settings/access/grants-received-card.tsx index 4ed9eb633..dd7f581b5 100644 --- a/src/components/settings/access/grants-received-card.tsx +++ b/src/components/settings/access/grants-received-card.tsx @@ -2,6 +2,11 @@ import { FolderOpen, Loader2 } from "lucide-react"; +import { + GrantActionAlert, + grantActionErrorKey, + type GrantAction, +} from "@/components/settings/access/grant-action-error"; import { GrantRowItem } from "@/components/settings/access/grant-row"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -34,6 +39,18 @@ import { * at the row and should not have to go hunting for the switcher. * * **Hand back.** The delegate's own way out, attributed as theirs so the * owner's record can tell "I withdrew it" from "he gave it back". + * + * v1.37.0 — both acts can now be refused in ways the person can do something + * about, and both used to fail in silence. An invitation that lapsed before it + * was opened, and the last Guardian being told they may not hand back a + * profile that would then have nobody looking after it. The refusal renders on + * its own row, announced, with the row's control still there. + * + * The failed state is read off the mutation rather than copied into a + * `useState` from `onError`. TanStack keeps `isError` / `error` / `variables` + * until something resets it, and `variables` is what says which row failed — a + * local copy would be a second version of that fact, and it is the copy that + * goes stale when an unrelated invalidation re-renders the panel. */ export function GrantsReceivedCard() { const { t } = useTranslations(); @@ -78,7 +95,12 @@ export function GrantsReceivedCard() { key={grant.id} grant={grant} side="received" - notice={renderConsent(grant)} + notice={ + <> + {renderConsent(grant)} + {renderFailure(grant)} + + } actions={renderActions(grant)} /> ))} @@ -88,6 +110,52 @@ export function GrantsReceivedCard() { ); + /** + * The refusal this row is carrying, if it is the row that was refused. + * + * Guarded on `variables` rather than rendered under the list, because a + * panel with two pending invitations would otherwise show one row's failure + * with no way to tell which act it belonged to. + */ + function renderFailure(grant: GrantRow) { + const failure = failedAction(grant.id); + if (!failure) return null; + return ( + + ); + } + + /** Which of the two acts failed on this row, and how to run it again. */ + function failedAction(grantId: string): { + action: GrantAction; + error: unknown; + pending: boolean; + retry: () => void; + } | null { + if (accept.isError && accept.variables === grantId) { + return { + action: "accept", + error: accept.error, + pending: accept.isPending, + retry: () => accept.mutate(grantId), + }; + } + if (renounce.isError && renounce.variables === grantId) { + return { + action: "renounce", + error: renounce.error, + pending: renounce.isPending, + retry: () => renounce.mutate(grantId), + }; + } + return null; + } + /** * The delegate's half of the consent, on the screen where they give it. *