feat: manage the guardians of a managed profile

- list who looks after a profile, invite another guardian and end an
  existing access from the profile's own row
- state the last-guardian floor from the real roster before the act, and
  say plainly that an invitation counts only once it is accepted
- give each of the invitation refusals its own sentence, branching on
  status for the two the route sends without an error code
- cover every call the card can make against the real routes
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 02:22:30 +02:00
1 parent b3548f105e
commit 6bfcccd755
11 files changed
+1210 -7

No files matched your search

+22 -1
View File
@@ -9671,7 +9671,28 @@
"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"
"deleteConfirm": "Profil löschen",
"guardiansTitle": "Wer sich um {name} kümmert",
"guardianYou": "(du)",
"guardianSince": "Betreut diese Akte seit {date}",
"guardianPending": "Eingeladen am {date}, noch nicht angenommen",
"guardianRemove": "Zugriff beenden",
"guardianRemoveTitle": "Zugriff von {name} beenden?",
"guardianRemoveBody": "{name} kann {profile} dann nicht mehr öffnen. Was diese Person eingetragen hat, bleibt in der Akte.",
"guardianWithdrawBody": "Die Einladung an {name} wird zurückgezogen. Die Person hat {profile} nie geöffnet, in der Akte ändert sich nichts.",
"guardianRemoveConfirm": "Zugriff beenden",
"soleGuardian": "Du bist die einzige Person, die sich um {name} kümmert — hier gibt es also keinen Zugriff, den du beenden könntest. Lade jemanden ein und warte auf die Annahme, gib deinen eigenen Zugriff unter „Akten, die ich öffnen kann“ zurück, oder lösche das Profil.",
"guardianInviteLabel": "Weitere betreuende Person einladen",
"guardianInvite": "Einladung senden",
"guardianInviting": "Wird gesendet…",
"guardianInvited": "Einladung an {name} gesendet.",
"guardianExpiresHint": "Optional. Der Zugriff endet am Ende dieses Tages in deiner Zeitzone.",
"guardianPendingNote": "Eine eingeladene Person zählt erst, wenn sie annimmt. Bis dahin bist du weiterhin die letzte, und die Rückgabe deines eigenen Zugriffs wird weiterhin abgelehnt.",
"guardianErrorDuplicate": "Dieses Konto hat bereits Zugriff auf diese Akte.",
"guardianErrorManagedInvitee": "Ein verwaltetes Profil kann keine andere Akte betreuen. Lade ein Konto ein, das sich anmeldet.",
"guardianErrorNoAccount": "Auf dieser Instanz gibt es kein Konto mit diesem Namen oder dieser E-Mail-Adresse.",
"guardianErrorRateLimit": "Zu viele Einladungen in der letzten Stunde. Warte etwas und versuche es erneut.",
"guardianErrorFailed": "Die Einladung konnte nicht gesendet werden."
},
"toast": {
"savedTo": "In der Akte von {name} gespeichert."
+22 -1
View File
@@ -9671,7 +9671,28 @@
"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"
"deleteConfirm": "Delete profile",
"guardiansTitle": "Who looks after {name}",
"guardianYou": "(you)",
"guardianSince": "Looking after this record since {date}",
"guardianPending": "Invited {date}, not accepted yet",
"guardianRemove": "End access",
"guardianRemoveTitle": "End {name}'s access?",
"guardianRemoveBody": "{name} stops being able to open {profile}. Anything they entered stays in the record.",
"guardianWithdrawBody": "The invitation to {name} is withdrawn. They never opened {profile}, so nothing changes in the record.",
"guardianRemoveConfirm": "End access",
"soleGuardian": "You are the only person looking after {name}, so there is no access here you can end. Invite somebody and wait for them to accept, hand your own access back under “Records I can open”, or delete the profile.",
"guardianInviteLabel": "Invite another guardian",
"guardianInvite": "Send invitation",
"guardianInviting": "Sending…",
"guardianInvited": "Invitation sent to {name}.",
"guardianExpiresHint": "Optional. Access ends at the end of that day in your timezone.",
"guardianPendingNote": "An invited guardian counts only once they accept. Until then you are still the last one, and handing your own access back is still refused.",
"guardianErrorDuplicate": "That account already has access to this record.",
"guardianErrorManagedInvitee": "A managed profile cannot look after another record. Invite an account that signs in.",
"guardianErrorNoAccount": "No account on this instance carries that name or e-mail.",
"guardianErrorRateLimit": "Too many invitations in the last hour. Wait a while and try again.",
"guardianErrorFailed": "The invitation could not be sent."
},
"toast": {
"savedTo": "Saved to {name}'s record."
+22 -1
View File
@@ -9671,7 +9671,28 @@
"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"
"deleteConfirm": "Eliminar perfil",
"guardiansTitle": "Quién se ocupa de {name}",
"guardianYou": "(tú)",
"guardianSince": "Se ocupa de este historial desde el {date}",
"guardianPending": "Invitada el {date}, aún sin aceptar",
"guardianRemove": "Terminar acceso",
"guardianRemoveTitle": "¿Terminar el acceso de {name}?",
"guardianRemoveBody": "{name} dejará de poder abrir {profile}. Lo que haya registrado se queda en el historial.",
"guardianWithdrawBody": "Se retira la invitación a {name}. Nunca abrió {profile}, así que en el historial no cambia nada.",
"guardianRemoveConfirm": "Terminar acceso",
"soleGuardian": "Eres la única persona que se ocupa de {name}, así que aquí no hay ningún acceso que puedas terminar. Invita a alguien y espera a que acepte, devuelve tu propio acceso en «Historiales que puedo abrir», o elimina el perfil.",
"guardianInviteLabel": "Invitar a otra persona responsable",
"guardianInvite": "Enviar invitación",
"guardianInviting": "Enviando…",
"guardianInvited": "Invitación enviada a {name}.",
"guardianExpiresHint": "Opcional. El acceso termina al final de ese día en tu zona horaria.",
"guardianPendingNote": "Una persona invitada solo cuenta cuando acepta. Hasta entonces sigues siendo la última, y devolver tu propio acceso se sigue rechazando.",
"guardianErrorDuplicate": "Esa cuenta ya tiene acceso a este historial.",
"guardianErrorManagedInvitee": "Un perfil administrado no puede ocuparse de otro historial. Invita a una cuenta que inicie sesión.",
"guardianErrorNoAccount": "En esta instancia no hay ninguna cuenta con ese nombre o correo.",
"guardianErrorRateLimit": "Demasiadas invitaciones en la última hora. Espera un poco y vuelve a intentarlo.",
"guardianErrorFailed": "No se pudo enviar la invitación."
},
"toast": {
"savedTo": "Guardado en el historial de {name}."
+22 -1
View File
@@ -9671,7 +9671,28 @@
"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"
"deleteConfirm": "Supprimer le profil",
"guardiansTitle": "Qui s'occupe de {name}",
"guardianYou": "(vous)",
"guardianSince": "S'occupe de ce dossier depuis le {date}",
"guardianPending": "Invitée le {date}, pas encore acceptée",
"guardianRemove": "Mettre fin à l'accès",
"guardianRemoveTitle": "Mettre fin à l'accès de {name} ?",
"guardianRemoveBody": "{name} ne pourra plus ouvrir {profile}. Ce qu'elle a saisi reste dans le dossier.",
"guardianWithdrawBody": "L'invitation adressée à {name} est retirée. Cette personne n'a jamais ouvert {profile}, rien ne change dans le dossier.",
"guardianRemoveConfirm": "Mettre fin à l'accès",
"soleGuardian": "Vous êtes la seule personne à vous occuper de {name} : il n'y a donc ici aucun accès auquel mettre fin. Invitez quelqu'un et attendez son acceptation, rendez votre propre accès sous « Dossiers que je peux ouvrir », ou supprimez le profil.",
"guardianInviteLabel": "Inviter une autre personne responsable",
"guardianInvite": "Envoyer l'invitation",
"guardianInviting": "Envoi…",
"guardianInvited": "Invitation envoyée à {name}.",
"guardianExpiresHint": "Facultatif. L'accès prend fin à la fin de cette journée dans votre fuseau horaire.",
"guardianPendingNote": "Une personne invitée ne compte qu'une fois qu'elle a accepté. D'ici là vous êtes toujours la dernière, et rendre votre propre accès reste refusé.",
"guardianErrorDuplicate": "Ce compte a déjà accès à ce dossier.",
"guardianErrorManagedInvitee": "Un profil géré ne peut pas s'occuper d'un autre dossier. Invitez un compte qui se connecte.",
"guardianErrorNoAccount": "Aucun compte de cette instance ne porte ce nom ou cette adresse e-mail.",
"guardianErrorRateLimit": "Trop d'invitations dans la dernière heure. Patientez un peu et réessayez.",
"guardianErrorFailed": "L'invitation n'a pas pu être envoyée."
},
"toast": {
"savedTo": "Enregistré dans le dossier de {name}."
+22 -1
View File
@@ -9671,7 +9671,28 @@
"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"
"deleteConfirm": "Elimina profilo",
"guardiansTitle": "Chi si occupa di {name}",
"guardianYou": "(tu)",
"guardianSince": "Si occupa di questa cartella dal {date}",
"guardianPending": "Invitata il {date}, non ancora accettata",
"guardianRemove": "Termina accesso",
"guardianRemoveTitle": "Terminare l'accesso di {name}?",
"guardianRemoveBody": "{name} non potrà più aprire {profile}. Quello che ha inserito resta nella cartella.",
"guardianWithdrawBody": "L'invito a {name} viene ritirato. Non ha mai aperto {profile}, quindi nella cartella non cambia nulla.",
"guardianRemoveConfirm": "Termina accesso",
"soleGuardian": "Sei l'unica persona che si occupa di {name}, quindi qui non c'è nessun accesso da terminare. Invita qualcuno e aspetta che accetti, restituisci il tuo accesso in «Cartelle che posso aprire», oppure elimina il profilo.",
"guardianInviteLabel": "Invita un'altra persona responsabile",
"guardianInvite": "Invia invito",
"guardianInviting": "Invio in corso…",
"guardianInvited": "Invito inviato a {name}.",
"guardianExpiresHint": "Facoltativo. L'accesso termina alla fine di quel giorno nel tuo fuso orario.",
"guardianPendingNote": "Una persona invitata conta solo quando accetta. Fino ad allora sei ancora l'ultima, e restituire il tuo accesso viene comunque rifiutato.",
"guardianErrorDuplicate": "Quell'account ha già accesso a questa cartella.",
"guardianErrorManagedInvitee": "Un profilo gestito non può occuparsi di un'altra cartella. Invita un account che accede da sé.",
"guardianErrorNoAccount": "Su questa istanza non esiste un account con quel nome o quella e-mail.",
"guardianErrorRateLimit": "Troppi inviti nell'ultima ora. Aspetta un po' e riprova.",
"guardianErrorFailed": "Non è stato possibile inviare l'invito."
},
"toast": {
"savedTo": "Salvato nella cartella di {name}."
+22 -1
View File
@@ -9671,7 +9671,28 @@
"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"
"deleteConfirm": "Usuń profil",
"guardiansTitle": "Kto opiekuje się kartą {name}",
"guardianYou": "(Ty)",
"guardianSince": "Opiekuje się tą kartą od {date}",
"guardianPending": "Zaproszenie z {date}, jeszcze nieprzyjęte",
"guardianRemove": "Zakończ dostęp",
"guardianRemoveTitle": "Zakończyć dostęp osoby {name}?",
"guardianRemoveBody": "{name} nie będzie już mogła otworzyć karty {profile}. To, co zapisała, zostaje w karcie.",
"guardianWithdrawBody": "Zaproszenie dla osoby {name} zostaje wycofane. Nigdy nie otworzyła karty {profile}, więc w karcie nic się nie zmienia.",
"guardianRemoveConfirm": "Zakończ dostęp",
"soleGuardian": "Jesteś jedyną osobą, która opiekuje się kartą {name}, więc nie ma tu dostępu, który mógłbyś zakończyć. Zaproś kogoś i poczekaj na przyjęcie, oddaj własny dostęp w sekcji „Karty, które mogę otworzyć” albo usuń profil.",
"guardianInviteLabel": "Zaproś kolejną osobę opiekującą się profilem",
"guardianInvite": "Wyślij zaproszenie",
"guardianInviting": "Wysyłanie…",
"guardianInvited": "Zaproszenie wysłane do osoby {name}.",
"guardianExpiresHint": "Opcjonalnie. Dostęp kończy się z końcem tego dnia w Twojej strefie czasowej.",
"guardianPendingNote": "Zaproszona osoba liczy się dopiero po przyjęciu zaproszenia. Do tego czasu wciąż jesteś ostatnia, a oddanie własnego dostępu nadal będzie odrzucane.",
"guardianErrorDuplicate": "To konto ma już dostęp do tej karty.",
"guardianErrorManagedInvitee": "Zarządzany profil nie może opiekować się inną kartą. Zaproś konto, które samo się loguje.",
"guardianErrorNoAccount": "W tej instancji nie ma konta o takiej nazwie ani takim adresie e-mail.",
"guardianErrorRateLimit": "Zbyt wiele zaproszeń w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.",
"guardianErrorFailed": "Nie udało się wysłać zaproszenia."
},
"toast": {
"savedTo": "Zapisano w karcie użytkownika {name}."
@@ -106,6 +106,14 @@ const rosterRef: { value: ManagedProfileGuardian[] | undefined } = {
};
/** The deletion mutation, idle unless a case says otherwise. */
const deleteRef = { value: idle() };
/** The guardian invitation, idle unless a case says otherwise. */
const inviteRef: { value: FakeMutation } = { value: idle() };
/** The guardian removal, whose `variables` name a profile and a grant. */
const removeGuardianRef: {
value: Omit<FakeMutation, "variables"> & {
variables: { grantId: string } | undefined;
};
} = { value: { ...idle(), variables: undefined } };
vi.mock("@/lib/queries/use-managed-profiles", async (importOriginal) => {
const actual =
@@ -114,6 +122,8 @@ vi.mock("@/lib/queries/use-managed-profiles", async (importOriginal) => {
...actual,
useCreateManagedProfile: () => idle(),
useDeleteManagedProfile: () => deleteRef.value,
useInviteManagedProfileGuardian: () => inviteRef.value,
useRemoveManagedProfileGuardian: () => removeGuardianRef.value,
useManagedProfileGuardians: () => ({
data: rosterRef.value,
isLoading: false,
@@ -173,6 +183,7 @@ import {
deleteProfileBody,
managedProfilesOf,
} from "../managed-profile-card";
import { guardianInviteErrorKey } from "../managed-profile-guardians";
function render(node: React.ReactNode): string {
return renderToStaticMarkup(
@@ -205,10 +216,17 @@ function entry(partial: Partial<AccountAccessEntry> = {}): AccountAccessEntry {
beforeEach(() => {
authRef.value = { accounts: [], active: null };
rosterRef.value = [guardian()];
// The caller is always an active Guardian of the profile: it is the only way
// to have the roster at all.
rosterRef.value = [guardian({ account: SELF })];
deleteRef.value = idle();
inviteRef.value = idle();
removeGuardianRef.value = { ...idle(), variables: undefined };
});
/** The signed-in account, as the roster names it. */
const SELF = { id: "guardian", username: "guardian", displayName: "You" };
describe("the managed-profile card, in the shared-access section", () => {
it("renders inside the section, after the invitation card", () => {
const html = renderGatedSection();
@@ -477,6 +495,220 @@ describe("what the confirmation says before it deletes", () => {
});
});
describe("managing the guardians of a profile", () => {
const OTHER = { id: "other", username: "housemate", displayName: "Jo" };
beforeEach(() => {
authRef.value = { accounts: [entry()], active: null };
});
it("renders one row per guardian and says which of them is you", () => {
rosterRef.value = [
guardian({ account: SELF }),
guardian({ grantId: "g2", account: OTHER }),
];
const html = renderGatedSection();
expect(html.match(/data-slot="managed-profile-guardian"/g)).toHaveLength(2);
expect(html).toContain('data-guardian-grant-id="g2"');
expect(html).toContain("(you)");
});
it("states the floor before the act when nobody else has accepted", () => {
// The whole point of the roster read. Discovering this as a 409 after
// inviting somebody is the state the release goal asks us to end.
rosterRef.value = [
guardian({ account: SELF }),
guardian({
grantId: "g2",
account: OTHER,
state: "PENDING",
acceptedAt: null,
}),
];
const html = renderGatedSection();
expect(html).toContain('data-slot="managed-profile-sole-guardian"');
expect(html).toContain("only person looking after");
// And it names all three ways forward rather than only refusing.
expect(html).toContain("wait for them to accept");
expect(html).toContain("Records I can open");
expect(html).toContain("delete the profile");
});
it("stops stating the floor once a second guardian has accepted", () => {
rosterRef.value = [
guardian({ account: SELF }),
guardian({ grantId: "g2", account: OTHER }),
];
expect(renderGatedSection()).not.toContain(
'data-slot="managed-profile-sole-guardian"',
);
});
it("offers no control to end one's own access, and says where that lives", () => {
// `revokeManagedProfileGuardian` refuses a target grant whose grantee is
// the caller, and the route answers the same 404 an unknown profile gets.
// A self-removal button would be one that always fails.
rosterRef.value = [guardian({ account: SELF })];
const html = renderGatedSection();
expect(html).not.toContain('data-slot="managed-profile-guardian-remove"');
expect(html).toContain('data-slot="managed-profile-sole-guardian"');
});
it("offers a confirmed removal on another guardian's row", () => {
rosterRef.value = [
guardian({ account: SELF }),
guardian({ grantId: "g2", account: OTHER }),
];
const html = renderGatedSection();
expect(
html.match(/data-slot="managed-profile-guardian-remove"/g),
).toHaveLength(1);
expect(html).toMatch(
/data-slot="managed-profile-guardian-remove"[^>]*class="[^"]*min-h-11/,
);
});
it("offers to withdraw an invitation that has not been accepted", () => {
// A pending invitation confers nothing, so withdrawing it can never
// strand the record and the control is not withheld.
rosterRef.value = [
guardian({ account: SELF }),
guardian({
grantId: "g2",
account: OTHER,
state: "PENDING",
acceptedAt: null,
}),
];
const html = renderGatedSection();
expect(
html.match(/data-slot="managed-profile-guardian-remove"/g),
).toHaveLength(1);
});
it("says an invitation only counts once it is accepted", () => {
const html = renderGatedSection();
expect(html).toContain('data-slot="managed-profile-pending-note"');
expect(html).toContain("counts only once they accept");
// In document order the sentence sits ahead of the control it qualifies.
expect(
html.indexOf('data-slot="managed-profile-pending-note"'),
).toBeLessThan(html.indexOf('data-slot="managed-profile-guardian-submit"'));
});
it("announces a refused removal on the guardian row it belongs to", () => {
rosterRef.value = [
guardian({ account: SELF }),
guardian({ grantId: "g2", account: OTHER }),
guardian({ grantId: "g3", account: { ...OTHER, id: "third" } }),
];
removeGuardianRef.value = {
...idle(),
isError: true,
error: new ApiError("refused", 409, {
errorCode: "managed_profile.guardian.required",
}),
variables: { grantId: "g2" },
};
const html = renderGatedSection();
expect(html).toContain('data-grant-error-for="g2"');
expect(html).not.toContain('data-grant-error-for="g3"');
// The floor, still handled as a refusal even though the panel states it
// in advance: the roster can be stale by the time the click lands.
expect(html).toContain("Add another guardian first");
});
it("announces a refused invitation on the form, without clearing it", () => {
inviteRef.value = failed(
new ApiError("refused", 409, {
errorCode: "managed_profile.guardian.duplicate",
}),
"ignored",
);
const html = renderGatedSection();
expect(html).toContain('data-slot="managed-profile-guardian-error"');
expect(html).toContain("already has access to this record");
expect(html).toContain('data-slot="managed-profile-guardian-identifier"');
});
});
describe("what a refused guardian invitation says", () => {
const cases: Array<{ err: unknown; key: string; why: string }> = [
{
err: new ApiError("refused", 409, {
errorCode: "managed_profile.guardian.duplicate",
}),
key: "recordSharing.managed.guardianErrorDuplicate",
why: "that account already has access — which is also what inviting yourself answers, because the grantor is the profile and never the caller",
},
{
err: new ApiError("refused", 422, {
errorCode: "managed_profile.guardian.managed_invitee",
}),
key: "recordSharing.managed.guardianErrorManagedInvitee",
why: "a managed profile cannot look after another record",
},
{
err: new ApiError("refused", 404, {
errorCode: "managed_profile.not_found",
}),
key: "recordSharing.actionError.profileNotFound",
why: "a 404 WITH a code — not the same as an unknown identifier",
},
{
err: new ApiError("refused", 404),
key: "recordSharing.managed.guardianErrorNoAccount",
why: "the codeless 404: no account carries that name or e-mail",
},
{
err: new ApiError("refused", 429),
key: "recordSharing.managed.guardianErrorRateLimit",
why: "the other codeless arm, where waiting is the whole recovery",
},
{
err: new ApiError("refused", 401, {
errorCode: "auth.stepup.required",
}),
key: "recordSharing.managed.errorStepUp",
why: "the step-up gate every route in this family carries",
},
{
err: new ApiError("refused", 500),
key: "recordSharing.managed.guardianErrorFailed",
why: "an unclassified failure still says which act failed",
},
{
err: new TypeError("Failed to fetch"),
key: "recordSharing.managed.errorOffline",
why: "never reached the server, so nothing was invited",
},
];
for (const { err, key, why } of cases) {
it(why, () => {
expect(guardianInviteErrorKey(err)).toBe(key);
});
}
it("tells the two 404s apart, which is the whole reason for the order", () => {
// The route publishes a code on one 404 and not on the other. Branching on
// status first would tell somebody to check a spelling when the real
// answer is that they are no longer a Guardian of that profile.
expect(
guardianInviteErrorKey(
new ApiError("refused", 404, {
errorCode: "managed_profile.not_found",
}),
),
).not.toBe(guardianInviteErrorKey(new ApiError("refused", 404)));
// And all nine cases above resolve to distinct sentences rather than one:
// the failure this table is written against is one message for everything.
expect(
new Set(cases.map(({ err }) => guardianInviteErrorKey(err))).size,
).toBe(cases.length);
});
});
describe("which accounts count as profiles looked after", () => {
it("keeps the managed entries and drops the rest", () => {
const managed = entry();
@@ -7,6 +7,7 @@ import {
grantActionErrorKey,
} from "@/components/settings/access/grant-action-error";
import { ManagedProfileCreateForm } from "@/components/settings/access/managed-profile-create-form";
import { ManagedProfileGuardians } from "@/components/settings/access/managed-profile-guardians";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConfirmButton } from "@/components/ui/confirm-button";
@@ -170,6 +171,13 @@ function ManagedProfileRow({
onRetry={() => remove.mutate(profile.accountId)}
/>
)}
{guardians.data && (
<ManagedProfileGuardians
profileId={profile.accountId}
profileName={name}
roster={guardians.data}
/>
)}
</li>
);
}
@@ -0,0 +1,340 @@
"use client";
import { useState } from "react";
import {
GrantActionAlert,
grantActionErrorKey,
} from "@/components/settings/access/grant-action-error";
import { endOfDayIso } from "@/components/settings/access/grant-invite-card";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/hooks/use-auth";
import { ApiError } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useInviteManagedProfileGuardian,
useRemoveManagedProfileGuardian,
type ManagedProfileGuardian,
} from "@/lib/queries/use-managed-profiles";
import { accountLabel } from "@/lib/sharing/account-access-view";
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
/**
* v1.37.0 — who else looks after this record, and how that changes.
*
* ## The floor is stated, and still enforced
*
* A managed profile may never be left with nobody looking after it. The server
* enforces that by refusing the removal (409
* `managed_profile.guardian.required`) and by counting only ACCEPTED,
* unexpired MANAGE grants — `activeGuardianWhere`. This panel says the same
* thing BEFORE the click, from the roster it is already showing, because
* discovering a floor as a refusal after inviting somebody is the state the
* release goal asks us to end.
*
* The pre-emptive statement is a courtesy and not the enforcement. The roster
* can be seconds stale, another Guardian can renounce between the render and
* the click, and the server is the only authority — so the 409 arm stays wired
* through `grant-action-error.tsx` regardless. A surface that hid the control
* AND dropped the refusal handler would fail silently the one time it matters.
*
* ## Why nobody can end their own access here
*
* `revokeManagedProfileGuardian` refuses a target grant whose grantee is the
* caller, and the route turns that into the same 404 an unknown profile gets.
* So a self-removal control would be a button that always fails. The way for a
* Guardian to step away is the renounce control on "What I can open", which is
* the same act from the delegate's own side and already carries the
* last-Guardian refusal. The card says so rather than offering a dead control
* or leaving somebody to hunt for it.
*
* That also means the last-ACTIVE-Guardian case and the own-row case coincide:
* to see this roster at all you must be an active Guardian of the profile, so
* "one ACTIVE Guardian" always means "you". The sentence names all three ways
* forward — invite somebody who accepts, hand your own access back, or delete
* the profile.
*
* ## The pending truth
*
* `inviteManagedProfileGuardian` mints the grant PENDING, through the ordinary
* invitation flow, and the invitee accepts it on their own shared-access panel.
* Until they do, they do not count. A Guardian who invites somebody and then
* tries to leave is still refused, and no wording in the product said so before
* this.
*/
export function ManagedProfileGuardians({
profileId,
profileName,
roster,
}: {
profileId: string;
profileName: string;
/** The resolved roster. The caller withholds this panel until it arrives. */
roster: ManagedProfileGuardian[];
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
const invite = useInviteManagedProfileGuardian();
const remove = useRemoveManagedProfileGuardian();
const [identifier, setIdentifier] = useState("");
const [expiresOn, setExpiresOn] = useState("");
const [invited, setInvited] = useState<string | null>(null);
const timezone = isValidTimezone(user?.timezone ?? "")
? (user?.timezone as string)
: DEFAULT_TIMEZONE;
const active = roster.filter((g) => g.state === "ACTIVE");
const soleGuardian = active.length <= 1;
const submit = (event: React.FormEvent) => {
event.preventDefault();
const value = identifier.trim();
if (value.length === 0 || invite.isPending) return;
setInvited(null);
invite.mutate(
{
profileId,
identifier: value,
expiresAt: endOfDayIso(expiresOn, timezone),
},
{
onSuccess: () => {
setIdentifier("");
setExpiresOn("");
setInvited(value);
},
},
);
};
return (
<div data-slot="managed-profile-guardians" className="space-y-3">
<h3 className="text-sm font-medium">
{t("recordSharing.managed.guardiansTitle", { name: profileName })}
</h3>
<ul data-slot="managed-profile-guardian-list" className="divide-y">
{roster.map((guardian) => {
const isSelf = guardian.account.id === user?.id;
// A PENDING invitation can always be withdrawn: it confers nothing,
// so ending it cannot strand the record. The floor only ever bites
// on an ACTIVE row, and the only ACTIVE row that could be the last
// one is the caller's own — which the route refuses anyway.
const removable = !isSelf;
const failed =
remove.isError && remove.variables?.grantId === guardian.grantId;
return (
<li
key={guardian.grantId}
data-slot="managed-profile-guardian"
data-guardian-grant-id={guardian.grantId}
data-guardian-state={guardian.state}
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">
<p className="truncate text-sm font-medium">
{accountLabel(guardian.account)}
{isSelf ? ` ${t("recordSharing.managed.guardianYou")}` : ""}
</p>
<p className="text-muted-foreground truncate text-xs">
{guardian.state === "ACTIVE" && guardian.acceptedAt
? t("recordSharing.managed.guardianSince", {
date: fmt.dateTime(new Date(guardian.acceptedAt)),
})
: t("recordSharing.managed.guardianPending", {
date: fmt.dateTime(new Date(guardian.invitedAt)),
})}
</p>
</div>
{removable && (
<div className="flex shrink-0 items-center">
<ConfirmButton
slot="managed-profile-guardian-remove"
size="sm"
className="min-h-11 sm:min-h-9"
label={t("recordSharing.managed.guardianRemove")}
title={t("recordSharing.managed.guardianRemoveTitle", {
name: accountLabel(guardian.account),
})}
body={t(
guardian.state === "PENDING"
? "recordSharing.managed.guardianWithdrawBody"
: "recordSharing.managed.guardianRemoveBody",
{
name: accountLabel(guardian.account),
profile: profileName,
},
)}
confirmLabel={t(
"recordSharing.managed.guardianRemoveConfirm",
)}
pending={remove.isPending}
onConfirm={() =>
remove.mutate({
profileId,
grantId: guardian.grantId,
})
}
/>
</div>
)}
</div>
{failed && (
<GrantActionAlert
grantId={guardian.grantId}
message={t(
grantActionErrorKey(remove.error, "removeGuardian"),
)}
retrying={remove.isPending}
onRetry={() =>
remove.mutate({ profileId, grantId: guardian.grantId })
}
/>
)}
</li>
);
})}
</ul>
{soleGuardian && (
// Content, not meta: it is the reason a control somebody is looking
// for is not on the screen.
<p data-slot="managed-profile-sole-guardian" className="text-sm">
{t("recordSharing.managed.soleGuardian", { name: profileName })}
</p>
)}
<form
onSubmit={submit}
data-slot="managed-profile-guardian-invite"
className="space-y-3"
>
<div className="space-y-1.5">
<Label htmlFor={`managed-guardian-identifier-${profileId}`}>
{t("recordSharing.managed.guardianInviteLabel")}
</Label>
<Input
id={`managed-guardian-identifier-${profileId}`}
data-slot="managed-profile-guardian-identifier"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder={t("recordSharing.invite.identifierPlaceholder")}
autoComplete="off"
maxLength={255}
/>
<p className="text-muted-foreground text-xs">
{t("recordSharing.invite.identifierHint")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor={`managed-guardian-expires-${profileId}`}>
{t("recordSharing.invite.expiresLabel")}
</Label>
<DateField
id={`managed-guardian-expires-${profileId}`}
data-slot="managed-profile-guardian-expires"
data-testid={`managed-guardian-expires-input-${profileId}`}
value={expiresOn}
onChange={setExpiresOn}
aria-describedby={`managed-guardian-expires-hint-${profileId}`}
/>
<p
id={`managed-guardian-expires-hint-${profileId}`}
className="text-muted-foreground text-xs"
>
{t("recordSharing.managed.guardianExpiresHint")}
</p>
</div>
{/* The truth the routes enforce and no wording stated. It sits ABOVE
the button, where it is part of the decision, rather than below it
where it would be a disclaimer. */}
<p data-slot="managed-profile-pending-note" className="text-sm">
{t("recordSharing.managed.guardianPendingNote")}
</p>
<Button
type="submit"
data-slot="managed-profile-guardian-submit"
disabled={invite.isPending || identifier.trim().length === 0}
>
{invite.isPending
? t("recordSharing.managed.guardianInviting")
: t("recordSharing.managed.guardianInvite")}
</Button>
{invite.isError && (
<p
role="alert"
data-slot="managed-profile-guardian-error"
className="text-destructive text-sm"
>
{t(guardianInviteErrorKey(invite.error))}
</p>
)}
{invited && (
<p
role="status"
data-slot="managed-profile-guardian-invited"
className="text-sm"
>
{t("recordSharing.managed.guardianInvited", { name: invited })}
</p>
)}
</form>
</div>
);
}
/**
* The message for a refused Guardian invitation.
*
* Six refusals, and **two of them carry no `errorCode`** — the rate limit
* (`guardians/route.ts`, before the body is even read) and the unknown-account
* 404. That asymmetry is the route's actual shape, not laziness here: the
* other four arms publish a stable code and are matched on it, and these two
* have to be told apart by status because there is nothing else to branch on.
* Recorded in `02-13.1-CONTRACT-FINDINGS.md` so whoever freezes the contract
* decides deliberately whether to close it.
*
* The code switch runs FIRST, and the order matters: `managed_profile.not_found`
* is a 404 WITH a code, and an unknown account is a 404 without one. Branching
* on status first would tell somebody to check the spelling of a name when the
* real answer is that they are not a Guardian of that profile any more.
*/
export function guardianInviteErrorKey(err: unknown): string {
if (!(err instanceof ApiError)) return "recordSharing.managed.errorOffline";
const code =
typeof err.meta?.errorCode === "string" ? err.meta.errorCode : null;
switch (code) {
// There is deliberately NO arm for `managed_profile.guardian.self`. The
// route declares one, and it cannot fire: `self_grant` needs the grantor
// to equal the grantee, and the grantor here is the profile rather than
// the caller — while naming the profile itself is caught one line earlier
// as a managed invitee. Inviting yourself arrives as `duplicate`, which is
// both true and the useful sentence. A client arm for a code the server
// cannot send would be a branch nothing can reach.
case "managed_profile.guardian.duplicate":
return "recordSharing.managed.guardianErrorDuplicate";
case "managed_profile.guardian.managed_invitee":
return "recordSharing.managed.guardianErrorManagedInvitee";
case "managed_profile.not_found":
return "recordSharing.actionError.profileNotFound";
default:
break;
}
// The two codeless arms. Waiting is the recovery for one and correcting the
// identifier is the recovery for the other, so they must not collapse.
if (err.status === 429) return "recordSharing.managed.guardianErrorRateLimit";
if (err.status === 404) return "recordSharing.managed.guardianErrorNoAccount";
// The step-up gate this whole family carries. Not a failure.
if (err.status === 401) return "recordSharing.managed.errorStepUp";
return "recordSharing.managed.guardianErrorFailed";
}
+83
View File
@@ -154,3 +154,86 @@ export function useDeleteManagedProfile() {
onSuccess: invalidate,
});
}
/**
* Exactly the body `POST /api/managed-profiles/{id}/guardians` accepts, plus
* the profile the request is addressed to.
*
* The route's schema is `.strict()` and names two fields. `identifier` is the
* invitee's username OR e-mail, matched case-insensitively — the same
* identifier the ordinary grant invitation takes. `expiresAt` is an ISO
* datetime WITH an offset: a bare `YYYY-MM-DD` is a 422, which is why the
* caller runs it through the shared end-of-day helper rather than sending the
* date field's raw value.
*/
export interface InviteManagedProfileGuardianInput {
profileId: string;
identifier: string;
expiresAt: string | null;
}
/**
* Offer somebody else a share in looking after this profile.
*
* The grant is created PENDING and reaches the invitee's ordinary "invitations
* to you" card, where they accept it exactly as they would any other. Nothing
* here accepts on their behalf, and until they accept they do not count toward
* the last-Guardian floor.
*/
export function useInviteManagedProfileGuardian() {
const invalidate = useInvalidateManagedProfiles();
return useMutation({
mutationKey: queryKeys.managedProfileGuardianInvite(),
mutationFn: (input: InviteManagedProfileGuardianInput) =>
apiPost(
`/api/managed-profiles/${input.profileId}/guardians`,
guardianInviteBody(input),
),
onSuccess: invalidate,
});
}
/**
* The wire body, composed once and exported.
*
* Pure and separate from the mutation so the integration suite can post the
* REAL body through the REAL route rather than a hand-written copy of it. A
* hand-written copy is the shape of test that stays green while the client
* renames a field: both ends prove themselves and the assembly between them is
* what dropped it.
*
* `profileId` addresses the request and is deliberately not in the body: the
* route's schema is `.strict()` and would refuse it.
*/
export function guardianInviteBody(input: InviteManagedProfileGuardianInput): {
identifier: string;
expiresAt: string | null;
} {
return { identifier: input.identifier, expiresAt: input.expiresAt };
}
/**
* End another Guardian's access.
*
* Another Guardian's, never one's own: the service refuses a target grant whose
* grantee is the caller and the route turns that into the same 404 an unknown
* profile gets. A Guardian steps away through the renounce control on the
* shared-access panel instead.
*
* `variables` carries both ids so a refusal can be rendered on the guardian row
* it belongs to rather than on the panel.
*/
export function useRemoveManagedProfileGuardian() {
const invalidate = useInvalidateManagedProfiles();
return useMutation({
mutationKey: queryKeys.managedProfileGuardianRemove(),
mutationFn: ({
profileId,
grantId,
}: {
profileId: string;
grantId: string;
}) => apiDelete(`/api/managed-profiles/${profileId}/guardians/${grantId}`),
onSuccess: invalidate,
});
}
@@ -122,6 +122,63 @@ async function listGuardians(profileId: string) {
);
}
/** `POST /api/managed-profiles`, with the body the card composes. */
async function createProfile(body: unknown) {
const { POST } = await import("@/app/api/managed-profiles/route");
return POST(
new NextRequest("http://localhost/api/managed-profiles", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}
async function deleteProfile(profileId: string) {
const { DELETE } = await import("@/app/api/managed-profiles/[id]/route");
return DELETE(
new NextRequest(`http://localhost/api/managed-profiles/${profileId}`, {
method: "DELETE",
}),
{ params: Promise.resolve({ id: profileId }) },
);
}
async function inviteGuardian(
profileId: string,
body: { identifier: string; expiresAt?: string | null },
) {
const { POST } =
await import("@/app/api/managed-profiles/[id]/guardians/route");
return POST(
new NextRequest(
`http://localhost/api/managed-profiles/${profileId}/guardians`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
),
{ params: Promise.resolve({ id: profileId }) },
);
}
async function removeGuardian(profileId: string, grantId: string) {
const { DELETE } =
await import("@/app/api/managed-profiles/[id]/guardians/[grantId]/route");
return DELETE(
new NextRequest(
`http://localhost/api/managed-profiles/${profileId}/guardians/${grantId}`,
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: profileId, grantId }) },
);
}
async function errorCodeOf(response: Response): Promise<unknown> {
return (await response.json()).meta?.errorCode;
}
/** A profile with its creator as sole Guardian, plus a pending second one. */
async function profileWithPendingSecondGuardian() {
const creator = await person("creator");
@@ -311,3 +368,360 @@ describe("who looks after a managed profile (real Postgres)", () => {
expect((await listGuardians(profile.id)).status).toBe(401);
});
});
describe("every call the managed-profile card can make", () => {
it("creates a profile from exactly the body the form composes", async () => {
const creator = await person("creator");
signIn(creator);
const response = await createProfile({
displayName: "Managed record",
dateOfBirth: null,
locale: "de",
timezone: "Europe/Zurich",
});
expect(response.status).toBe(201);
const created = (await response.json()).data;
expect(created.recordKind).toBe("managed");
// The creator is its Guardian, accepted, from the same transaction — which
// is why the card can read the profile off the account payload rather than
// from a list endpoint that does not exist.
signIn(creator);
const roster = (await (await listGuardians(created.id)).json())
.data as Array<{ state: string; account: { id: string } }>;
expect(roster).toEqual([
expect.objectContaining({
state: "ACTIVE",
account: expect.objectContaining({ id: creator.id }),
}),
]);
});
it("refuses a body carrying a field the form does not collect", async () => {
// The schema is `.strict()`. This is the reason the client names its four
// fields one at a time instead of spreading its form state: a helpful
// extra key is a 422 about something nobody filled in.
signIn(await person("creator"));
const response = await createProfile({
displayName: "Managed record",
locale: "en",
timezone: "UTC",
heightCm: 120,
});
expect(response.status).toBe(422);
});
it("refuses a display name that is only whitespace", async () => {
// The bound the form mirrors before it sends: the route trims first.
signIn(await person("creator"));
expect(
(
await createProfile({
displayName: " ",
locale: "en",
timezone: "UTC",
})
).status,
).toBe(422);
});
it("deletes a profile, and answers a second attempt with the same 404", async () => {
const { creator, profile } = await profileWithPendingSecondGuardian();
signIn(creator);
expect((await deleteProfile(profile.id)).status).toBe(200);
expect(
await getPrismaClient().user.findUnique({ where: { id: profile.id } }),
).toBeNull();
const second = await deleteProfile(profile.id);
expect(second.status).toBe(404);
expect(await errorCodeOf(second)).toBe("managed_profile.not_found");
});
it("refuses deletion by somebody who is not a Guardian", async () => {
const { profile } = await profileWithPendingSecondGuardian();
signIn(await person("stranger"));
const response = await deleteProfile(profile.id);
expect(response.status).toBe(404);
expect(await errorCodeOf(response)).toBe("managed_profile.not_found");
// And the record is still there.
expect(
await getPrismaClient().user.findUnique({ where: { id: profile.id } }),
).not.toBeNull();
});
it("invites a second Guardian as PENDING, with the offset expiry the form sends", async () => {
const creator = await person("creator");
const invitee = await person("invitee");
signIn(creator);
const { profile } = await createManagedProfile({
creatorId: creator.id,
displayName: "Managed record",
dateOfBirth: null,
locale: "en",
timezone: "UTC",
});
// The shape `endOfDayIso` produces: an ISO instant, offset-bearing. A bare
// `YYYY-MM-DD` is a 422 here, which is why the client never sends the date
// field's raw value.
const expiresAt = new Date(Date.now() + 86_400_000).toISOString();
const response = await inviteGuardian(profile.id, {
identifier: invitee.username,
expiresAt,
});
expect(response.status).toBe(201);
const grant = (await response.json()).data;
expect(grant.acceptedAt).toBeNull();
expect(new Date(grant.expiresAt).toISOString()).toBe(expiresAt);
// PENDING, and therefore not counted by the floor — which is the sentence
// the card states before anybody tries to leave.
const roster = (await (await listGuardians(profile.id)).json())
.data as Array<{ state: string }>;
expect(roster.filter((r) => r.state === "ACTIVE")).toHaveLength(1);
expect(roster.filter((r) => r.state === "PENDING")).toHaveLength(1);
});
it("accepts the body the client actually composes, field names and all", async () => {
// Both ends and the pipe between them. The route's schema is `.strict()`,
// so a client that renamed `identifier` would be refused with a 422 — and
// a test that posted a hand-written copy of the body would stay green
// through exactly that rename. This posts the composer's own output.
const { guardianInviteBody } =
await import("@/lib/queries/use-managed-profiles");
const creator = await person("creator");
const invitee = await person("invitee");
signIn(creator);
const { profile } = await createManagedProfile({
creatorId: creator.id,
displayName: "Managed record",
dateOfBirth: null,
locale: "en",
timezone: "UTC",
});
const body = guardianInviteBody({
profileId: profile.id,
identifier: invitee.username,
expiresAt: null,
});
// Non-zero proof, deliberately name-agnostic: an empty body would be
// refused for the wrong reason and would make the 201 below prove nothing.
// The NAMES are proved by the route accepting it, not by a second copy of
// them written here.
expect(Object.keys(body)).toHaveLength(2);
const response = await inviteGuardian(
profile.id,
body as { identifier: string; expiresAt: string | null },
);
expect(response.status).toBe(201);
expect((await response.json()).data.acceptedAt).toBeNull();
});
it("refuses a bare calendar date as an expiry", async () => {
const creator = await person("creator");
const invitee = await person("invitee");
signIn(creator);
const { profile } = await createManagedProfile({
creatorId: creator.id,
displayName: "Managed record",
dateOfBirth: null,
locale: "en",
timezone: "UTC",
});
expect(
(
await inviteGuardian(profile.id, {
identifier: invitee.username,
expiresAt: "2027-01-30",
})
).status,
).toBe(422);
});
it("answers an invitation addressed to yourself with the duplicate refusal", async () => {
// NOT `managed_profile.guardian.self`, and the plan predicted otherwise.
// `self_grant` fires when the grantor equals the grantee, and the grantor
// here is the PROFILE — never the caller. So inviting yourself is simply
// an account that already has access, which is what the person is told,
// and the route's 422 self arm cannot be reached. Recorded as a finding;
// the client has no arm for a code the server cannot send.
const { creator, profile } = await profileWithPendingSecondGuardian();
signIn(creator);
const response = await inviteGuardian(profile.id, {
identifier: creator.username,
});
expect(response.status).toBe(409);
expect(await errorCodeOf(response)).toBe(
"managed_profile.guardian.duplicate",
);
});
it("cannot be made to answer the self-invitation 422 either", async () => {
// The other door to `self_grant` is naming the profile itself, and that
// one is closed by the managed-invitee check one line earlier. Both paths
// are asserted so the arm is known to be dead rather than assumed to be.
const { creator, profile } = await profileWithPendingSecondGuardian();
signIn(creator);
const response = await inviteGuardian(profile.id, {
identifier: profile.username,
});
expect(response.status).toBe(422);
expect(await errorCodeOf(response)).toBe(
"managed_profile.guardian.managed_invitee",
);
});
it("refuses an invitation to somebody who already has access", async () => {
const { creator, invitee, profile } =
await profileWithPendingSecondGuardian();
signIn(creator);
const response = await inviteGuardian(profile.id, {
identifier: invitee.username,
});
expect(response.status).toBe(409);
expect(await errorCodeOf(response)).toBe(
"managed_profile.guardian.duplicate",
);
});
it("refuses a managed profile as a Guardian", async () => {
const { creator, profile } = await profileWithPendingSecondGuardian();
const { profile: other } = await createManagedProfile({
creatorId: creator.id,
displayName: "Second record",
dateOfBirth: null,
locale: "en",
timezone: "UTC",
});
signIn(creator);
const response = await inviteGuardian(profile.id, {
identifier: other.username,
});
expect(response.status).toBe(422);
expect(await errorCodeOf(response)).toBe(
"managed_profile.guardian.managed_invitee",
);
});
it("answers an unknown identifier with a 404 carrying NO error code", async () => {
// The asymmetry the client has to branch on status for. Four arms of this
// route publish a code; this one and the rate limit do not.
const { creator, profile } = await profileWithPendingSecondGuardian();
signIn(creator);
const response = await inviteGuardian(profile.id, {
identifier: "nobody-here",
});
expect(response.status).toBe(404);
expect(await errorCodeOf(response)).toBeUndefined();
});
it("refuses an invitation from somebody who is not a Guardian", async () => {
const { profile, invitee } = await profileWithPendingSecondGuardian();
signIn(await person("stranger"));
const response = await inviteGuardian(profile.id, {
identifier: invitee.username,
});
expect(response.status).toBe(404);
expect(await errorCodeOf(response)).toBe("managed_profile.not_found");
});
it("ends another Guardian's access and clears their switched sessions", async () => {
const { creator, invitee, profile, invitation } =
await profileWithPendingSecondGuardian();
await acceptGrant({ grantId: invitation.id, granteeId: invitee.id });
signIn(creator);
const response = await removeGuardian(profile.id, invitation.id);
expect(response.status).toBe(200);
expect(
await getPrismaClient().accountGrant.findUniqueOrThrow({
where: { id: invitation.id },
}),
).toMatchObject({ revokedBy: "GRANTOR" });
// And the roster the card re-reads no longer carries them.
const roster = (await (await listGuardians(profile.id)).json())
.data as unknown[];
expect(roster).toHaveLength(1);
});
it("withdraws a pending invitation without touching the accepted count", async () => {
const { creator, profile, invitation } =
await profileWithPendingSecondGuardian();
signIn(creator);
expect((await removeGuardian(profile.id, invitation.id)).status).toBe(200);
const roster = (await (await listGuardians(profile.id)).json())
.data as Array<{ state: string }>;
expect(roster).toHaveLength(1);
expect(roster[0].state).toBe("ACTIVE");
});
it("refuses a Guardian ending their OWN access, and leaves the grant alone", async () => {
// This is the shape the card's missing self-removal control comes from.
// `revokeManagedProfileGuardian` refuses a target whose grantee is the
// caller, and the route turns that into `managed_profile.not_found` — NOT
// into the last-Guardian 409. A self-removal button would therefore be one
// that always fails, with a message that does not describe what happened.
// Stepping away is the renounce control on the shared-access panel.
const { creator, profile } = await profileWithPendingSecondGuardian();
const own = await getPrismaClient().accountGrant.findFirstOrThrow({
where: { grantorId: profile.id, granteeId: creator.id },
});
signIn(creator);
const response = await removeGuardian(profile.id, own.id);
expect(response.status).toBe(404);
expect(await errorCodeOf(response)).toBe("managed_profile.not_found");
expect(
await getPrismaClient().accountGrant.findUniqueOrThrow({
where: { id: own.id },
}),
).toMatchObject({ revokedAt: null, revokedBy: null });
});
it("refuses removal by somebody who is not a Guardian of the profile", async () => {
const { profile, invitation } = await profileWithPendingSecondGuardian();
signIn(await person("stranger"));
const response = await removeGuardian(profile.id, invitation.id);
expect(response.status).toBe(404);
expect(await errorCodeOf(response)).toBe("managed_profile.not_found");
});
it("cannot be made to answer the last-Guardian 409 through this route", async () => {
// Recorded as a property rather than as a gap in coverage. The refusal
// fires only when the TARGET is active and the accepted count is at most
// one — and the caller must be an accepted Guardian to get this far, and
// may not target themselves. Two accepted Guardians is therefore the
// minimum for the branch to be evaluated at all, and two is never one.
//
// The floor is real; it is enforced on the renounce route, on account
// deletion and on a data wipe. It is this ROUTE that cannot express it,
// which is why the card states the floor from the roster instead of
// waiting for a refusal that will not come.
const { creator, invitee, profile, invitation } =
await profileWithPendingSecondGuardian();
await acceptGrant({ grantId: invitation.id, granteeId: invitee.id });
signIn(creator);
// Two accepted Guardians: the removal is allowed rather than refused.
expect((await removeGuardian(profile.id, invitation.id)).status).toBe(200);
// And with one left, the only target available to that Guardian is
// themselves — which is the 404 above, not the 409.
const own = await getPrismaClient().accountGrant.findFirstOrThrow({
where: { grantorId: profile.id, granteeId: creator.id },
});
const self = await removeGuardian(profile.id, own.id);
expect(self.status).toBe(404);
expect(await errorCodeOf(self)).not.toBe(
"managed_profile.guardian.required",
);
});
});