mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix: preserve sharing failure states
Accepting, revoking and handing back a grant had no failure rendering at all. The request came back refused, or 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 most need a reader were the two that arrived most quietly — an invitation that had 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. Each refusal now renders on the row it belongs to, announced, in content contrast, beside a control that runs the same act again. The sentences are split by what the person does next rather than by status code, so lapsed and withdrawn read differently and the guardian refusal names the way out. A request that never reached the server says so instead of reporting a refusal nobody issued. The failed state is read off the mutation rather than copied into local state from onError: the copy is what goes stale, and the mutation already knows which row it was for. The invitation form keeps every field it had, and only a successful send clears them.
This commit is contained in:
1 parent
c92a80ee4c
commit
71ad8d66b6
11 files changed
+765
-10
No files matched your search
+17
-1
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> = {}): GrantList {
|
||||
return { given: [], received: [], retentionDays: 365, ...partial };
|
||||
}
|
||||
|
||||
/** A mutation as the panel sees one: idle unless a case says otherwise. */
|
||||
interface FakeMutation {
|
||||
mutate: ReturnType<typeof vi.fn>;
|
||||
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<typeof import("@/lib/queries/use-account-grants")>();
|
||||
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(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(<GrantsReceivedCard />);
|
||||
|
||||
// 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(<GrantsReceivedCard />);
|
||||
|
||||
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(<GrantsGivenCard />);
|
||||
|
||||
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(<GrantsReceivedCard />);
|
||||
|
||||
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(<GrantsReceivedCard />);
|
||||
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(<GrantsGivenCard />)).not.toContain(
|
||||
'data-slot="grant-action-error"',
|
||||
);
|
||||
expect(render(<GrantsReceivedCard />)).not.toContain(
|
||||
'data-slot="grant-action-error"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<GrantAction, string> = {
|
||||
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 (
|
||||
<div
|
||||
data-slot="grant-action-error"
|
||||
data-grant-error-for={grantId}
|
||||
role="alert"
|
||||
className="text-destructive flex flex-wrap items-center gap-2 text-sm"
|
||||
>
|
||||
<span>{message}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-slot="grant-action-retry"
|
||||
disabled={retrying}
|
||||
onClick={onRetry}
|
||||
>
|
||||
{t("recordSharing.actionError.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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() {
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<GrantActionAlert
|
||||
grantId={grant.id}
|
||||
message={t(grantActionErrorKey(revoke.error, "revoke"))}
|
||||
retrying={revoke.isPending}
|
||||
onRetry={() => 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.
|
||||
|
||||
@@ -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() {
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<GrantActionAlert
|
||||
grantId={grant.id}
|
||||
message={t(grantActionErrorKey(failure.error, failure.action))}
|
||||
retrying={failure.pending}
|
||||
onRetry={failure.retry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user