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:
Marc-André Bombeck committed 2026-08-07 02:07:49 +02:00
1 parent 365ba8c2eb
commit b3548f105e
11 files changed
+482 -35

No files matched your search

+13 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(" ");
}
+59 -1
View File
@@ -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,
});
}