diff --git a/messages/de.json b/messages/de.json index bd7fc6f34..d3ed2a674 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." diff --git a/messages/en.json b/messages/en.json index 782d47575..e391523b7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." diff --git a/messages/es.json b/messages/es.json index 574c9cb63..69d92dcf7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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í." diff --git a/messages/fr.json b/messages/fr.json index 14efafce6..aa4109e19 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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." diff --git a/messages/it.json b/messages/it.json index e232ff48f..865b7ee9b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." diff --git a/messages/pl.json b/messages/pl.json index 2dc6cf27c..91b91e8fa 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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." diff --git a/src/__tests__/sharing-recoverable-errors.test.tsx b/src/__tests__/sharing-recoverable-errors.test.tsx index 881a13555..0c73a27f2 100644 --- a/src/__tests__/sharing-recoverable-errors.test.tsx +++ b/src/__tests__/sharing-recoverable-errors.test.tsx @@ -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"), diff --git a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx index 2d616dc39..3bc6d11d9 100644 --- a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx +++ b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx @@ -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(); - 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; + 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 { + 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 { 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 => + params ? `${key}(${JSON.stringify(params)})` : key; + const tCount = ( + base: string, + count: number, + params?: Record, + ): 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(); diff --git a/src/components/settings/access/grant-action-error.tsx b/src/components/settings/access/grant-action-error.tsx index 62e95682f..1e3dc4442 100644 --- a/src/components/settings/access/grant-action-error.tsx +++ b/src/components/settings/access/grant-action-error.tsx @@ -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 = { 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": diff --git a/src/components/settings/access/managed-profile-card.tsx b/src/components/settings/access/managed-profile-card.tsx index 73d220b33..2ce499513 100644 --- a/src/components/settings/access/managed-profile-card.tsx +++ b/src/components/settings/access/managed-profile-card.tsx @@ -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 && (
    {profiles.map((profile) => ( -
  • - {/* UI-STANDARDS §11 inline action row. */} -
    -
    - {/* A person's name is user data and stays content. */} -

    - {accountLabel(profile)} -

    -

    - {t("recordSharing.lookingAfter.kindManaged")} -

    -
    -
    -
  • + profile={profile} + remove={remove} + /> ))}
)} @@ -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; +}) { + const { t, tCount } = useTranslations(); + const guardians = useManagedProfileGuardians(profile.accountId); + const name = accountLabel(profile); + const failed = remove.isError && remove.variables === profile.accountId; + + return ( +
  • + {/* UI-STANDARDS §11 inline action row. */} +
    +
    + {/* A person's name is user data and stays content. */} +

    {name}

    +

    + {t("recordSharing.lookingAfter.kindManaged")} +

    +
    + {guardians.data && ( +
    + remove.mutate(profile.accountId)} + /> +
    + )} +
    + {failed && ( + remove.mutate(profile.accountId)} + /> + )} +
  • + ); +} + /** * 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; + tCount: ( + baseKey: string, + count: number, + params?: Record, + ) => 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(" "); +} diff --git a/src/lib/queries/use-managed-profiles.ts b/src/lib/queries/use-managed-profiles.ts index b0f7d1f03..2757bf14c 100644 --- a/src/lib/queries/use-managed-profiles.ts +++ b/src/lib/queries/use-managed-profiles.ts @@ -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( + `/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, + }); +}