mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat: delete a managed profile from its guardian's panel
- confirm before deleting, naming what goes and that it cannot be undone - state how many people lose the record, taken from the guardian roster rather than assumed to be one - say that deleting is the way out when you are the last guardian, since this route sends no last-guardian refusal - announce a refused deletion on the profile's own row, retryable
This commit is contained in:
1 parent
365ba8c2eb
commit
b3548f105e
11 files changed
+482
-35
No files matched your search
+13
-2
@@ -9663,7 +9663,15 @@
|
||||
"errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.",
|
||||
"errorOffline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert — prüfe deine Verbindung und versuche es erneut.",
|
||||
"errorInvalid": "Der Server hat diese Angaben abgelehnt. Prüfe Name, Datum und Zeitzone.",
|
||||
"errorFailed": "Das Profil konnte nicht angelegt werden."
|
||||
"errorFailed": "Das Profil konnte nicht angelegt werden.",
|
||||
"delete": "Profil löschen",
|
||||
"deleteTitle": "{name} löschen?",
|
||||
"deleteBody": "Alles, was für {name} erfasst wurde, verschwindet mit dem Profil — Messwerte, Medikamente, Dokumente und Notizen. Das lässt sich nicht rückgängig machen.",
|
||||
"deleteGuardiansOne": "{count} Person betreut diese Akte derzeit und verliert sie.",
|
||||
"deleteGuardiansFew": "{count} Personen betreuen diese Akte derzeit und verlieren sie.",
|
||||
"deleteGuardiansOther": "{count} Personen betreuen diese Akte derzeit und verlieren sie.",
|
||||
"deleteFloorNote": "Das ist auch der Ausweg, wenn du die letzte betreuende Person bist: Du darfst die Akte beenden, aber nicht ohne Betreuung zurücklassen.",
|
||||
"deleteConfirm": "Profil löschen"
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "In der Akte von {name} gespeichert."
|
||||
@@ -9809,7 +9817,10 @@
|
||||
"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."
|
||||
"renounceFailed": "Der Zugriff konnte nicht zurückgegeben werden.",
|
||||
"profileNotFound": "Dieses Profil gibt es nicht mehr — es wurde vielleicht schon gelöscht, oder dein Zugriff darauf ist beendet.",
|
||||
"deleteProfileFailed": "Das Profil konnte nicht gelöscht werden.",
|
||||
"removeGuardianFailed": "Dieser Zugriff konnte nicht beendet werden."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Ein Eintrag zur Familienanamnese beschreibt die Angehörigen dieser Person. Trage ihn nur ein, wenn sie das hier festgehalten haben möchte."
|
||||
|
||||
+13
-2
@@ -9663,7 +9663,15 @@
|
||||
"errorStepUp": "Confirm your second factor, then try again.",
|
||||
"errorOffline": "That did not reach the server. Nothing has changed — check your connection and try again.",
|
||||
"errorInvalid": "The server refused those details. Check the name, the date and the timezone.",
|
||||
"errorFailed": "The profile could not be created."
|
||||
"errorFailed": "The profile could not be created.",
|
||||
"delete": "Delete profile",
|
||||
"deleteTitle": "Delete {name}?",
|
||||
"deleteBody": "Everything recorded for {name} goes with the profile — readings, medications, documents and notes. This cannot be undone.",
|
||||
"deleteGuardiansOne": "{count} person looks after this record today and will lose it.",
|
||||
"deleteGuardiansFew": "{count} people look after this record today and will lose it.",
|
||||
"deleteGuardiansOther": "{count} people look after this record today and will lose it.",
|
||||
"deleteFloorNote": "This is also the way out when you are the last guardian: you may end the record, but you may not leave it with nobody looking after it.",
|
||||
"deleteConfirm": "Delete profile"
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Saved to {name}'s record."
|
||||
@@ -9809,7 +9817,10 @@
|
||||
"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."
|
||||
"renounceFailed": "The access could not be handed back.",
|
||||
"profileNotFound": "That profile is not there any more — it may already have been deleted, or your access to it may have ended.",
|
||||
"deleteProfileFailed": "The profile could not be deleted.",
|
||||
"removeGuardianFailed": "That access could not be ended."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "A family history entry describes this person's relatives. Add it only when they would want it recorded here."
|
||||
|
||||
+13
-2
@@ -9663,7 +9663,15 @@
|
||||
"errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.",
|
||||
"errorOffline": "Esto no llegó al servidor. No ha cambiado nada — revisa tu conexión y vuelve a intentarlo.",
|
||||
"errorInvalid": "El servidor rechazó esos datos. Revisa el nombre, la fecha y la zona horaria.",
|
||||
"errorFailed": "No se pudo crear el perfil."
|
||||
"errorFailed": "No se pudo crear el perfil.",
|
||||
"delete": "Eliminar perfil",
|
||||
"deleteTitle": "¿Eliminar {name}?",
|
||||
"deleteBody": "Todo lo registrado para {name} desaparece con el perfil — mediciones, medicamentos, documentos y notas. Esto no se puede deshacer.",
|
||||
"deleteGuardiansOne": "{count} persona se ocupa hoy de este historial y lo perderá.",
|
||||
"deleteGuardiansFew": "{count} personas se ocupan hoy de este historial y lo perderán.",
|
||||
"deleteGuardiansOther": "{count} personas se ocupan hoy de este historial y lo perderán.",
|
||||
"deleteFloorNote": "También es la salida cuando eres la última persona responsable: puedes terminar el historial, pero no dejarlo sin nadie que se ocupe de él.",
|
||||
"deleteConfirm": "Eliminar perfil"
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Guardado en el historial de {name}."
|
||||
@@ -9809,7 +9817,10 @@
|
||||
"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."
|
||||
"renounceFailed": "No se pudo devolver el acceso.",
|
||||
"profileNotFound": "Ese perfil ya no está — puede que se haya eliminado, o que tu acceso haya terminado.",
|
||||
"deleteProfileFailed": "No se pudo eliminar el perfil.",
|
||||
"removeGuardianFailed": "No se pudo terminar ese acceso."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Una entrada de antecedentes familiares describe a los familiares de esta persona. Añádela solo si querría que quedara registrada aquí."
|
||||
|
||||
+13
-2
@@ -9663,7 +9663,15 @@
|
||||
"errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.",
|
||||
"errorOffline": "Cela n'a pas atteint le serveur. Rien n'a changé — vérifiez votre connexion et réessayez.",
|
||||
"errorInvalid": "Le serveur a refusé ces informations. Vérifiez le nom, la date et le fuseau horaire.",
|
||||
"errorFailed": "Le profil n'a pas pu être créé."
|
||||
"errorFailed": "Le profil n'a pas pu être créé.",
|
||||
"delete": "Supprimer le profil",
|
||||
"deleteTitle": "Supprimer {name} ?",
|
||||
"deleteBody": "Tout ce qui a été enregistré pour {name} disparaît avec le profil — mesures, médicaments, documents et notes. Cette action est irréversible.",
|
||||
"deleteGuardiansOne": "{count} personne s'occupe aujourd'hui de ce dossier et le perdra.",
|
||||
"deleteGuardiansFew": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.",
|
||||
"deleteGuardiansOther": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.",
|
||||
"deleteFloorNote": "C'est aussi l'issue lorsque vous êtes la dernière personne responsable : vous pouvez mettre fin au dossier, mais pas le laisser sans personne.",
|
||||
"deleteConfirm": "Supprimer le profil"
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Enregistré dans le dossier de {name}."
|
||||
@@ -9809,7 +9817,10 @@
|
||||
"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."
|
||||
"renounceFailed": "L'accès n'a pas pu être rendu.",
|
||||
"profileNotFound": "Ce profil n'existe plus — il a peut-être été supprimé, ou votre accès a pris fin.",
|
||||
"deleteProfileFailed": "Le profil n'a pas pu être supprimé.",
|
||||
"removeGuardianFailed": "Cet accès n'a pas pu prendre fin."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Un antécédent familial décrit les proches de cette personne. Ne l'ajoutez que si elle voudrait le voir consigné ici."
|
||||
|
||||
+13
-2
@@ -9663,7 +9663,15 @@
|
||||
"errorStepUp": "Conferma il tuo secondo fattore e riprova.",
|
||||
"errorOffline": "Non è arrivato al server. Non è cambiato nulla — controlla la connessione e riprova.",
|
||||
"errorInvalid": "Il server ha rifiutato questi dati. Controlla nome, data e fuso orario.",
|
||||
"errorFailed": "Non è stato possibile creare il profilo."
|
||||
"errorFailed": "Non è stato possibile creare il profilo.",
|
||||
"delete": "Elimina profilo",
|
||||
"deleteTitle": "Eliminare {name}?",
|
||||
"deleteBody": "Tutto ciò che è stato registrato per {name} sparisce insieme al profilo — misurazioni, farmaci, documenti e note. Non si può annullare.",
|
||||
"deleteGuardiansOne": "{count} persona si occupa oggi di questa cartella e la perderà.",
|
||||
"deleteGuardiansFew": "{count} persone si occupano oggi di questa cartella e la perderanno.",
|
||||
"deleteGuardiansOther": "{count} persone si occupano oggi di questa cartella e la perderanno.",
|
||||
"deleteFloorNote": "È anche la via d'uscita quando sei l'ultima persona responsabile: puoi chiudere la cartella, ma non lasciarla senza nessuno.",
|
||||
"deleteConfirm": "Elimina profilo"
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Salvato nella cartella di {name}."
|
||||
@@ -9809,7 +9817,10 @@
|
||||
"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."
|
||||
"renounceFailed": "Non è stato possibile restituire l'accesso.",
|
||||
"profileNotFound": "Quel profilo non c'è più — potrebbe essere già stato eliminato, o il tuo accesso è terminato.",
|
||||
"deleteProfileFailed": "Non è stato possibile eliminare il profilo.",
|
||||
"removeGuardianFailed": "Non è stato possibile terminare quell'accesso."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Una voce di anamnesi familiare descrive i parenti di questa persona. Aggiungila solo se vorrebbe vederla registrata qui."
|
||||
|
||||
+13
-2
@@ -9663,7 +9663,15 @@
|
||||
"errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.",
|
||||
"errorOffline": "To nie dotarło do serwera. Nic się nie zmieniło — sprawdź połączenie i spróbuj ponownie.",
|
||||
"errorInvalid": "Serwer odrzucił te dane. Sprawdź nazwę, datę i strefę czasową.",
|
||||
"errorFailed": "Nie udało się utworzyć profilu."
|
||||
"errorFailed": "Nie udało się utworzyć profilu.",
|
||||
"delete": "Usuń profil",
|
||||
"deleteTitle": "Usunąć {name}?",
|
||||
"deleteBody": "Wszystko, co zapisano dla {name}, znika razem z profilem — pomiary, leki, dokumenty i notatki. Tego nie da się cofnąć.",
|
||||
"deleteGuardiansOne": "{count} osoba opiekuje się dziś tą kartą i ją straci.",
|
||||
"deleteGuardiansFew": "{count} osoby opiekują się dziś tą kartą i ją stracą.",
|
||||
"deleteGuardiansOther": "{count} osób opiekuje się dziś tą kartą i ją straci.",
|
||||
"deleteFloorNote": "To także wyjście, gdy jesteś ostatnią osobą opiekującą się profilem: możesz zamknąć kartę, ale nie zostawić jej bez opieki.",
|
||||
"deleteConfirm": "Usuń profil"
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Zapisano w karcie użytkownika {name}."
|
||||
@@ -9809,7 +9817,10 @@
|
||||
"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."
|
||||
"renounceFailed": "Nie udało się zwrócić dostępu.",
|
||||
"profileNotFound": "Tego profilu już nie ma — mógł zostać usunięty albo Twój dostęp do niego się zakończył.",
|
||||
"deleteProfileFailed": "Nie udało się usunąć profilu.",
|
||||
"removeGuardianFailed": "Nie udało się zakończyć tego dostępu."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Wpis w wywiadzie rodzinnym opisuje bliskich tej osoby. Dodaj go tylko wtedy, gdy chciałaby, żeby został tu zapisany."
|
||||
|
||||
@@ -207,6 +207,36 @@ describe("what a refused sharing action says", () => {
|
||||
key: "recordSharing.actionError.notFound",
|
||||
why: "the row is gone",
|
||||
},
|
||||
{
|
||||
action: "deleteProfile",
|
||||
err: apiError(404, "managed_profile.not_found"),
|
||||
key: "recordSharing.actionError.profileNotFound",
|
||||
why: "the only refusal the delete route classifies, and it covers three situations the server will not tell apart",
|
||||
},
|
||||
{
|
||||
action: "removeGuardian",
|
||||
err: apiError(404, "managed_profile.not_found"),
|
||||
key: "recordSharing.actionError.profileNotFound",
|
||||
why: "the same refusal reached from the guardian-removal route",
|
||||
},
|
||||
{
|
||||
action: "removeGuardian",
|
||||
err: apiError(409, "managed_profile.guardian.required"),
|
||||
key: "recordSharing.actionError.lastGuardian",
|
||||
why: "the floor, still enforced by the server after the card has hidden the control",
|
||||
},
|
||||
{
|
||||
action: "deleteProfile",
|
||||
err: apiError(500),
|
||||
key: "recordSharing.actionError.deleteProfileFailed",
|
||||
why: "anything the delete route does not classify becomes the generic envelope and must not borrow a diagnosis",
|
||||
},
|
||||
{
|
||||
action: "removeGuardian",
|
||||
err: new TypeError("Failed to fetch"),
|
||||
key: "recordSharing.actionError.offline",
|
||||
why: "never reached the server, so the guardian still has access and the row is unchanged",
|
||||
},
|
||||
{
|
||||
action: "accept",
|
||||
err: new TypeError("Failed to fetch"),
|
||||
|
||||
@@ -38,6 +38,7 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
|
||||
import type { GrantList } from "@/lib/queries/use-account-grants";
|
||||
import type { ManagedProfileGuardian } from "@/lib/queries/use-managed-profiles";
|
||||
|
||||
/** The account payload, as the cards under test read it. */
|
||||
const authRef: {
|
||||
@@ -99,17 +100,42 @@ vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
/** The roster read, as each profile row sees it. */
|
||||
const rosterRef: { value: ManagedProfileGuardian[] | undefined } = {
|
||||
value: [],
|
||||
};
|
||||
/** The deletion mutation, idle unless a case says otherwise. */
|
||||
const deleteRef = { value: idle() };
|
||||
|
||||
vi.mock("@/lib/queries/use-managed-profiles", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/queries/use-managed-profiles")>();
|
||||
return { ...actual, useCreateManagedProfile: () => idle() };
|
||||
return {
|
||||
...actual,
|
||||
useCreateManagedProfile: () => idle(),
|
||||
useDeleteManagedProfile: () => deleteRef.value,
|
||||
useManagedProfileGuardians: () => ({
|
||||
data: rosterRef.value,
|
||||
isLoading: false,
|
||||
isError: rosterRef.value === undefined,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
function idle() {
|
||||
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,
|
||||
@@ -119,13 +145,34 @@ function idle() {
|
||||
};
|
||||
}
|
||||
|
||||
function failed(error: unknown, variables: string): FakeMutation {
|
||||
return { ...idle(), isError: true, error, variables };
|
||||
}
|
||||
|
||||
function guardian(
|
||||
partial: Partial<ManagedProfileGuardian> = {},
|
||||
): ManagedProfileGuardian {
|
||||
return {
|
||||
grantId: "g1",
|
||||
account: { id: "acct-1", username: "guardian", displayName: "Alex" },
|
||||
state: "ACTIVE",
|
||||
invitedAt: "2026-01-02T10:00:00.000Z",
|
||||
acceptedAt: "2026-01-02T11:00:00.000Z",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
import { AccessSection } from "@/components/settings/access-section";
|
||||
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
|
||||
import {
|
||||
createManagedProfileErrorKey,
|
||||
displayNameIssue,
|
||||
} from "../managed-profile-create-form";
|
||||
import { managedProfilesOf } from "../managed-profile-card";
|
||||
import {
|
||||
activeGuardianCount,
|
||||
deleteProfileBody,
|
||||
managedProfilesOf,
|
||||
} from "../managed-profile-card";
|
||||
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
@@ -158,6 +205,8 @@ function entry(partial: Partial<AccountAccessEntry> = {}): AccountAccessEntry {
|
||||
|
||||
beforeEach(() => {
|
||||
authRef.value = { accounts: [], active: null };
|
||||
rosterRef.value = [guardian()];
|
||||
deleteRef.value = idle();
|
||||
});
|
||||
|
||||
describe("the managed-profile card, in the shared-access section", () => {
|
||||
@@ -331,6 +380,103 @@ describe("what the form decides before it sends", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ending a managed profile", () => {
|
||||
it("offers a confirmed delete on the profile's own row", () => {
|
||||
authRef.value = { accounts: [entry()], active: null };
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-slot="managed-profile-delete"');
|
||||
// The mobile tap floor UI-STANDARDS §11 asks of an inline action.
|
||||
expect(html).toMatch(
|
||||
/data-slot="managed-profile-delete"[^>]*class="[^"]*min-h-11/,
|
||||
);
|
||||
});
|
||||
|
||||
it("withholds the control until the roster has answered", () => {
|
||||
// The confirm copy states how many people lose the record, and that number
|
||||
// comes from the roster. A control that existed before the read would put
|
||||
// the sentence's presence at the mercy of which request landed first —
|
||||
// the defect the retention disclosure already paid for once.
|
||||
authRef.value = { accounts: [entry()], active: null };
|
||||
rosterRef.value = undefined;
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-managed-profile-id="p1"');
|
||||
expect(html).not.toContain('data-slot="managed-profile-delete"');
|
||||
});
|
||||
|
||||
it("announces a refused deletion on the row it was refused for", () => {
|
||||
authRef.value = {
|
||||
accounts: [entry(), entry({ accountId: "p2", displayName: "Second" })],
|
||||
active: null,
|
||||
};
|
||||
deleteRef.value = failed(
|
||||
new ApiError("refused", 404, {
|
||||
errorCode: "managed_profile.not_found",
|
||||
}),
|
||||
"p1",
|
||||
);
|
||||
const html = renderGatedSection();
|
||||
|
||||
expect(html).toContain('data-slot="grant-action-error"');
|
||||
expect(html).toContain('data-grant-error-for="p1"');
|
||||
// Not painted onto the profile that did not fail.
|
||||
expect(html).not.toContain('data-grant-error-for="p2"');
|
||||
expect(html.match(/data-slot="grant-action-error"/g)).toHaveLength(1);
|
||||
// Specific, not the generic "could not be deleted".
|
||||
expect(html).toContain("That profile is not there any more");
|
||||
// Retryable, and the row keeps its control.
|
||||
expect(html).toContain('data-slot="grant-action-retry"');
|
||||
expect(html).toContain('data-slot="managed-profile-delete"');
|
||||
});
|
||||
|
||||
it("shows nothing at all while the deletion is idle", () => {
|
||||
authRef.value = { accounts: [entry()], active: null };
|
||||
expect(renderGatedSection()).not.toContain(
|
||||
'data-slot="grant-action-error"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the confirmation says before it deletes", () => {
|
||||
const t = (key: string, params?: Record<string, string | number>): string =>
|
||||
params ? `${key}(${JSON.stringify(params)})` : key;
|
||||
const tCount = (
|
||||
base: string,
|
||||
count: number,
|
||||
params?: Record<string, string | number>,
|
||||
): string => `${base}:${count}(${JSON.stringify(params)})`;
|
||||
|
||||
it("names what goes, how many people lose it, and the way out", () => {
|
||||
const body = deleteProfileBody({
|
||||
t,
|
||||
tCount,
|
||||
name: "Managed record",
|
||||
activeGuardians: 2,
|
||||
});
|
||||
expect(body).toContain(
|
||||
'recordSharing.managed.deleteBody({"name":"Managed record"})',
|
||||
);
|
||||
expect(body).toContain("recordSharing.managed.deleteGuardians:2");
|
||||
// The delete route emits no last-Guardian refusal, because deleting IS the
|
||||
// way out of the floor. The copy says so rather than duplicating a refusal
|
||||
// this route never sends.
|
||||
expect(body).toContain("recordSharing.managed.deleteFloorNote");
|
||||
});
|
||||
|
||||
it("counts the guardians who actually hold the record", () => {
|
||||
// A pending invitation confers nothing, and the server counts the same
|
||||
// way. Counting it would tell somebody two people lose the record when
|
||||
// one does.
|
||||
expect(
|
||||
activeGuardianCount([
|
||||
guardian(),
|
||||
guardian({ grantId: "g2", state: "PENDING", acceptedAt: null }),
|
||||
]),
|
||||
).toBe(1);
|
||||
expect(activeGuardianCount([])).toBe(0);
|
||||
expect(activeGuardianCount(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("which accounts count as profiles looked after", () => {
|
||||
it("keeps the managed entries and drops the rest", () => {
|
||||
const managed = entry();
|
||||
|
||||
@@ -35,13 +35,16 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* 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";
|
||||
export type GrantAction =
|
||||
"accept" | "revoke" | "renounce" | "deleteProfile" | "removeGuardian";
|
||||
|
||||
/** 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",
|
||||
deleteProfile: "recordSharing.actionError.deleteProfileFailed",
|
||||
removeGuardian: "recordSharing.actionError.removeGuardianFailed",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -68,6 +71,15 @@ export function grantActionErrorKey(err: unknown, action: GrantAction): string {
|
||||
// it is the same sentence.
|
||||
case "managed_profile.guardian.required":
|
||||
return "recordSharing.actionError.lastGuardian";
|
||||
// v1.37.0 — the only refusal the delete route classifies, and the only one
|
||||
// the guardian-removal route classifies besides the floor. It covers three
|
||||
// situations the server deliberately does not tell apart (no such profile,
|
||||
// not a managed one, the caller is not its Guardian), so the sentence
|
||||
// promises no diagnosis: it says the profile is not reachable and leaves
|
||||
// it there. Anything the route does NOT classify becomes the generic 500
|
||||
// envelope and must not borrow this sentence.
|
||||
case "managed_profile.not_found":
|
||||
return "recordSharing.actionError.profileNotFound";
|
||||
case "sharing.accept.expired":
|
||||
return "recordSharing.actionError.acceptExpired";
|
||||
case "sharing.accept.revoked":
|
||||
|
||||
@@ -2,11 +2,21 @@
|
||||
|
||||
import { UserRoundCog } from "lucide-react";
|
||||
|
||||
import {
|
||||
GrantActionAlert,
|
||||
grantActionErrorKey,
|
||||
} from "@/components/settings/access/grant-action-error";
|
||||
import { ManagedProfileCreateForm } from "@/components/settings/access/managed-profile-create-form";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useDeleteManagedProfile,
|
||||
useManagedProfileGuardians,
|
||||
type ManagedProfileGuardian,
|
||||
} from "@/lib/queries/use-managed-profiles";
|
||||
import {
|
||||
accountLabel,
|
||||
type AccountAccessEntry,
|
||||
@@ -40,10 +50,20 @@ import {
|
||||
*
|
||||
* It sits directly after the invitation card because both are acts, and this
|
||||
* one creates the record the other two then talk about.
|
||||
*
|
||||
* ## One deletion mutation for the whole card
|
||||
*
|
||||
* Hoisted here rather than instantiated per row, so `variables` is what says
|
||||
* WHICH profile was refused — the same shape `grants-given-card.tsx` uses, and
|
||||
* the reason a static render can assert a failure state at all. The failure is
|
||||
* read off the mutation rather than copied into a `useState`, so an unrelated
|
||||
* invalidation cannot clear it and a second failure cannot paint one row's
|
||||
* message onto another's.
|
||||
*/
|
||||
export function ManagedProfileCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const remove = useDeleteManagedProfile();
|
||||
const profiles = managedProfilesOf(user?.accountAccess?.accounts);
|
||||
|
||||
return (
|
||||
@@ -63,25 +83,11 @@ export function ManagedProfileCard() {
|
||||
{profiles.length > 0 && (
|
||||
<ul data-slot="managed-profile-list" className="divide-y">
|
||||
{profiles.map((profile) => (
|
||||
<li
|
||||
<ManagedProfileRow
|
||||
key={profile.accountId}
|
||||
data-slot="managed-profile-row"
|
||||
data-managed-profile-id={profile.accountId}
|
||||
className="py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
{/* UI-STANDARDS §11 inline action row. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* A person's name is user data and stays content. */}
|
||||
<p className="truncate text-sm font-medium">
|
||||
{accountLabel(profile)}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{t("recordSharing.lookingAfter.kindManaged")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
profile={profile}
|
||||
remove={remove}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
@@ -92,6 +98,82 @@ export function ManagedProfileCard() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile: who it is, and the way to end it.
|
||||
*
|
||||
* The roster read lives here rather than in the card because it is per
|
||||
* profile, and because the delete control depends on it: the confirm copy
|
||||
* states how many people lose the record, and that number has to come from the
|
||||
* read that produced the row rather than from a local counter. The control is
|
||||
* withheld until the roster has answered, for the same reason `grants-given-
|
||||
* card.tsx` withholds its revoke until the retention window is in hand — a
|
||||
* disclosure that appears or not depending on which request landed first is
|
||||
* the defect that release already paid for once.
|
||||
*
|
||||
* A roster that 404s is a profile this account is no longer a Guardian of (the
|
||||
* account payload can be that much stale), and it leaves the row visible with
|
||||
* no destructive control, which is the right way round.
|
||||
*/
|
||||
function ManagedProfileRow({
|
||||
profile,
|
||||
remove,
|
||||
}: {
|
||||
profile: AccountAccessEntry;
|
||||
remove: ReturnType<typeof useDeleteManagedProfile>;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const guardians = useManagedProfileGuardians(profile.accountId);
|
||||
const name = accountLabel(profile);
|
||||
const failed = remove.isError && remove.variables === profile.accountId;
|
||||
|
||||
return (
|
||||
<li
|
||||
data-slot="managed-profile-row"
|
||||
data-managed-profile-id={profile.accountId}
|
||||
className="space-y-2 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
{/* UI-STANDARDS §11 inline action row. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* A person's name is user data and stays content. */}
|
||||
<p className="truncate text-sm font-medium">{name}</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{t("recordSharing.lookingAfter.kindManaged")}
|
||||
</p>
|
||||
</div>
|
||||
{guardians.data && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<ConfirmButton
|
||||
slot="managed-profile-delete"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
label={t("recordSharing.managed.delete")}
|
||||
title={t("recordSharing.managed.deleteTitle", { name })}
|
||||
body={deleteProfileBody({
|
||||
t,
|
||||
tCount,
|
||||
name,
|
||||
activeGuardians: activeGuardianCount(guardians.data),
|
||||
})}
|
||||
confirmLabel={t("recordSharing.managed.deleteConfirm")}
|
||||
pending={remove.isPending}
|
||||
onConfirm={() => remove.mutate(profile.accountId)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{failed && (
|
||||
<GrantActionAlert
|
||||
grantId={profile.accountId}
|
||||
message={t(grantActionErrorKey(remove.error, "deleteProfile"))}
|
||||
retrying={remove.isPending}
|
||||
onRetry={() => remove.mutate(profile.accountId)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed entries of the account payload, in the order the server sent
|
||||
* them.
|
||||
@@ -105,3 +187,56 @@ export function managedProfilesOf(
|
||||
): AccountAccessEntry[] {
|
||||
return (accounts ?? []).filter((entry) => entry.recordKind === "managed");
|
||||
}
|
||||
|
||||
/**
|
||||
* How many people actually hold this record right now.
|
||||
*
|
||||
* ACTIVE only, which is the same rule `activeGuardianWhere` enforces
|
||||
* server-side: an invitation nobody has accepted confers nothing, and counting
|
||||
* it would tell somebody two people lose the record when one does.
|
||||
*/
|
||||
export function activeGuardianCount(
|
||||
roster: ManagedProfileGuardian[] | undefined,
|
||||
): number {
|
||||
return (roster ?? []).filter((guardian) => guardian.state === "ACTIVE")
|
||||
.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* What deleting this profile does, as one paragraph.
|
||||
*
|
||||
* Exported and pure because the dialog it feeds only exists after a click and
|
||||
* an SSR render cannot see inside it — the same reason `revokeBody` is
|
||||
* exported from the grants card. The composition is the thing worth pinning.
|
||||
*
|
||||
* Three sentences, and the third is the one that is easy to leave out. The
|
||||
* delete route emits NO last-Guardian refusal, and that is not an oversight:
|
||||
* deleting the profile is the documented way out of the floor. A Guardian may
|
||||
* not strand a record with nobody looking after it, but they may end the
|
||||
* record. Saying so here is better than duplicating a refusal this route never
|
||||
* sends.
|
||||
*/
|
||||
export function deleteProfileBody({
|
||||
t,
|
||||
tCount,
|
||||
name,
|
||||
activeGuardians,
|
||||
}: {
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
tCount: (
|
||||
baseKey: string,
|
||||
count: number,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
name: string;
|
||||
/** Taken from the roster read, never from a local counter. */
|
||||
activeGuardians: number;
|
||||
}): string {
|
||||
return [
|
||||
t("recordSharing.managed.deleteBody", { name }),
|
||||
tCount("recordSharing.managed.deleteGuardians", activeGuardians, {
|
||||
count: activeGuardians,
|
||||
}),
|
||||
t("recordSharing.managed.deleteFloorNote"),
|
||||
].join(" ");
|
||||
}
|
||||
@@ -22,13 +22,15 @@
|
||||
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { invalidateGrantReads } from "@/lib/queries/use-account-grants";
|
||||
import type { GrantParty } from "@/lib/queries/use-account-grants";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
@@ -96,3 +98,59 @@ export function useCreateManagedProfile() {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** One person who looks after a profile, as the roster publishes them. */
|
||||
export interface ManagedProfileGuardian {
|
||||
/** The grant row — what `DELETE .../guardians/{grantId}` takes. */
|
||||
grantId: string;
|
||||
account: GrantParty;
|
||||
/**
|
||||
* PENDING or ACTIVE. Only ACTIVE counts toward the last-Guardian floor, and
|
||||
* the server counts the same way — a pending invitation does not let the
|
||||
* last Guardian leave.
|
||||
*/
|
||||
state: "PENDING" | "ACTIVE";
|
||||
invitedAt: string;
|
||||
acceptedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who else looks after this profile.
|
||||
*
|
||||
* The read the removal control needs to exist at all: a Guardian grant has the
|
||||
* PROFILE as grantor, so it never appears in the caller's own given-list and
|
||||
* `GET /api/account/grants` cannot supply the `grantId`. It is also where the
|
||||
* last-Guardian floor is counted from BEFORE the act rather than discovered as
|
||||
* a 409 after it.
|
||||
*/
|
||||
export function useManagedProfileGuardians(profileId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.managedProfileGuardians(profileId),
|
||||
queryFn: () =>
|
||||
apiGet<ManagedProfileGuardian[]>(
|
||||
`/api/managed-profiles/${profileId}/guardians`,
|
||||
),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a managed profile, and everything recorded in it.
|
||||
*
|
||||
* The variable is the profile id, which is what lets a refusal be rendered on
|
||||
* the row it was refused for rather than somewhere near it.
|
||||
*
|
||||
* This route emits no last-Guardian refusal, and that is deliberate rather
|
||||
* than an omission: deleting the profile is the documented way OUT of the
|
||||
* floor. A Guardian may not strand a record with nobody looking after it, but
|
||||
* they may end the record.
|
||||
*/
|
||||
export function useDeleteManagedProfile() {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileDelete(),
|
||||
mutationFn: (profileId: string) =>
|
||||
apiDelete<{ deleted: true }>(`/api/managed-profiles/${profileId}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user