diff --git a/apps/backstage/src/components/member-home.tsx b/apps/backstage/src/components/member-home.tsx
index 2bbf95c6..a6bee5d5 100644
--- a/apps/backstage/src/components/member-home.tsx
+++ b/apps/backstage/src/components/member-home.tsx
@@ -15,6 +15,7 @@ import { useActivitiesByTerm } from "../features/activities/hooks/use-activities
import { useInitiativesByTerm } from "../features/initiatives/hooks/use-initiatives-by-term";
import { usePositions } from "../features/positions/hooks/use-positions";
import { joinYear } from "../features/members/lib/member-display";
+import { isSelfMember } from "../features/members/lib/member-permissions";
import { summarizeParticipations } from "../features/members/lib/participation-summary";
import { MemberPointsSummary } from "../features/members/components/member-points-summary";
import { MemberCredentialCard } from "../features/members/components/member-credential-card";
@@ -92,7 +93,7 @@ export function MemberHome() {
// at a role, so the honest mirror is doc ownership — NOT the CASL own-doc grant, which
// only members carrying the built-in Member role hold (a roles:["Treasury"] principal
// would lose a self-edit the rules would have accepted).
- const canEditSelf = member.uid !== undefined && member.uid === uid;
+ const canEditSelf = isSelfMember(member, uid);
const cargoId = member.positions?.[termId]?.cargoId ?? null;
const cargo = cargoId ? positionsById.get(cargoId) : null;
diff --git a/apps/backstage/src/features/members/components/member-drawer.test.tsx b/apps/backstage/src/features/members/components/member-drawer.test.tsx
index 46656f78..b6fa3685 100644
--- a/apps/backstage/src/features/members/components/member-drawer.test.tsx
+++ b/apps/backstage/src/features/members/components/member-drawer.test.tsx
@@ -10,6 +10,15 @@ vi.mock("@tanstack/react-router", () => ({
Link: ({ children }: { children: React.ReactNode }) => {children},
}));
+// The drawer now reads the caller's uid to decide whether the row it opened is the caller's
+// OWN (the members table lists it too), which makes the edit a SELF-assignment. Mocked as a
+// factory with no `importOriginal`: lib/auth/auth builds its store from getFirebase().auth at
+// module scope, so merely evaluating the real module initializes Firebase and the whole file
+// fails to collect. uid "someone-else" keeps every case below a non-self edit.
+vi.mock("../../../lib/auth/auth", () => ({
+ useAuth: () => ({ user: { uid: "someone-else" }, claims: { roles: ["Admin"] } }),
+}));
+
const m: Member = {
id: "1",
name: "Ana Gómez",
diff --git a/apps/backstage/src/features/members/components/member-drawer.tsx b/apps/backstage/src/features/members/components/member-drawer.tsx
index 4dbbf5bd..a1e9fd29 100644
--- a/apps/backstage/src/features/members/components/member-drawer.tsx
+++ b/apps/backstage/src/features/members/components/member-drawer.tsx
@@ -14,7 +14,9 @@ import { MemberForm } from "./member-form";
import { joinYear, memberPositionLabel } from "../lib/member-display";
import { memberFormDefaults } from "../lib/member-form-defaults";
import { useMemberPhoto } from "../hooks/use-member-photo";
+import { isSelfMember } from "../lib/member-permissions";
import { Can } from "../../../lib/authz/ability-context";
+import { useAuth } from "../../../lib/auth/auth";
import { useCan } from "../../../lib/authz/use-can";
interface MemberDrawerProps {
@@ -136,7 +138,9 @@ function EditBody({
onSubmit: (data: MemberInput) => Promise;
}) {
const { onUpload, onRemove } = useMemberPhoto(member.id);
- const { canAssignBoardSeat } = useCan();
+ const { canAssignBoardSeat, isAdmin } = useCan();
+ // The table lists the caller's own row too, so this drawer can be a self-assignment.
+ const uid = useAuth().user?.uid;
return (
diff --git a/apps/backstage/src/features/members/components/member-form.test.tsx b/apps/backstage/src/features/members/components/member-form.test.tsx
index c2252593..9163e9b2 100644
--- a/apps/backstage/src/features/members/components/member-form.test.tsx
+++ b/apps/backstage/src/features/members/components/member-form.test.tsx
@@ -3,8 +3,36 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { MemberInput, Position } from "@luminova/types";
import { MemberForm } from "./member-form";
+import { cargoNoteIds } from "./no-assignable-cargos-note";
import { toMemberUpdateDoc } from "../repositories/member-mapper";
import { pickDate } from "../../../test/pick-date";
+import { permissionLabel } from "../../permissions/lib/permission-matrix";
+
+// Through the same helper the form calls, not a hand-typed literal: the ids are no longer
+// exported individually, and a test that re-typed one would keep passing after a rename.
+const MINT_PENDING_NOTE_ID = cargoNoteIds("member").mintPending;
+
+// The note names the permission through `permissionLabel`, and its own comment says the two
+// features must not drift. Assert against the same source, not a hardcoded copy — a literal
+// here would keep passing after either half of the label is renamed, which is exactly the
+// coupling the note is worried about.
+const BOARD_SEAT_LABEL = permissionLabel("update:BoardSeat");
+
+// The mint-pending note used to say "permisos de administrador", which was true only of its
+// one original trigger. It now fires for a SELF-assignment of any granting cargo, so copy
+// naming administrator permissions would be a lie in that case. Matched on the outcome half of
+// the sentence, which is the part both triggers share.
+const MINT_PENDING_COPY = /no se aplicarán hasta que un administrador confirme la asignación/i;
+
+// The four authority props are REQUIRED on the component (a call site that forgets one used to
+// compile clean and fail OPEN on `isSelfAssignment`). Spread FIRST in every render below so a
+// case that cares about one still just names it — the explicit prop wins.
+const FORM_AUTHORITY = {
+ allowPowerGrants: false,
+ allowReplacePowerCargo: false,
+ assignerIsAdmin: false,
+ isSelfAssignment: false,
+} as const;
const positions: Position[] = [
{
@@ -73,7 +101,9 @@ const inactiveCargoPosition: Position = {
describe("MemberForm", () => {
it("blocks submit and shows an error when required fields are empty", async () => {
const onSubmit = vi.fn();
- render();
+ render(
+ ,
+ );
await userEvent.click(screen.getByRole("button", { name: /crear/i }));
expect(await screen.findAllByText("Mínimo 3 caracteres.")).not.toHaveLength(0);
expect(onSubmit).not.toHaveBeenCalled();
@@ -81,7 +111,9 @@ describe("MemberForm", () => {
it("renders the gender toggle and requires it on submit", async () => {
const onSubmit = vi.fn();
- render();
+ render(
+ ,
+ );
expect(screen.getByRole("group", { name: "Género" })).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/nombre/i), "Ana Pérez");
await userEvent.type(screen.getByLabelText(/correo/i), "ana@jci.bo");
@@ -97,7 +129,13 @@ describe("MemberForm", () => {
// authority that renders them all.
it("shows gendered cargo labels and excludes comisiones from the cargo options", async () => {
render(
- ,
+ ,
);
await userEvent.click(screen.getByRole("button", { name: "Femenino" }));
await userEvent.click(screen.getByLabelText("Cargo"));
@@ -131,20 +169,27 @@ describe("MemberForm", () => {
];
const { unmount } = render(
,
);
- expect(screen.getByRole("note")).toHaveTextContent(/Asientos de directiva/);
+ expect(screen.getByRole("note")).toHaveTextContent(BOARD_SEAT_LABEL);
await userEvent.click(screen.getByLabelText("Cargo"));
expect(await screen.findByText("Sin resultados")).toBeInTheDocument();
unmount();
// The delegate sees the very same catalog as assignable, and no note.
render(
- ,
+ ,
);
expect(screen.queryByRole("note")).not.toBeInTheDocument();
await userEvent.click(screen.getByLabelText("Cargo"));
@@ -158,6 +203,7 @@ describe("MemberForm", () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
{
const onSubmit = vi.fn();
render(
{
it("submits valid data with the chosen cargo and comisiones", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
- render();
+ render(
+ ,
+ );
await userEvent.type(screen.getByLabelText(/nombre/i), "Ana Pérez");
await userEvent.type(screen.getByLabelText(/correo/i), "ana@jci.bo");
await userEvent.click(screen.getByRole("button", { name: "Femenino" }));
@@ -239,7 +293,13 @@ describe("MemberForm", () => {
it("locks comisiones as Comité Ejecutivo Local and clears them for a CEL cargo", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
- ,
+ ,
);
await userEvent.type(screen.getByLabelText(/nombre/i), "Ana Pérez");
await userEvent.type(screen.getByLabelText(/correo/i), "ana@jci.bo");
@@ -258,14 +318,21 @@ describe("MemberForm", () => {
});
it("groups fields under section headers", () => {
- render( {}} />);
+ render(
+ {}}
+ />,
+ );
expect(screen.getByText("Datos personales")).toBeInTheDocument();
expect(screen.getByText("Membresía")).toBeInTheDocument();
});
it("renders a children slot before the submit button", () => {
render(
- {}}>
+ {}}>
extra-slot,
);
@@ -275,6 +342,7 @@ describe("MemberForm", () => {
it("shows inactive assigned cargo with (inactivo) suffix in combobox trigger", async () => {
render(
{
// (createPositionsSafe applies the same predicate). Without it a non-Admin sees a
// grant-free CEL cargo, picks 'Presidente', and the create 403s into a generic error.
it("hides a grant-free CEL cargo from a non-Admin and keeps the JDL dirección", async () => {
- render();
+ render(
+ ,
+ );
await userEvent.click(screen.getByLabelText("Cargo"));
expect(await screen.findByText("Director de Área")).toBeInTheDocument();
expect(screen.queryByText("Presidente")).not.toBeInTheDocument();
@@ -296,7 +371,13 @@ describe("MemberForm", () => {
it("shows a grant-free CEL cargo to an Admin", async () => {
render(
- ,
+ ,
);
await userEvent.click(screen.getByLabelText("Cargo"));
expect(await screen.findByText("Presidente")).toBeInTheDocument();
@@ -324,13 +405,16 @@ describe("MemberForm", () => {
it("BLOCKING: does NOT lock a grant-free CEL seat — clearing it is the allowed takedown", () => {
render(
,
);
- expect(screen.queryByText(/Solo un Admin puede cambiar el cargo/i)).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(/Solo un administrador puede cambiar el cargo/i),
+ ).not.toBeInTheDocument();
});
// Dropping the seat from the options handed it to the `(inactivo)` fallback, which re-added
@@ -339,6 +423,7 @@ describe("MemberForm", () => {
it("BLOCKING: never labels the active grant-free CEL seat '(inactivo)' to a non-Admin", () => {
render(
{
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
{
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
{
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
{
it("does NOT lock a non-Admin editing a member on a grant-free JDL dirección", () => {
render(
,
);
- expect(screen.queryByText(/Solo un Admin puede cambiar el cargo/i)).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(/Solo un administrador puede cambiar el cargo/i),
+ ).not.toBeInTheDocument();
+ });
+
+ // BLOCKING: the two rules conjuncts of positionsAssignmentSafe() are gated on DIFFERENT
+ // principals. `update:BoardSeat` lifts the NEW side (cargoAssignableByNonAdmin, the cargo
+ // written in) — which is what `allowPowerGrants` carries — but the OLD side
+ // (currentCargoGrantsEmpty, the cargo being REPLACED) is Admin-ROLE only and is deliberately
+ // NOT delegated. So the delegate is the one principal for whom both flags disagree, and the
+ // form must still lock. While the lock was `!allowPowerGrants && locked(...)` this render
+ // handed a delegate an open picker on a write the rules ALWAYS deny: render-then-403.
+ const powerCargo: Position = {
+ id: "pos-secre",
+ title: "Secretario",
+ titleFemale: "Secretaria",
+ category: "CEL",
+ grants: ["Secretary"],
+ term: null,
+ sigla: null,
+ description: "Lleva las actas.",
+ active: true,
+ deletedAt: null,
+ };
+
+ it("BLOCKING: locks for a board-seat DELEGATE on a member seated on a power-granting cargo", () => {
+ render(
+ ,
+ );
+ const trigger = screen.getByLabelText("Cargo");
+ expect(trigger).toBeDisabled();
+ const note = screen.getByText(/Solo un administrador puede cambiar el cargo/i);
+ expect(note).toBeInTheDocument();
+ // The note sits after the field in the DOM, so the association is the only way a
+ // screen-reader user reaching a disabled trigger meets the reason.
+ expect(trigger).toHaveAttribute("aria-describedby", note.id);
+ });
+
+ it("does NOT lock an Admin on that same power-granting seat", () => {
+ render(
+ ,
+ );
+ expect(screen.getByLabelText("Cargo")).not.toBeDisabled();
+ expect(
+ screen.queryByText(/Solo un administrador puede cambiar el cargo/i),
+ ).not.toBeInTheDocument();
+ });
+
+ // ---- self-assignment: the second, disjoint refusal in resolveTrustedGrants ----
+ //
+ // BLOCKING: the finding. A delegate holding update:Member + update:BoardSeat opens THEIR OWN
+ // profile and seats themselves on a vacant NON-Admin-granting power cargo. Every gate says
+ // yes — boardSeatDelegate() permits the write, the seat publishes to the Directiva, the save
+ // returns 200 — but `resolveTrustedGrants` computes `selfAssigned = assignedBy === memberUid`
+ // and honors it only for an Admin, so no claim is minted. syncMemberClaims is a background
+ // trigger, so no response carries the refusal; and while the warning keyed on
+ // `grants.includes("Admin")` alone, a Secretario seat rendered NO note whatsoever.
+ const pickSecretario = async () => {
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ await userEvent.click(await screen.findByText("Secretario"));
+ };
+
+ it("BLOCKING: warns a delegate seating THEMSELVES on a non-Admin power cargo", async () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument();
+ await pickSecretario();
+ const note = screen.getByText(MINT_PENDING_COPY);
+ expect(note.id).toBe(MINT_PENDING_NOTE_ID);
+ // The note sits after the field in the DOM, so the association is the only way a
+ // screen-reader user on the trigger meets it before committing the save.
+ expect(screen.getByLabelText("Cargo")).toHaveAttribute(
+ "aria-describedby",
+ MINT_PENDING_NOTE_ID,
+ );
+ // The copy must not name administrator permissions: this cargo grants Secretary.
+ expect(note).not.toHaveTextContent(/permisos de administrador/i);
+ });
+
+ it("BLOCKING: the SAME delegate on the SAME cargo for someone else stays silent", async () => {
+ // The control that makes the case above about self-assignment and nothing else. Identical
+ // props but `isSelfAssignment={false}`: update:BoardSeat DOES mint a Secretary seat for
+ // another member, so a note here would be false and would train users past the real one.
+ render(
+ ,
+ );
+ await pickSecretario();
+ expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument();
+ expect(screen.getByLabelText("Cargo")).not.toHaveAttribute("aria-describedby");
+ });
+
+ it("stays silent for an ADMIN seating themselves — they mint it", async () => {
+ // `assignerIsAdmin` satisfies both arms of the trust gate, so self-assignment is not a
+ // refusal for them. Without this cell the fix could be "warn on any self-assignment".
+ render(
+ ,
+ );
+ await pickSecretario();
+ expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument();
+ });
+
+ // Both new props default to false, which is what keeps the ~20 renders above (and the
+ // invite drawer, which passes only `assignerIsAdmin`) compiling. Pin the defaults: a
+ // required `isSelfAssignment` would be a breaking prop, but a default of TRUE would put the
+ // note on every create.
+ it("defaults both new props to false rather than warning by accident", async () => {
+ render(
+ ,
+ );
+ await pickSecretario();
+ expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument();
+ });
+
+ // BLOCKING: the takedown note now has an id and is the third arm of cargoNoteId(). While the
+ // association was a two-branch ternary over noCargos/locked, a takedown-only editor got
+ // `aria-describedby={undefined}`: the note rendered, sat AFTER the field in the DOM, and a
+ // screen-reader user reaching a trigger whose seat option is disabled met no reason at all.
+ it("BLOCKING: associates the takedown note with the trigger", () => {
+ render(
+ ,
+ );
+ const note = screen.getByText(/solo un administrador puede asignarlo/i);
+ expect(note.id).toBeTruthy();
+ expect(screen.getByLabelText("Cargo")).toHaveAttribute("aria-describedby", note.id);
+ // Not the mint-pending id: a grant-free seat mints nothing to warn about, and the two
+ // notes' ids must not be interchangeable.
+ expect(note.id).not.toBe(MINT_PENDING_NOTE_ID);
});
it("renders comisión option as 'sigla — title' when sigla is present", async () => {
render(
;
@@ -43,7 +58,20 @@ interface MemberFormProps {
* ones and CEL seats alike (rules' `cargoAssignableByNonAdmin`, applied by both
* `createPositionsSafe` and `positionsAssignmentSafe`). Non-Admin sees only assignable
* cargos plus the current selection. */
- allowPowerGrants?: boolean;
+ allowPowerGrants: boolean;
+ /** Whether the editor may REPLACE a cargo that already confers power (rules'
+ * `currentCargoGrantsEmpty`, the other conjunct). Admin role only — `update:BoardSeat`
+ * deliberately does NOT lift this one, so it must not be folded into `allowPowerGrants`.
+ * See positionsLockedForEditor(). */
+ allowReplacePowerCargo: boolean;
+ /** Whether the CALLER holds the Admin role, which is what beacon's `resolveTrustedGrants`
+ * keys the mint on. Named after the minting authority, not after `allowReplacePowerCargo`,
+ * which mirrors a different rules predicate and only happens to equal it today. */
+ assignerIsAdmin: boolean;
+ /** Whether the member being edited IS the caller. The trust gate refuses to mint a
+ * self-assignment of any granting cargo from a non-Admin — confer power on others, never on
+ * yourself — so the picker must say so before the click. */
+ isSelfAssignment: boolean;
children?: ReactNode;
}
@@ -73,7 +101,10 @@ export function MemberForm({
onSubmit,
showPreview,
avatarSeed,
- allowPowerGrants = false,
+ allowPowerGrants,
+ allowReplacePowerCargo,
+ assignerIsAdmin,
+ isSelfAssignment,
children,
}: MemberFormProps) {
const [formError, setFormError] = useState(null);
@@ -113,14 +144,14 @@ export function MemberForm({
// per-form copy is what let this one re-add the held seat labelled "(inactivo)" while the
// other dropped it).
const assignedCargoId = defaultValues?.cargoId ?? null;
- // A power-granting assigned cargo locks cargo/comisiones for a non-Admin — the write
- // re-stamps the same cargoId and `currentCargoGrantsEmpty()` blocks clearing it, so no
+ // A power-granting assigned cargo locks cargo/comisiones for anyone but an Admin — the
+ // write re-stamps the same cargoId and `currentCargoGrantsEmpty()` blocks clearing it, so no
// positions change succeeds. Bio edits still save, because the mapper omits an unchanged
// slot. A grant-free CEL seat is NOT locked: clearing it is deliberately allowed, so the
// form stays open, the seat renders disabled (visible, not assignable) and "Quitar cargo"
- // makes the takedown reachable. See positionsLockedForNonAdmin() / cargoTakedownOnly().
- const assignedCargo = positions.find((p) => p.id === assignedCargoId);
- const positionsLocked = !allowPowerGrants && positionsLockedForNonAdmin(assignedCargo);
+ // makes the takedown reachable. See positionsLockedForEditor() / cargoTakedownOnly().
+ const held = heldCargo(positions, assignedCargoId);
+ const positionsLocked = positionsLockedForEditor(held, allowReplacePowerCargo);
const cargoTakedown = cargoTakedownOnly(selectedCargo, allowPowerGrants);
const cargoOptions = cargoOptionsForEditor({
positions,
@@ -128,6 +159,19 @@ export function MemberForm({
allowPowerGrants,
assignedCargoId,
});
+ const noCargos = noAssignableCargos({ cargoOptions, allowPowerGrants, locked: positionsLocked });
+ const mintPending = cargoGrantNeedsAdminAssigner(
+ selectedCargo,
+ assignerIsAdmin,
+ isSelfAssignment,
+ );
+ // Every note explaining the picker sits after the field in the DOM, so without this a
+ // screen-reader user reaching the trigger hears "Sin resultados" or a disabled control and
+ // never meets the reason. Priority order and the co-firing rules live in cargoNoteId().
+ const describedBy = cargoNoteId(
+ { noCargos, locked: positionsLocked, takedown: cargoTakedown, mintPending },
+ NOTE_IDS,
+ );
const comisionLabel = (p: Position) => (p.sigla ? `${p.sigla} — ${p.title}` : p.title);
const activeComisionOptions = positions
@@ -249,6 +293,7 @@ export function MemberForm({
}}
placeholder="Sin cargo"
disabled={positionsLocked}
+ aria-describedby={describedBy}
/>
{cargoTakedown && (
)}
/>
@@ -300,21 +348,22 @@ export function MemberForm({
)}
{positionsLocked && (
-
- Solo un Admin puede cambiar el cargo de un miembro cuyo cargo otorga permisos. Puedes
- editar el resto de sus datos.
+
+ Solo un administrador puede cambiar el cargo de un miembro cuyo cargo otorga permisos.
+ Puedes editar el resto de sus datos.
)}
+ {/* Suppressed while locked: the picker is disabled there, so nothing about what the
+ save would mint is actionable. */}
+ {!positionsLocked && mintPending && }
{cargoTakedown && (
-
- Este cargo es del Comité Ejecutivo Local: solo un Admin puede asignarlo. Puedes
+
+ Este cargo es del Comité Ejecutivo Local: solo un administrador puede asignarlo. Puedes
quitárselo con «Quitar cargo» o dejarlo como está; el resto de sus datos se guarda
igual.
)}
- {noAssignableCargos({ cargoOptions, allowPowerGrants, locked: positionsLocked }) && (
-
- )}
+ {noCargos && }
(
-
+
{children}
),
});
}
-vi.mock("../../../lib/auth/request-password-reset", () => ({
- requestPasswordReset: vi.fn().mockResolvedValue(undefined),
-}));
-
-import { requestPasswordReset } from "../../../lib/auth/request-password-reset";
-const mockedRequestPasswordReset = vi.mocked(requestPasswordReset);
+/** The two invite outcomes, as `useProvisionMemberLogin` reports them. The MAIL is sent inside
+ * that hook now (a component-scoped onSuccess was dropped whenever the caller unmounted first),
+ * so this drawer never calls `requestPasswordReset` and the fixtures say what happened instead
+ * of mocking the mail module. `fallbackLink` is non-null ONLY on the failure branch: the mail,
+ * when it goes out, invalidates the oobCode the link carries. */
+const mailed = (email: string): InviteResult => ({
+ email,
+ emailSent: true,
+ fallbackLink: null,
+ mailError: null,
+});
+const mailFailed = (email: string, link: string | null): InviteResult => ({
+ email,
+ emailSent: false,
+ fallbackLink: link,
+ mailError: "network error",
+});
async function fill() {
fireEvent.change(screen.getByLabelText(/Nombre/), { target: { value: "Ana Gómez" } });
@@ -39,7 +75,6 @@ async function fill() {
describe("MemberInviteDrawer", () => {
beforeEach(() => {
vi.clearAllMocks();
- mockedRequestPasswordReset.mockResolvedValue(undefined);
});
it("blocks submit and stays on the form when required fields are empty", async () => {
@@ -50,7 +85,7 @@ describe("MemberInviteDrawer", () => {
positions={[]}
onClose={() => {}}
onCreate={onCreate}
- onProvision={async () => ({ email: "", actionLink: "" })}
+ onProvision={async () => mailed("")}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
@@ -62,9 +97,7 @@ describe("MemberInviteDrawer", () => {
it("creates the member then provisions login when access is checked, reaching done", async () => {
const onCreate = vi.fn().mockResolvedValue("new-id");
- const onProvision = vi
- .fn()
- .mockResolvedValue({ email: "ana@jci.bo", actionLink: "https://example.com/link" });
+ const onProvision = vi.fn().mockResolvedValue(mailed("ana@jci.bo"));
renderWithAbility(
{
await waitFor(() => expect(screen.getByText("Ana Gómez fue agregada")).toBeInTheDocument());
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onProvision).toHaveBeenCalledWith("new-id");
- expect(mockedRequestPasswordReset).toHaveBeenCalledWith("ana@jci.bo");
expect(screen.getByText(/Invitación enviada a ana@jci\.bo/)).toBeInTheDocument();
expect(screen.getByText(/recibirá un correo/i)).toBeInTheDocument();
});
it("skips provisioning when access is unchecked", async () => {
- const onProvision = vi
- .fn()
- .mockResolvedValue({ email: "ana@jci.bo", actionLink: "https://example.com/link" });
+ const onProvision = vi.fn().mockResolvedValue(mailed("ana@jci.bo"));
renderWithAbility(
{
fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
await waitFor(() => expect(screen.getByText("Ana Gómez fue agregada")).toBeInTheDocument());
expect(onProvision).not.toHaveBeenCalled();
- expect(mockedRequestPasswordReset).not.toHaveBeenCalled();
expect(screen.getByText(/Aún no tiene acceso/)).toBeInTheDocument();
});
- it("shows email-sent copy when requestPasswordReset resolves", async () => {
+ it("shows email-sent copy when the invite reports the mail went out", async () => {
renderWithAbility(
{}}
onCreate={async () => "id3"}
- onProvision={async () => ({ email: "ana@jci.bo", actionLink: "https://example.com/link" })}
+ onProvision={async () => mailed("ana@jci.bo")}
/>,
);
await fill();
@@ -125,18 +154,14 @@ describe("MemberInviteDrawer", () => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
- it("shows warning and copy-link button when requestPasswordReset rejects", async () => {
- mockedRequestPasswordReset.mockRejectedValue(new Error("network error"));
+ it("shows warning and copy-link button when the invite reports the mail failed", async () => {
renderWithAbility(
{}}
onCreate={async () => "id4"}
- onProvision={async () => ({
- email: "ana@jci.bo",
- actionLink: "https://example.com/action-link",
- })}
+ onProvision={async () => mailFailed("ana@jci.bo", "https://example.com/action-link")}
/>,
);
await fill();
@@ -148,11 +173,32 @@ describe("MemberInviteDrawer", () => {
expect(screen.getByRole("button", { name: "Copiar enlace de acceso" })).toBeInTheDocument();
});
+ // BLOCKING: the copy button is the fallback for a failed mail — the second fallback in a row
+ // — and jsdom's navigator has no `clipboard`, exactly like an insecure context. The click
+ // used to read `navigator.clipboard.writeText` and throw a TypeError synchronously, so the
+ // `.catch()` never ran, `copyState` never became "failed", and the select-all `` that
+ // exists precisely for this never rendered. Routed through useCopyToClipboard now.
+ it("BLOCKING: falls back to a selectable link when the clipboard API is unavailable", async () => {
+ renderWithAbility(
+ {}}
+ onCreate={async () => "idClip"}
+ onProvision={async () => mailFailed("ana@jci.bo", "https://example.com/action-link")}
+ />,
+ );
+ await fill();
+ fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
+ const copyButton = await screen.findByRole("button", { name: "Copiar enlace de acceso" });
+ expect(navigator.clipboard).toBeUndefined();
+ await userEvent.click(copyButton);
+ expect(await screen.findByText("https://example.com/action-link")).toBeInTheDocument();
+ });
+
// --- create:MemberLogin delegation ---
- const drawer = (
- onProvision = vi.fn().mockResolvedValue({ email: "a@b.co", actionLink: "l" }),
- ) => ({
+ const drawer = (onProvision = vi.fn().mockResolvedValue(mailed("a@b.co"))) => ({
node: (
{
expect(screen.queryByLabelText("Enviar acceso a la app")).not.toBeInTheDocument();
await fill();
fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
- await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
+ // Wait for the POSITIVE signal, not the absence of an alert: the negative is already true
+ // before the submit resolves, so a waitFor on it returns on the first tick and the
+ // onProvision assertion below would pass merely because the async handler had not run yet.
+ await screen.findByText(/Aún no tiene acceso a la app/);
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(onProvision).not.toHaveBeenCalled();
});
@@ -197,24 +247,10 @@ describe("MemberInviteDrawer", () => {
// beacon's power-seat guard would refuse it, so attempting it would create the member,
// 403, and point the user at a row action that fails identically forever.
const onProvision = vi.fn();
- const powerCargo = [
- {
- id: "pos-power",
- title: "Secretario",
- titleFemale: null,
- category: "CEL" as const,
- grants: ["Secretary"] as never,
- term: null,
- sigla: null,
- description: "",
- active: true,
- deletedAt: null,
- },
- ];
renderWithAbility(
{}}
onCreate={async () => "idB"}
onProvision={onProvision}
@@ -228,7 +264,255 @@ describe("MemberInviteDrawer", () => {
await userEvent.click(await screen.findByText(/Secretari[ao]/));
fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument());
- expect(screen.getByRole("alert")).toHaveTextContent(/solo un Admin puede enviarle el acceso/);
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ /solo un administrador puede enviarle el acceso/,
+ );
expect(onProvision).not.toHaveBeenCalled();
});
+
+ // The `!isAdmin` term of provisionBlocked had no test: every Admin-path case above passes
+ // positions={[]}, so seatedCargo was always undefined and the cargo clause never fired.
+ // Mutate that term away and this is the only case that notices — without it, an Admin
+ // inviting a board member would be told "solo un administrador puede enviarle el acceso",
+ // self-contradictory copy, suite green.
+ it("BLOCKING: an ADMIN inviting a member on a power-granting cargo still provisions", async () => {
+ const onProvision = vi.fn().mockResolvedValue(mailed("ana@jci.bo"));
+ renderWithAbility(
+ {}}
+ onCreate={async () => "idAdminPower"}
+ onProvision={onProvision}
+ />,
+ );
+ await fill();
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ await userEvent.click(await screen.findByText(/Secretari[ao]/));
+ fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
+ await waitFor(() => expect(onProvision).toHaveBeenCalledWith("idAdminPower"));
+ expect(await screen.findByText(/Invitación enviada a ana@jci\.bo/)).toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ // BLOCKING: the same delegate, same power cargo, but with "Enviar acceso" UNTICKED. Nothing
+ // was attempted, so this is not the blockedByCargo alert — it is the plain done screen, and
+ // it used to read "Podrás invitarlo desde el menú de su fila". That row action is hidden
+ // from this very caller by memberProvisionBlocked, for the identical reason, so the copy
+ // pointed them at an affordance that is not there and would never appear. `blockedByCargo`
+ // could not carry this: it ANDs in sendAccess, which is false here by construction.
+ it("BLOCKING: tells a blocked delegate to ask an administrator, not to use the row menu", async () => {
+ const onProvision = vi.fn();
+ renderWithAbility(
+ {}}
+ onCreate={async () => "idUnticked"}
+ onProvision={onProvision}
+ />,
+ { roles: ["Member"], perms: ["create:Member", "create:MemberLogin", "update:BoardSeat"] },
+ );
+ await fill();
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ await userEvent.click(await screen.findByText(/Secretari[ao]/));
+ fireEvent.click(screen.getByLabelText("Enviar acceso a la app"));
+ fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
+ expect(await screen.findByText(/un administrador debe enviarle el acceso/)).toBeInTheDocument();
+ expect(screen.queryByText(/desde el menú de su fila/)).not.toBeInTheDocument();
+ // Nothing was attempted and nothing failed, so this is guidance, not an error.
+ expect(onProvision).not.toHaveBeenCalled();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("keeps the row-menu copy when the delegate is NOT blocked", async () => {
+ // The control: same caller, same unticked checkbox, no power cargo. The row action IS
+ // available to them here, so sending them to it is correct — the new branch must not
+ // swallow the ordinary case.
+ renderWithAbility(
+ {}}
+ onCreate={async () => "idPlain"}
+ onProvision={vi.fn()}
+ />,
+ { roles: ["Member"], perms: ["create:Member", "create:MemberLogin", "update:BoardSeat"] },
+ );
+ await fill();
+ fireEvent.click(screen.getByLabelText("Enviar acceso a la app"));
+ fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
+ expect(await screen.findByText(/desde el menú de su fila/)).toBeInTheDocument();
+ expect(screen.queryByText(/un administrador debe enviarle el acceso/)).not.toBeInTheDocument();
+ });
+
+ it("BLOCKING: never promises the row action to a creator who lacks create:MemberLogin", async () => {
+ // The OTHER conjunct the row item is gated on. This principal reaches the drawer — the
+ // trigger only asks `Can I="create" a="Member"` — but never sees "Invitar a la app" in the
+ // row menu, because canProvisionLogin is false. The checkbox is not rendered for them
+ // either, so they always land on this branch, on an ordinary grant-free member.
+ renderWithAbility(
+ {}}
+ onCreate={async () => "idCreatorOnly"}
+ onProvision={vi.fn()}
+ />,
+ // read:Member too: /members' nav gate is an unconditional read:Member, so a
+ // create-only principal never reaches the page that hosts this drawer.
+ { roles: ["Member"], perms: ["read:Member", "create:Member"] },
+ );
+ expect(screen.queryByLabelText("Enviar acceso a la app")).not.toBeInTheDocument();
+ await fill();
+ fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
+ expect(
+ await screen.findByText(/Pídele a un administrador que le envíe el acceso/),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/desde el menú de su fila/)).not.toBeInTheDocument();
+ });
+
+ it("keeps the row-menu copy for an ADMIN who unticked the checkbox on a power cargo", async () => {
+ // draftProvisionBlocked short-circuits on callerIsAdmin, so `provisionBlocked` is false
+ // for them even seated on the power cargo — an Admin can always invite from the row. This
+ // is the case the old `!isAdmin &&` conjunct at the call site used to cover.
+ renderWithAbility(
+ {}}
+ onCreate={async () => "idAdminUnticked"}
+ onProvision={vi.fn()}
+ />,
+ );
+ await fill();
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ await userEvent.click(await screen.findByText(/Secretari[ao]/));
+ fireEvent.click(screen.getByLabelText("Enviar acceso a la app"));
+ fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
+ expect(await screen.findByText(/desde el menú de su fila/)).toBeInTheDocument();
+ });
+
+ // --- provision refusals: tagged reason vs raw diagnostic ---
+
+ /** A rejection shaped like the callable's: a FirebaseError carries the server's English prose
+ * as `message` and the machine-readable refusal under `details.reason`. */
+ function provisionRefusal(reason: string, message: string) {
+ return Object.assign(new Error(message), { details: { reason } });
+ }
+
+ // BLOCKING: this drawer was the third and last provisioning entry point, and the only one
+ // still rendering the server's raw English prose to a Spanish-speaking operator. The row menu
+ // and the profile header already routed refusals through provisionErrorMessage; a delegate
+ // who hit `reprovision-requires-admin` here read "this member already has a login…" and had
+ // no idea an Admin could finish it — so they retried the invite forever.
+ it("BLOCKING: maps a TAGGED provision refusal to its Spanish message", async () => {
+ const onProvision = vi
+ .fn()
+ .mockRejectedValue(
+ provisionRefusal(
+ "reprovision-requires-admin",
+ "this member already has a login; only an Admin can re-send it",
+ ),
+ );
+ renderWithAbility(
+ {}}
+ onCreate={async () => "idTagged"}
+ onProvision={onProvision}
+ />,
+ { roles: ["Member"], perms: ["create:Member", "create:MemberLogin"] },
+ );
+ await fill();
+ fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
+ expect(
+ await screen.findByText(
+ /Ya existe un acceso para este correo\. Pídele a un administrador que lo reenvíe o lo vincule\./,
+ ),
+ ).toBeInTheDocument();
+ // BLOCKING: the refusal must be the HEADLINE, not small print under a contradiction.
+ // The default fallback tells the operator to invite from the row menu — and that item IS
+ // offered here, because memberProvisionBlocked keys `hasLogin` on member.uid and this
+ // just-created doc has none: beacon refused on the Auth directory, which the client cannot
+ // see. Demoting the real reason to "Detalle:" therefore sends them into exactly the
+ // infinite retry this mapping exists to end.
+ expect(screen.queryByText(/desde el menú de su fila/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/^Detalle:/)).not.toBeInTheDocument();
+ // The raw prose must be GONE, not merely accompanied — it is the thing being replaced.
+ expect(screen.queryByText(/this member already has a login/)).not.toBeInTheDocument();
+ // The member was still created, so the done screen is guidance, not a create failure.
+ expect(screen.getByText("Ana Gómez fue agregada")).toBeInTheDocument();
+ });
+
+ // The other half of the same line, and the reason the raw message stays as the FALLBACK: an
+ // App Check / quota / config failure carries no `details.reason`, and its message is the one
+ // diagnostic anybody gets. A fix that mapped everything to a generic Spanish sentence would
+ // pass the test above and destroy this.
+ it("BLOCKING: keeps the raw message for an UNTAGGED provision failure", async () => {
+ const onProvision = vi.fn().mockRejectedValue(new Error("AppCheck token is invalid"));
+ renderWithAbility(
+ {}}
+ onCreate={async () => "idUntagged"}
+ onProvision={onProvision}
+ />,
+ { roles: ["Member"], perms: ["create:Member", "create:MemberLogin"] },
+ );
+ await fill();
+ fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
+ expect(await screen.findByText(/Detalle: AppCheck token is invalid/)).toBeInTheDocument();
+ });
+
+ // A `reason` that is not in the table — beacon adding one before the client ships the copy —
+ // falls back the same way. Pinned separately because a Map lookup returning `undefined` and a
+ // plain-object lookup returning `Object.prototype.toString` are both "not found", and only
+ // one of them renders a function into the DOM.
+ it("falls back to the raw message for an UNKNOWN tagged reason", async () => {
+ const onProvision = vi
+ .fn()
+ .mockRejectedValue(provisionRefusal("some-future-reason", "server said no"));
+ renderWithAbility(
+ {}}
+ onCreate={async () => "idUnknown"}
+ onProvision={onProvision}
+ />,
+ { roles: ["Member"], perms: ["create:Member", "create:MemberLogin"] },
+ );
+ await fill();
+ fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
+ expect(await screen.findByText(/Detalle: server said no/)).toBeInTheDocument();
+ });
+
+ // beacon withholds the action link from a non-Admin caller (it is a bearer credential for
+ // the account), so a delegate whose reset mail then fails has NO manual fallback — the copy
+ // must send them to an Admin rather than to a copy button that would copy nothing. Only
+ // reachable as delegate + provision succeeded + the mail rejected.
+ it("BLOCKING: tells a delegate to ask an administrator when there is no action link to share", async () => {
+ renderWithAbility(
+ {}}
+ onCreate={async () => "idNoLink"}
+ onProvision={async () => mailFailed("ana@jci.bo", null)}
+ />,
+ { roles: ["Member"], perms: ["create:Member", "create:MemberLogin"] },
+ );
+ await fill();
+ fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
+ await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument());
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "El correo no se pudo enviar. Pídele a un administrador que reenvíe la invitación.",
+ );
+ expect(
+ screen.queryByRole("button", { name: /Copiar enlace de acceso/ }),
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/apps/backstage/src/features/members/components/member-invite-drawer.tsx b/apps/backstage/src/features/members/components/member-invite-drawer.tsx
index 28a79537..a94db88a 100644
--- a/apps/backstage/src/features/members/components/member-invite-drawer.tsx
+++ b/apps/backstage/src/features/members/components/member-invite-drawer.tsx
@@ -1,9 +1,12 @@
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { Button, Checkbox, Sheet } from "@luminova/ui";
import { type MemberInput, type Position } from "@luminova/types";
import { MemberForm } from "./member-form";
import { actionMessage } from "../lib/member-display";
-import { requestPasswordReset } from "../../../lib/auth/request-password-reset";
+import type { InviteResult } from "../hooks/use-provision-member-login";
+import { draftProvisionBlocked } from "../lib/provision-gate";
+import { provisionRefusalMessage } from "../lib/provision-error";
+import { useCopyToClipboard } from "../../../lib/use-copy-to-clipboard";
import { useCan } from "../../../lib/authz/use-can";
interface MemberInviteDrawerProps {
@@ -11,18 +14,27 @@ interface MemberInviteDrawerProps {
positions: Position[];
onClose: () => void;
onCreate: (data: MemberInput) => Promise;
- onProvision: (memberId: string) => Promise<{ email: string; actionLink: string }>;
+ onProvision: (memberId: string) => Promise;
}
interface DoneState {
/** The invite was skipped because the member's cargo confers permissions and the caller is
* not an Admin — beacon would refuse it, so nothing was attempted. */
blockedByCargo: boolean;
+ /** The same predicate WITHOUT the sendAccess conjunct — a delegate who left the checkbox
+ * unticked is still blocked from the row action, so the done screen must not send them there. */
+ provisionBlocked: boolean;
name: string;
email: string;
provisioned: boolean;
emailSent: boolean;
actionLink: string | null;
+ /** The callable's own explanation, when it refused ON PURPOSE. Drives the HEADLINE, not the
+ * small print: these refusals ("ya existe un acceso para este correo") contradict the
+ * default "invítalo desde el menú de su fila", and the row action really is offered —
+ * memberProvisionBlocked keys `hasLogin` on member.uid, which a just-created doc lacks,
+ * because beacon refused on the Auth directory the client cannot see. */
+ refusalMessage: string | null;
errorDetail: string | null;
}
@@ -44,7 +56,7 @@ export function MemberInviteDrawer({
const { canProvisionLogin, canAssignBoardSeat, isAdmin } = useCan();
const [done, setDone] = useState(null);
const [sendAccess, setSendAccess] = useState(canProvisionLogin);
- const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
+ const { copyState, copy, resetCopyState } = useCopyToClipboard();
// The drawer mounts with the page, before the auth token's claims decode (the store
// emits with empty claims first, then re-emits). Re-sync the default each time it
@@ -56,10 +68,17 @@ export function MemberInviteDrawer({
if (open) setSendAccess(canProvisionLogin);
}, [open, canProvisionLogin]);
+ // Bumped by every submit AND by every reset, so a submit that is still in flight when the
+ // operator dismisses the Sheet (the X, Escape, the overlay — only the submit BUTTON is
+ // disabled while sending) cannot land its done screen afterwards. Without it, reopening
+ // «Invitar miembro» showed the previous member's done screen, action link included.
+ const attempt = useRef(0);
+
const reset = () => {
+ attempt.current += 1;
setDone(null);
setSendAccess(canProvisionLogin);
- setCopyState("idle");
+ resetCopyState();
};
const close = () => {
@@ -68,48 +87,66 @@ export function MemberInviteDrawer({
};
const handleSubmit = async (data: MemberInput) => {
+ const mine = attempt.current + 1;
+ attempt.current = mine;
const id = await onCreate(data);
let provisioned = false;
let emailSent = false;
let actionLink: string | null = null;
+ let refusalMessage: string | null = null;
let errorDetail: string | null = null;
// beacon refuses a non-Admin provisioning a member seated on a granting cargo (the
// power-seat guard). The rules DO let that member be created, so without this check the
// drawer would create them, 403 on the invite, and send the user to a row action that
- // fails the same way on every retry. Decide before writing anything.
- const seatedCargo = data.cargoId ? positions.find((p) => p.id === data.cargoId) : undefined;
- const provisionBlocked = !isAdmin && (seatedCargo?.grants.length ?? 0) > 0;
- if (sendAccess && provisionBlocked) {
- errorDetail = null;
- } else if (sendAccess) {
+ // fails the same way on every retry. Decide before writing anything. Same predicate as
+ // the row menu and the profile header — see provision-gate.ts.
+ const provisionBlocked = draftProvisionBlocked(
+ data.cargoId,
+ // NOT `id` — that name is the CREATED MEMBER's doc id, bound above and passed to
+ // onProvision. Both are strings, so shadowing it here would let a later edit resolve
+ // the wrong document with no type error.
+ (cargoId) => positions.find((p) => p.id === cargoId),
+ isAdmin,
+ );
+ // Nothing is attempted when blocked: the done screen explains it instead of reporting a
+ // failure that never happened.
+ if (sendAccess && !provisionBlocked) {
// The member is already created; if provisioning fails, fall through to the
// done screen with provisioned=false ("aún no tiene acceso, invítalo desde su
// fila") instead of throwing — a thrown error reads as a create failure and
// would invite a duplicate-create retry. Surface the real cause (App Check,
// quota, config) instead of swallowing it — this is the only diagnostic we get.
try {
+ // The mail is part of onProvision (use-provision-member-login), not a second step this
+ // component arranges: doing it here left one caller able to provision without mailing,
+ // and the action link is only valid when the mail did NOT go out.
const result = await onProvision(id);
provisioned = true;
- actionLink = result.actionLink || null;
- try {
- await requestPasswordReset(data.email);
- emailSent = true;
- } catch (err) {
- console.error("No se pudo enviar el correo de acceso", err);
- errorDetail = err instanceof Error ? err.message : String(err);
- }
+ emailSent = result.emailSent;
+ actionLink = result.fallbackLink;
+ errorDetail = result.mailError;
} catch (err) {
console.error("No se pudo aprovisionar el acceso del miembro", err);
- errorDetail = err instanceof Error ? err.message : String(err);
+ // A deliberate refusal becomes the headline; anything else keeps its raw message as
+ // the only diagnostic we get (App Check, quota, config).
+ refusalMessage = provisionRefusalMessage(err);
+ if (refusalMessage === null) {
+ errorDetail = err instanceof Error ? err.message : String(err);
+ }
}
}
+ // Closed or reset while this was in flight — the member IS created either way (the toast
+ // on the page behind reports that), but this drawer no longer owns the screen.
+ if (attempt.current !== mine) return;
setDone({
blockedByCargo: sendAccess && provisionBlocked,
+ provisionBlocked,
name: data.name,
email: data.email,
provisioned,
emailSent,
actionLink,
+ refusalMessage,
errorDetail,
});
};
@@ -134,14 +171,14 @@ export function MemberInviteDrawer({
) : done.blockedByCargo ? (
- {`${done.name} fue creado, pero su cargo otorga permisos: solo un Admin puede enviarle el acceso. Pídeselo para completar la invitación.`}
+ {`${done.name} fue creado, pero su cargo otorga permisos: solo un administrador puede enviarle el acceso. Pídele a un administrador que complete la invitación.`}
) : done.provisioned && !done.emailSent ? (
<>
{done.actionLink
? "El correo no se pudo enviar. Comparte el enlace de acceso manualmente."
- : "El correo no se pudo enviar. Pídele a un Admin que reenvíe la invitación."}
+ : "El correo no se pudo enviar. Pídele a un administrador que reenvíe la invitación."}
{done.errorDetail && (
Detalle: {done.errorDetail}
@@ -152,12 +189,7 @@ export function MemberInviteDrawer({
as="button"
type="button"
variant="secondary"
- onClick={() => {
- navigator.clipboard
- .writeText(done.actionLink ?? "")
- .then(() => setCopyState("copied"))
- .catch(() => setCopyState("failed"));
- }}
+ onClick={() => copy(done.actionLink ?? "")}
className="w-full justify-center"
>
{copyState === "copied" ? "Enlace copiado" : "Copiar enlace de acceso"}
@@ -172,8 +204,17 @@ export function MemberInviteDrawer({
>
) : (
<>
+ {/* Only promise the row action to someone who will actually see it — the row
+ item is gated on `canProvisionLogin && !provisionBlocked`, so both conjuncts
+ are answered here, and a server refusal outranks both. */}
- Aún no tiene acceso a la app. Podrás invitarlo desde el menú de su fila.
+ {done.refusalMessage
+ ? `Aún no tiene acceso a la app. ${done.refusalMessage}`
+ : done.provisionBlocked
+ ? "Aún no tiene acceso a la app. Su cargo otorga permisos, así que un administrador debe enviarle el acceso."
+ : canProvisionLogin
+ ? "Aún no tiene acceso a la app. Podrás invitarlo desde el menú de su fila."
+ : "Aún no tiene acceso a la app. Pídele a un administrador que le envíe el acceso."}
{done.errorDetail && (
Detalle: {done.errorDetail}
@@ -202,6 +243,11 @@ export function MemberInviteDrawer({
pendingLabel="Enviando…"
showPreview
allowPowerGrants={canAssignBoardSeat}
+ allowReplacePowerCargo={isAdmin}
+ assignerIsAdmin={isAdmin}
+ // A member being CREATED is never the caller: the create lane forbids `uid` to a
+ // non-Admin, so no seat written here can be the author's own.
+ isSelfAssignment={false}
defaultValues={{ joinDate: today(), status: "Activo", cargoId: null, comisionIds: [] }}
onSubmit={handleSubmit}
>
diff --git a/apps/backstage/src/features/members/components/member-positions-form.test.tsx b/apps/backstage/src/features/members/components/member-positions-form.test.tsx
index c6d11993..9dc183c9 100644
--- a/apps/backstage/src/features/members/components/member-positions-form.test.tsx
+++ b/apps/backstage/src/features/members/components/member-positions-form.test.tsx
@@ -3,6 +3,24 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Position } from "@luminova/types";
import { MemberPositionsForm } from "./member-positions-form";
+import { cargoNoteIds } from "./no-assignable-cargos-note";
+import { permissionLabel } from "../../permissions/lib/permission-matrix";
+
+// Through the same helper the form calls, not a hand-typed literal: the ids are no longer
+// exported individually, and a test that re-typed one would keep passing after a rename.
+const MINT_PENDING_NOTE_ID = cargoNoteIds("positions").mintPending;
+
+// The mint-pending note used to say "permisos de administrador", which was true only of its
+// one original trigger. It now fires for a SELF-assignment of any granting cargo — a
+// Secretario, say — so copy naming administrator permissions would be a lie in that case.
+// Matched on the outcome half of the sentence, which is the part both triggers share.
+const MINT_PENDING_COPY = /no se aplicarán hasta que un administrador confirme la asignación/i;
+
+// The note names the permission through `permissionLabel`, and its own comment says the two
+// features must not drift. Assert against the same source, not a hardcoded copy — a literal
+// here would keep passing after either half of the label is renamed, which is exactly the
+// coupling the note is worried about.
+const BOARD_SEAT_LABEL = permissionLabel("update:BoardSeat");
const pos = (id: string, category: Position["category"]): Position => ({
id,
@@ -25,6 +43,9 @@ describe("MemberPositionsForm", () => {
positions={positions}
gender="Masculino"
allowPowerGrants={false}
+ allowReplacePowerCargo={false}
+ assignerIsAdmin={false}
+ isSelfAssignment={false}
defaultValues={{ cargoId: null, comisionIds: [] }}
onSubmit={onSubmit}
/>,
@@ -45,11 +66,14 @@ describe("MemberPositionsForm", () => {
positions={gated}
gender="Masculino"
allowPowerGrants={false}
+ allowReplacePowerCargo={false}
+ assignerIsAdmin={false}
+ isSelfAssignment={false}
defaultValues={{ cargoId: null, comisionIds: [] }}
onSubmit={vi.fn()}
/>,
);
- expect(screen.getByRole("note")).toHaveTextContent(/Asientos de directiva/);
+ expect(screen.getByRole("note")).toHaveTextContent(BOARD_SEAT_LABEL);
unmount();
// A delegate assigns the same catalog: no note.
@@ -58,6 +82,9 @@ describe("MemberPositionsForm", () => {
positions={gated}
gender="Masculino"
allowPowerGrants
+ allowReplacePowerCargo={false}
+ assignerIsAdmin={false}
+ isSelfAssignment={false}
defaultValues={{ cargoId: null, comisionIds: [] }}
onSubmit={vi.fn()}
/>,
@@ -73,13 +100,16 @@ describe("MemberPositionsForm", () => {
positions={gated}
gender="Masculino"
allowPowerGrants={false}
+ allowReplacePowerCargo={false}
+ assignerIsAdmin={false}
+ isSelfAssignment={false}
defaultValues={{ cargoId: "power", comisionIds: [] }}
onSubmit={vi.fn()}
/>,
);
const notes = screen.getAllByRole("note");
expect(notes).toHaveLength(1);
- expect(notes[0]).toHaveTextContent(/Solo un Admin/);
+ expect(notes[0]).toHaveTextContent(/Solo un administrador/);
});
it("submits selected cargo and comisiones", async () => {
@@ -89,6 +119,9 @@ describe("MemberPositionsForm", () => {
positions={positions}
gender="Masculino"
allowPowerGrants={false}
+ allowReplacePowerCargo={false}
+ assignerIsAdmin={false}
+ isSelfAssignment={false}
defaultValues={{ cargoId: null, comisionIds: [] }}
onSubmit={onSubmit}
/>,
@@ -119,6 +152,9 @@ describe("MemberPositionsForm", () => {
positions={[cce]}
gender="Femenino"
allowPowerGrants={false}
+ allowReplacePowerCargo={false}
+ assignerIsAdmin={false}
+ isSelfAssignment={false}
defaultValues={{ cargoId: null, comisionIds: [] }}
onSubmit={vi.fn().mockResolvedValue(undefined)}
/>,
@@ -136,6 +172,9 @@ describe("MemberPositionsForm", () => {
positions={[powerCargo]}
gender="Masculino"
allowPowerGrants={false}
+ allowReplacePowerCargo={false}
+ assignerIsAdmin={false}
+ isSelfAssignment={false}
defaultValues={{ cargoId: null, comisionIds: [] }}
onSubmit={vi.fn().mockResolvedValue(undefined)}
/>,
@@ -144,12 +183,143 @@ describe("MemberPositionsForm", () => {
expect(screen.queryByText("presidente")).not.toBeInTheDocument();
});
+ // The silent outcome. A delegate may WRITE this seat — boardSeatDelegate() allows it and
+ // the save succeeds — but resolveTrustedGrants refuses an Admin-granting cargo from a
+ // non-Admin assigner, so the member is seated with no Admin claim and nothing else says so.
+ it("BLOCKING: warns a delegate that an Admin-granting cargo mints nothing until an Admin confirms", async () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument();
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ await userEvent.click(await screen.findByText("presidente"));
+ expect(screen.getByText(MINT_PENDING_COPY)).toBeInTheDocument();
+ });
+
+ it("stays silent for an Admin picking that same cargo, who does mint it", async () => {
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ await userEvent.click(await screen.findByText("presidente"));
+ expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument();
+ });
+
+ // ---- self-assignment: the second, disjoint refusal in resolveTrustedGrants ----
+ //
+ // BLOCKING: the finding. A delegate holding update:Position + update:BoardSeat opens THEIR
+ // OWN profile and seats themselves on a vacant NON-Admin-granting power cargo. Every gate
+ // above says yes: boardSeatDelegate() permits the write, the seat publishes to the
+ // Directiva, and the save returns 200. But `resolveTrustedGrants` computes
+ // `selfAssigned = assignedBy === memberUid` and honors it only for an Admin, so no claim is
+ // minted. syncMemberClaims is a background trigger — no response carries the refusal — and
+ // before this the warning keyed on `grants.includes("Admin")` alone, so a Secretario seat
+ // rendered NO note at all. The picker is the only place this can be said.
+ const selfCargo: Position = { ...pos("secretario", "CEL"), grants: ["Secretary"] };
+
+ const seatSelf = async () => {
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ await userEvent.click(await screen.findByText("secretario"));
+ };
+
+ it("BLOCKING: warns a delegate seating THEMSELVES on a non-Admin power cargo", async () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument();
+ await seatSelf();
+ const note = screen.getByText(MINT_PENDING_COPY);
+ expect(note).toBeInTheDocument();
+ // The note sits after the field in the DOM, so the association is the only way a
+ // screen-reader user on the trigger meets it before committing the save.
+ expect(note.id).toBe(MINT_PENDING_NOTE_ID);
+ expect(screen.getByLabelText("Cargo")).toHaveAttribute(
+ "aria-describedby",
+ MINT_PENDING_NOTE_ID,
+ );
+ // The copy must not name administrator permissions: this cargo grants Secretary.
+ expect(note).not.toHaveTextContent(/permisos de administrador/i);
+ // And the seat is still assignable — the write succeeds, which is exactly why the note
+ // has to explain what will NOT follow it.
+ expect(screen.getByRole("button", { name: /guardar/i })).toBeEnabled();
+ });
+
+ it("BLOCKING: the SAME delegate on the SAME cargo for someone else stays silent", async () => {
+ // The control that makes the case above about self-assignment and nothing else. Identical
+ // props but `isSelfAssignment={false}`: update:BoardSeat DOES mint a Secretary seat for
+ // another member, so a note here would be false and would train users past the real one.
+ render(
+ ,
+ );
+ await seatSelf();
+ expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument();
+ expect(screen.getByLabelText("Cargo")).not.toHaveAttribute("aria-describedby");
+ });
+
+ it("stays silent for an ADMIN seating themselves — they mint it", async () => {
+ // `assignerIsAdmin` satisfies both arms of the trust gate, so self-assignment is not a
+ // refusal for them. Without this cell the fix could be "warn on any self-assignment".
+ render(
+ ,
+ );
+ await seatSelf();
+ expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument();
+ });
+
it("shows power-granting cargos to an Admin", async () => {
render(
,
@@ -164,12 +334,15 @@ describe("MemberPositionsForm", () => {
positions={[powerCargo]}
gender="Masculino"
allowPowerGrants={false}
+ allowReplacePowerCargo={false}
+ assignerIsAdmin={false}
+ isSelfAssignment={false}
defaultValues={{ cargoId: "presidente", comisionIds: [] }}
onSubmit={vi.fn().mockResolvedValue(undefined)}
/>,
);
expect(screen.getByRole("button", { name: /guardar/i })).toBeDisabled();
- expect(screen.getByText(/Solo un Admin puede cambiar los cargos/i)).toBeInTheDocument();
+ expect(screen.getByText(/Solo un administrador puede cambiar los cargos/i)).toBeInTheDocument();
});
it("does NOT lock when the editor may assign power grants (Admin)", () => {
@@ -178,6 +351,9 @@ describe("MemberPositionsForm", () => {
positions={[powerCargo]}
gender="Masculino"
allowPowerGrants
+ allowReplacePowerCargo={true}
+ assignerIsAdmin={true}
+ isSelfAssignment={false}
defaultValues={{ cargoId: "presidente", comisionIds: [] }}
onSubmit={vi.fn().mockResolvedValue(undefined)}
/>,
@@ -185,6 +361,36 @@ describe("MemberPositionsForm", () => {
expect(screen.getByRole("button", { name: /guardar/i })).not.toBeDisabled();
});
+ // BLOCKING: the two rules conjuncts of positionsAssignmentSafe() are gated on DIFFERENT
+ // principals. `update:BoardSeat` lifts the NEW side (cargoAssignableByNonAdmin, the cargo
+ // written in) — which is what `allowPowerGrants` carries — but the OLD side
+ // (currentCargoGrantsEmpty, the cargo being REPLACED) is Admin-ROLE only and is deliberately
+ // NOT delegated. So the delegate is the one principal for whom both flags disagree, and the
+ // form must still lock. While the lock was `!allowPowerGrants && locked(...)` this render
+ // handed a delegate an open picker on a write the rules ALWAYS deny: render-then-403.
+ it("BLOCKING: locks for a board-seat DELEGATE on a member seated on a power-granting cargo", () => {
+ render(
+ ,
+ );
+ const trigger = screen.getByLabelText("Cargo");
+ expect(trigger).toBeDisabled();
+ const note = screen.getByText(/Solo un administrador puede cambiar los cargos/i);
+ expect(note).toBeInTheDocument();
+ // The note sits after the field in the DOM, so the association is the only way a
+ // screen-reader user reaching a disabled trigger meets the reason.
+ expect(trigger).toHaveAttribute("aria-describedby", note.id);
+ expect(screen.getByRole("button", { name: /guardar/i })).toBeDisabled();
+ });
+
// The publication half of the mirror. pos_cel_free is grant-free, so the grants filter
// alone still offered it: a non-Admin picked 'Presidente' and the save 403'd on the rules'
// `category != 'CEL'` conjunct with a generic error.
@@ -196,6 +402,9 @@ describe("MemberPositionsForm", () => {
positions={[celFree, pos("dir", "JDL")]}
gender="Masculino"
allowPowerGrants={false}
+ allowReplacePowerCargo={false}
+ assignerIsAdmin={false}
+ isSelfAssignment={false}
defaultValues={{ cargoId: null, comisionIds: [] }}
onSubmit={vi.fn().mockResolvedValue(undefined)}
/>,
@@ -213,6 +422,9 @@ describe("MemberPositionsForm", () => {
positions={[celFree]}
gender="Masculino"
allowPowerGrants
+ allowReplacePowerCargo={true}
+ assignerIsAdmin={true}
+ isSelfAssignment={false}
defaultValues={{ cargoId: null, comisionIds: [] }}
onSubmit={vi.fn().mockResolvedValue(undefined)}
/>,
@@ -233,9 +445,26 @@ describe("MemberPositionsForm", () => {
positions: [celFree, pos("etica", "Comision")],
gender: "Masculino" as const,
allowPowerGrants: false,
+ allowReplacePowerCargo: false,
+ assignerIsAdmin: false,
+ isSelfAssignment: false,
defaultValues: { cargoId: "presidente_libre", comisionIds: [] },
};
+ // BLOCKING: the takedown note now has an id and is the third arm of cargoNoteId(). While
+ // the association was a two-branch ternary over noCargos/locked, a takedown-only editor got
+ // `aria-describedby={undefined}`: the note rendered, sat AFTER the field in the DOM, and a
+ // screen-reader user reaching a trigger whose only option is disabled met no reason at all.
+ it("BLOCKING: associates the takedown note with the trigger", () => {
+ render();
+ const note = screen.getByText(/solo un administrador puede asignarlo/i);
+ expect(note.id).toBeTruthy();
+ expect(screen.getByLabelText("Cargo")).toHaveAttribute("aria-describedby", note.id);
+ // Not the mint-pending id: a grant-free seat mints nothing to warn about, and the two
+ // notes' ids must not be interchangeable.
+ expect(note.id).not.toBe(MINT_PENDING_NOTE_ID);
+ });
+
// Dropping the seat from the options was half a mirror: it left the CEL cargoId as the RHF
// value with no option to render it, so the trigger showed the "Sin cargo" PLACEHOLDER for a
// seated member, saving as-is re-submitted the cargoId into a 403, and Combobox's clear
@@ -244,7 +473,9 @@ describe("MemberPositionsForm", () => {
render();
expect(screen.getByLabelText("Cargo")).toHaveTextContent("presidente_libre");
// Not locked — the takedown stays open — but not savable while the seat is kept either.
- expect(screen.queryByText(/Solo un Admin puede cambiar los cargos/i)).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(/Solo un administrador puede cambiar los cargos/i),
+ ).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /guardar/i })).toBeDisabled();
});
@@ -280,12 +511,15 @@ describe("MemberPositionsForm", () => {
positions={[granting, pos("etica", "Comision")]}
gender="Masculino"
allowPowerGrants={false}
+ allowReplacePowerCargo={false}
+ assignerIsAdmin={false}
+ isSelfAssignment={false}
defaultValues={{ cargoId: "tesorero", comisionIds: [] }}
onSubmit={vi.fn().mockResolvedValue(undefined)}
/>,
);
expect(screen.getByRole("button", { name: /guardar/i })).toBeDisabled();
- expect(screen.getByText(/Solo un Admin puede cambiar los cargos/i)).toBeInTheDocument();
+ expect(screen.getByText(/Solo un administrador puede cambiar los cargos/i)).toBeInTheDocument();
});
it("does NOT lock a non-Admin editing a member seated on a grant-free JDL dirección", () => {
@@ -294,6 +528,9 @@ describe("MemberPositionsForm", () => {
positions={positions}
gender="Masculino"
allowPowerGrants={false}
+ allowReplacePowerCargo={false}
+ assignerIsAdmin={false}
+ isSelfAssignment={false}
defaultValues={{ cargoId: "dir", comisionIds: [] }}
onSubmit={vi.fn().mockResolvedValue(undefined)}
/>,
@@ -308,6 +545,9 @@ describe("MemberPositionsForm", () => {
positions={positions}
gender="Masculino"
allowPowerGrants={false}
+ allowReplacePowerCargo={false}
+ assignerIsAdmin={false}
+ isSelfAssignment={false}
defaultValues={{ cargoId: null, comisionIds: [] }}
onSubmit={onSubmit}
/>,
diff --git a/apps/backstage/src/features/members/components/member-positions-form.tsx b/apps/backstage/src/features/members/components/member-positions-form.tsx
index 8ac9b25c..26e272b2 100644
--- a/apps/backstage/src/features/members/components/member-positions-form.tsx
+++ b/apps/backstage/src/features/members/components/member-positions-form.tsx
@@ -5,12 +5,21 @@ import { z } from "zod";
import { Button, Combobox, Field, MultiSelect } from "@luminova/ui";
import { type MemberGender, type Position } from "@luminova/types";
import {
+ cargoGrantNeedsAdminAssigner,
+ cargoNoteId,
cargoOptionsForEditor,
- cargoTakedownOnly,
noAssignableCargos,
- positionsLockedForNonAdmin,
} from "../lib/assignable-cargo";
-import { NoAssignableCargosNote } from "./no-assignable-cargos-note";
+// Directly from the rules-mirroring module, not through assignable-cargo.ts: the file a
+// predicate comes from is what says the emulator parity test holds it to firestore.rules.
+import {
+ cargoTakedownOnly,
+ heldCargo,
+ positionsLockedForEditor,
+} from "../lib/assignable-cargo-core";
+import { cargoNoteIds, MintPendingNote, NoAssignableCargosNote } from "./no-assignable-cargos-note";
+
+const NOTE_IDS = cargoNoteIds("positions");
const positionsSchema = z.object({
cargoId: z.string().min(1).nullable(),
@@ -24,6 +33,9 @@ export function MemberPositionsForm({
gender,
defaultValues,
allowPowerGrants,
+ allowReplacePowerCargo,
+ assignerIsAdmin,
+ isSelfAssignment,
onSubmit,
}: {
positions: Position[];
@@ -34,6 +46,19 @@ export function MemberPositionsForm({
* assignable cargos, plus the seat the member already holds rendered DISABLED, so the
* trigger names the real cargo without putting a denied write one click away. */
allowPowerGrants: boolean;
+ /** Whether the caller may REPLACE a cargo that already confers power (rules'
+ * `currentCargoGrantsEmpty`, the other conjunct). Admin role only — `update:BoardSeat`
+ * deliberately does NOT lift this one, so it must not be folded into `allowPowerGrants`.
+ * See positionsLockedForEditor(). */
+ allowReplacePowerCargo: boolean;
+ /** Whether the CALLER holds the Admin role, which is what beacon's `resolveTrustedGrants`
+ * keys the mint on. Named after the minting authority, not after `allowReplacePowerCargo`,
+ * which mirrors a different rules predicate and only happens to equal it today. */
+ assignerIsAdmin: boolean;
+ /** Whether the member being edited IS the caller. The trust gate refuses to mint a
+ * self-assignment of any granting cargo from a non-Admin — confer power on others, never on
+ * yourself — so the picker must say so before the click. */
+ isSelfAssignment: boolean;
onSubmit: (data: PositionsInput) => Promise;
}) {
const [formError, setFormError] = useState(null);
@@ -44,14 +69,14 @@ export function MemberPositionsForm({
formState: { isSubmitting },
} = useForm({ resolver: zodResolver(positionsSchema), defaultValues });
- // A power-granting current cargo locks the whole slot for a non-Admin: every save re-stamps
- // that cargoId, and `currentCargoGrantsEmpty()` blocks clearing it too, so nothing they can
- // submit succeeds. A grant-free CEL seat is the asymmetric case — keeping it is denied,
- // CLEARING it is allowed on purpose — so the form stays open, the seat renders as a disabled
- // option (the trigger must not claim "Sin cargo" for a seated member) and only the takedown
- // can be saved. See positionsLockedForNonAdmin() / cargoTakedownOnly().
- const assignedCargo = positions.find((p) => p.id === defaultValues.cargoId);
- const locked = !allowPowerGrants && positionsLockedForNonAdmin(assignedCargo);
+ // A power-granting current cargo locks the whole slot for anyone but an Admin: every save
+ // re-stamps that cargoId, and `currentCargoGrantsEmpty()` blocks clearing it too, so nothing
+ // they can submit succeeds. A grant-free CEL seat is the asymmetric case — keeping it is
+ // denied, CLEARING it is allowed on purpose — so the form stays open, the seat renders as a
+ // disabled option (the trigger must not claim "Sin cargo" for a seated member) and only the
+ // takedown can be saved. See positionsLockedForEditor() / cargoTakedownOnly().
+ const held = heldCargo(positions, defaultValues.cargoId);
+ const locked = positionsLockedForEditor(held, allowReplacePowerCargo);
const cargoOptions = cargoOptionsForEditor({
positions,
gender,
@@ -60,6 +85,19 @@ export function MemberPositionsForm({
});
const selectedCargo = positions.find((p) => p.id === watch("cargoId"));
const takedownOnly = cargoTakedownOnly(selectedCargo, allowPowerGrants);
+ const noCargos = noAssignableCargos({ cargoOptions, allowPowerGrants, locked });
+ const mintPending = cargoGrantNeedsAdminAssigner(
+ selectedCargo,
+ assignerIsAdmin,
+ isSelfAssignment,
+ );
+ // Every note explaining the picker sits after the field in the DOM, so without this a
+ // screen-reader user reaching the trigger hears "Sin resultados" or a disabled control and
+ // never meets the reason. Priority order and the co-firing rules live in cargoNoteId().
+ const describedBy = cargoNoteId(
+ { noCargos, locked, takedown: takedownOnly, mintPending },
+ NOTE_IDS,
+ );
const comisionOptions = positions
.filter((p) => p.active && p.category === "Comision")
.map((p) => ({ value: p.id, label: p.sigla ? `${p.sigla} — ${p.title}` : p.title }));
@@ -88,6 +126,7 @@ export function MemberPositionsForm({
onChange={field.onChange}
placeholder="Sin cargo"
disabled={locked}
+ aria-describedby={describedBy}
/>
{takedownOnly && (
)}
/>
{locked && (
-
- Solo un Admin puede cambiar los cargos de un miembro cuyo cargo otorga permisos.
+
+ Solo un administrador puede cambiar los cargos de un miembro cuyo cargo otorga permisos.
)}
+ {/* Suppressed while locked: the picker is disabled there, so nothing about what the save
+ would mint is actionable. */}
+ {!locked && mintPending && }
{takedownOnly && (
-
- Este cargo es del Comité Ejecutivo Local: solo un Admin puede asignarlo. Puedes quitárselo
- con «Quitar cargo» y guardar, o elegir otro cargo.
+
+ Este cargo es del Comité Ejecutivo Local: solo un administrador puede asignarlo. Puedes
+ quitárselo con «Quitar cargo» y guardar, o elegir otro cargo.
{formError}
diff --git a/apps/backstage/src/features/members/components/member-profile-page.test.tsx b/apps/backstage/src/features/members/components/member-profile-page.test.tsx
new file mode 100644
index 00000000..95b31d1c
--- /dev/null
+++ b/apps/backstage/src/features/members/components/member-profile-page.test.tsx
@@ -0,0 +1,512 @@
+import { describe, expect, it, vi, afterEach, beforeEach } from "vitest";
+import { act, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import { Timestamp } from "firebase/firestore";
+import { currentTermKey, type Member, type Position } from "@luminova/types";
+import type { AuthClaims } from "@luminova/auth/roles";
+import { roleClaims } from "@luminova/auth/test-helpers";
+
+function member(over: Partial = {}): Member {
+ return {
+ id: "m1",
+ name: "Ana Gómez",
+ email: "ana@jci.bo",
+ joinDate: Timestamp.now(),
+ birthdate: Timestamp.now(),
+ status: "Activo",
+ profilePicture: null,
+ totalPoints: 0,
+ active: true,
+ deletedAt: null,
+ ...over,
+ };
+}
+
+const memberQuery = {
+ data: member(),
+ isLoading: false,
+ isError: false,
+ error: null,
+ refetch: vi.fn(),
+};
+
+vi.mock("@tanstack/react-router", async (orig) => ({
+ ...(await orig()),
+ getRouteApi: () => ({ useParams: () => ({ memberId: "m1" }) }),
+ Link: (props: { to: string; children: ReactNode }) => {props.children},
+}));
+
+const POWER_CARGO: Position = {
+ id: "pos-power",
+ title: "Secretario",
+ titleFemale: "Secretaria",
+ category: "CEL",
+ grants: ["Secretary"],
+ term: null,
+ sigla: null,
+ description: "",
+ active: true,
+ deletedAt: null,
+};
+const positionsQuery = { data: [POWER_CARGO] as Position[] | undefined, isError: false };
+
+vi.mock("../hooks/use-member", () => ({ useMember: () => memberQuery }));
+vi.mock("../../positions/hooks/use-positions", () => ({ usePositions: () => positionsQuery }));
+vi.mock("../hooks/use-member-points", () => ({ useMemberPoints: () => ({ data: null }) }));
+vi.mock("../hooks/use-member-participations", () => ({
+ useMemberParticipations: () => ({ data: [] }),
+}));
+vi.mock("../hooks/use-member-points-by-term", () => ({
+ useMemberPointsByTerm: () => ({ data: [] }),
+}));
+vi.mock("../../activities/hooks/use-activities-by-term", () => ({
+ useActivitiesByTerm: () => ({ data: [] }),
+}));
+vi.mock("../../initiatives/hooks/use-initiatives-by-term", () => ({
+ useInitiativesByTerm: () => ({ data: [] }),
+}));
+vi.mock("../hooks/use-update-member", () => ({
+ useUpdateMember: () => ({ mutateAsync: vi.fn() }),
+}));
+vi.mock("../hooks/use-set-member-positions", () => ({
+ useSetMemberPositions: () => ({ mutateAsync: vi.fn() }),
+}));
+vi.mock("../../../lib/auth/auth", () => ({
+ useAuth: () => ({ user: { uid: "admin" }, claims: { roles: ["Admin"] } }),
+}));
+vi.mock("../../../lib/auth/request-password-reset", () => ({
+ requestPasswordReset: vi.fn().mockResolvedValue(undefined),
+}));
+
+// The REAL useProvisionMemberLogin runs here, with only its two edges mocked: the callable and
+// the reset mail. Faking the hook itself is what let the "mail sent from a component-scoped
+// onSuccess" bug survive — a hand-written fake that invokes `opts.onSuccess` unconditionally
+// models a TanStack mutation that always has listeners, which is precisely the thing that is
+// not true. vi.hoisted because the factories run at import time.
+const { callable } = vi.hoisted(() => ({ callable: vi.fn() }));
+vi.mock("firebase/functions", () => ({ httpsCallable: () => callable }));
+vi.mock("@luminova/firebase/functions", () => ({ getFunctionsService: () => ({}) }));
+
+import { MemberProfilePage } from "./member-profile-page";
+import { AbilityProvider } from "../../../lib/authz/ability-context";
+import { requestPasswordReset } from "../../../lib/auth/request-password-reset";
+
+const mockedRequestPasswordReset = vi.mocked(requestPasswordReset);
+
+/** What beacon is pretending to return. The MAIL's outcome is the other knob
+ * (`mockedRequestPasswordReset`); together they decide whether a fallback link exists. */
+function provisionResolvesWith(result: { email: string; actionLink: string }) {
+ callable.mockResolvedValue({ data: result });
+}
+
+function pageTree(claims: AuthClaims, queryClient: QueryClient) {
+ return (
+
+
+
+
+
+ );
+}
+
+function renderPage(claims: AuthClaims = roleClaims("Admin")) {
+ // The sidebar panels still run their own real queries (roles, etc.); a throwaway client with
+ // retries off keeps them from retrying against a mock-less Firestore for the whole test.
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const view = render(pageTree(claims, queryClient));
+ return {
+ ...view,
+ /** Re-render the SAME tree after mutating `memberQuery.data` — what the real page does when
+ * `useMember` refetches. Identical element type at the identical position, so React keeps
+ * the subtree mounted and any state it holds survives; that is precisely the property
+ * under test below. */
+ refetchMember: () => view.rerender(pageTree(claims, queryClient)),
+ };
+}
+
+describe("MemberProfilePage — InviteAccess", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockedRequestPasswordReset.mockResolvedValue(undefined);
+ memberQuery.data = member();
+ });
+
+ // BLOCKING: the reset MAIL is the delivery path for every new login — a stated owner
+ // requirement that mail goes out for every new user. Returning an action link (which beacon
+ // does only for an ADMIN caller) used to short-circuit it with an early `return`, so this was
+ // the one surface where an Admin's invite sent nothing and the member waited for a mail that
+ // never came. The link is a manual FALLBACK on top, never a substitute.
+ it("BLOCKING: sends the reset mail even when beacon returns an action link", async () => {
+ provisionResolvesWith({ email: "ana@jci.bo", actionLink: "https://example.com/link" });
+ renderPage();
+ await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" }));
+ await waitFor(() => expect(mockedRequestPasswordReset).toHaveBeenCalledWith("ana@jci.bo"));
+ expect(mockedRequestPasswordReset).toHaveBeenCalledTimes(1);
+ expect(await screen.findByText("Invitación enviada por correo.")).toBeInTheDocument();
+ // BLOCKING, and the opposite of what this line used to assert: the link is NOT offered
+ // once the mail goes out. Firebase keeps only the most recent password-reset oobCode
+ // valid, so `sendPasswordResetEmail` above invalidated the one `actionLink` carries —
+ // offering it under "si no le llega el correo" hands the Admin a link that fails with
+ // auth/invalid-action-code, on the branch where the copy promises it works.
+ expect(screen.queryByRole("button", { name: /Copiar enlace/ })).not.toBeInTheDocument();
+ expect(screen.queryByText("https://example.com/link")).not.toBeInTheDocument();
+ });
+
+ it("sends the reset mail when beacon withholds the link (delegate caller)", async () => {
+ provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" });
+ renderPage();
+ await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" }));
+ await waitFor(() => expect(mockedRequestPasswordReset).toHaveBeenCalledWith("ana@jci.bo"));
+ expect(await screen.findByText("Invitación enviada por correo.")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /Copiar enlace/ })).not.toBeInTheDocument();
+ });
+
+ // BLOCKING: the dialog now exists ONLY on the mail-failure branch, so it must say so itself.
+ // Its modal `aria-hidden` takes the header alert out of the accessibility tree while it is
+ // open, and a dialog that only said "comparte este enlace" left a screen-reader user with no
+ // way to learn the mail had failed at all.
+ it("BLOCKING: the link dialog states the mail failed, not only the header", async () => {
+ provisionResolvesWith({ email: "ana@jci.bo", actionLink: "https://example.com/link" });
+ mockedRequestPasswordReset.mockRejectedValue(new Error("network"));
+ renderPage();
+ await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" }));
+ const dialog = await screen.findByRole("dialog");
+ expect(within(dialog).getByText(/No se pudo enviar el correo/)).toBeInTheDocument();
+ expect(within(dialog).getByText("https://example.com/link")).toBeInTheDocument();
+ // The header keeps its own copy for after the dialog is dismissed. getByText, not
+ // getByRole("alert"): the open modal's aria-hidden takes that alert out of the
+ // accessibility tree — which is the whole reason the dialog has to say it too.
+ expect(
+ screen.getByText(
+ "Se creó el acceso, pero no se pudo enviar el correo. Comparte el enlace manualmente.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("points at an administrator when the mail fails and there is no link to share", async () => {
+ provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" });
+ mockedRequestPasswordReset.mockRejectedValue(new Error("network"));
+ renderPage();
+ await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Se creó el acceso, pero no se pudo enviar el correo. Pídele a un administrador que lo reenvíe.",
+ );
+ });
+
+ // BLOCKING: the mail is INSIDE the mutation, so `isPending` covers it. It used to be a
+ // floating promise the mutation did not track, so the button re-enabled the moment the
+ // CALLABLE resolved and a second click could interleave — attempt one's mail resolving into
+ // `sent` while attempt two's rejected into `error`, leaving the header asserting both. The
+ // window is closed structurally now rather than by a `sending` flag beside it, and this is
+ // what pins that: the callable has already resolved here and the button is still disabled.
+ it("BLOCKING: stays disabled until the reset mail settles, not just the callable", async () => {
+ provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" });
+ let settleMail = () => {};
+ mockedRequestPasswordReset.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ settleMail = () => resolve();
+ }),
+ );
+ renderPage();
+ await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" }));
+ await waitFor(() => expect(mockedRequestPasswordReset).toHaveBeenCalled());
+ expect(screen.getByRole("button", { name: "Generando…" })).toBeDisabled();
+ await act(async () => {
+ settleMail();
+ });
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Invitar acceso" })).toBeEnabled(),
+ );
+ expect(screen.getByText("Invitación enviada por correo.")).toBeInTheDocument();
+ });
+
+ // The invariant the `attempt` ref and the resets exist to hold, asserted across the retry
+ // that the re-enabled button makes reachable: the header must never claim a failure and a
+ // success at the same time. `invite()` clears both up front, so the second attempt's outcome
+ // fully replaces the first one's rather than accumulating beside it.
+ it("BLOCKING: never shows the sent confirmation and the failure alert together", async () => {
+ provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" });
+ mockedRequestPasswordReset.mockRejectedValue(new Error("network"));
+ renderPage();
+ await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" }));
+ expect(await screen.findByRole("alert")).toBeInTheDocument();
+ expect(screen.queryByText("Invitación enviada por correo.")).not.toBeInTheDocument();
+
+ mockedRequestPasswordReset.mockResolvedValue(undefined);
+ await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" }));
+ expect(await screen.findByText("Invitación enviada por correo.")).toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ // Dismissing the dialog must make it STAY dismissed. It is `open={link !== null && !dismissed}`
+ // — derived from the mutation's own data plus one flag — so a dismiss that did not set the
+ // flag would leave a dialog impossible to close at all.
+ it("BLOCKING: closes the link dialog, and it stays closed", async () => {
+ provisionResolvesWith({ email: "ana@jci.bo", actionLink: "https://example.com/link" });
+ mockedRequestPasswordReset.mockRejectedValue(new Error("network"));
+ renderPage();
+ await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" }));
+ expect(await screen.findByRole("dialog")).toBeInTheDocument();
+ expect(screen.getByText("https://example.com/link")).toBeInTheDocument();
+ await userEvent.keyboard("{Escape}");
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+ expect(screen.queryByText("https://example.com/link")).not.toBeInTheDocument();
+ });
+
+ // A second attempt must not re-open the dialog on the FIRST attempt's credential — an action
+ // link is a bearer credential for the account. The mutation replaces `data` wholesale, and
+ // `dismissed` is reset per click, so the only link that can render is the current one's.
+ it("BLOCKING: a later successful invite does not resurrect the previous action link", async () => {
+ provisionResolvesWith({ email: "ana@jci.bo", actionLink: "https://example.com/link" });
+ mockedRequestPasswordReset.mockRejectedValue(new Error("network"));
+ renderPage();
+ await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" }));
+ expect(await screen.findByRole("dialog")).toBeInTheDocument();
+ await userEvent.keyboard("{Escape}");
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+
+ mockedRequestPasswordReset.mockResolvedValue(undefined);
+ await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" }));
+ await waitFor(() =>
+ expect(screen.getByText("Invitación enviada por correo.")).toBeInTheDocument(),
+ );
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(screen.queryByText("https://example.com/link")).not.toBeInTheDocument();
+ });
+});
+
+// Guardrail #3: loading, error and absent are three states. This page reads `positions` for two
+// unrelated jobs — the cargo editors and `memberProvisionBlocked` — and BOTH treat "absent" as
+// "keep waiting". On a failed query nothing ever lands, so the page silently loses the form and
+// the invite button with no error and no retry anywhere.
+describe("MemberProfilePage — the positions query failed", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ memberQuery.data = member();
+ positionsQuery.data = undefined;
+ positionsQuery.isError = true;
+ });
+
+ afterEach(() => {
+ positionsQuery.data = [POWER_CARGO];
+ positionsQuery.isError = false;
+ });
+
+ it("BLOCKING: says the catalog failed instead of rendering no form at all", () => {
+ renderPage();
+ expect(screen.getByText(/No se pudo cargar el catálogo de cargos/)).toBeInTheDocument();
+ });
+
+ // `memberProvisionBlocked` fails CLOSED on an unresolvable cargo, which is the right
+ // direction — but "we could not check" must not render as "not allowed", silently.
+ it("BLOCKING: tells a delegate why the invite affordance is missing", () => {
+ renderPage({ roles: ["Member"], perms: ["read:Member", "create:MemberLogin"] });
+ expect(screen.queryByRole("button", { name: /acceso/ })).not.toBeInTheDocument();
+ expect(
+ screen.getByText(/no podemos verificar si este miembro puede recibir acceso/),
+ ).toBeInTheDocument();
+ });
+
+ // An Admin is subject to none of those refusals, so the failed catalog cannot mislead them —
+ // the button stays, and they get the form-level notice only.
+ it("still offers the invite to an Admin", () => {
+ renderPage();
+ expect(screen.getByRole("button", { name: "Invitar acceso" })).toBeInTheDocument();
+ });
+});
+
+// A successful invite makes `memberProvisionBlocked` TRUE — beacon writes `member.uid`, and
+// `hasLogin` is the first clause of the gate. So the flag that decides whether to offer the
+// button flips as a RESULT of pressing it. It therefore cannot gate the mount.
+describe("MemberProfilePage — InviteAccess survives its own success", () => {
+ // A delegate, not an Admin: memberProvisionBlocked short-circuits to false for an Admin, so
+ // the flag never flips for them and none of this is reachable.
+ const DELEGATE: AuthClaims = { roles: ["Member"], perms: ["read:Member", "create:MemberLogin"] };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockedRequestPasswordReset.mockResolvedValue(undefined);
+ memberQuery.data = member();
+ });
+
+ // BLOCKING: the whole finding. beacon created the login but the reset MAIL failed, and beacon
+ // withholds the action link from a delegate — so this alert is the ONLY notice anywhere that
+ // an account now exists with no password mail sent. Gating the mount on `!inviteBlocked`
+ // unmounted the component on the very next refetch and deleted that notice, leaving a page
+ // that looks like nothing happened, on a member who can no longer be invited.
+ it("BLOCKING: keeps the mail-failure alert after `blocked` flips true", async () => {
+ provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" });
+ mockedRequestPasswordReset.mockRejectedValue(new Error("network"));
+ const { refetchMember } = renderPage(DELEGATE);
+
+ // Not blocked yet: no uid, no grants, no seat — the button is offered.
+ expect(screen.getByRole("button", { name: "Invitar acceso" })).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" }));
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent(
+ "Se creó el acceso, pero no se pudo enviar el correo. Pídele a un administrador que lo reenvíe.",
+ );
+
+ // What beacon actually did: the member now carries a uid, so the next refetch blocks.
+ memberQuery.data = member({ uid: "minted-uid" });
+ refetchMember();
+
+ // The BUTTON is gone — the callable would refuse a second attempt from this caller…
+ expect(screen.queryByRole("button", { name: /acceso/ })).not.toBeInTheDocument();
+ // …and the alert is STILL the same node, not a re-created one: unmounting InviteAccess
+ // would have reset `error` to null and rendered nothing at all.
+ expect(screen.getByRole("alert")).toBe(alert);
+ expect(screen.getByRole("alert")).toHaveTextContent(/no se pudo enviar el correo/);
+ });
+
+ // The success half of the same sequence. Same unmount, same erasure — the delegate would be
+ // left unable to tell a completed invite from one that never ran.
+ it("BLOCKING: keeps the sent confirmation after `blocked` flips true", async () => {
+ provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" });
+ const { refetchMember } = renderPage(DELEGATE);
+ await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" }));
+ expect(await screen.findByText("Invitación enviada por correo.")).toBeInTheDocument();
+
+ memberQuery.data = member({ uid: "minted-uid" });
+ refetchMember();
+
+ expect(screen.getByText("Invitación enviada por correo.")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /acceso/ })).not.toBeInTheDocument();
+ });
+
+ // The control: the gate is still a gate. A member who was ALREADY provisioned before the page
+ // loaded gets no button at all — `blocked` hides it on the first render too, not only after a
+ // flip, so moving it off the mount did not turn it into a no-op.
+ it("hides the button from the first render for an already-provisioned member", () => {
+ memberQuery.data = member({ uid: "existing-uid" });
+ renderPage(DELEGATE);
+ expect(screen.queryByRole("button", { name: /acceso/ })).not.toBeInTheDocument();
+ });
+
+ // …and the perm gate is untouched: it still decides whether InviteAccess mounts AT ALL.
+ it("mounts nothing for a caller without create:MemberLogin", () => {
+ renderPage({ roles: ["Member"], perms: ["read:Member"] });
+ expect(screen.queryByRole("button", { name: /acceso/ })).not.toBeInTheDocument();
+ });
+
+ // An ADMIN is never blocked, so the button stays offered as "Reenviar acceso" after the same
+ // flip — the branch that proves `blocked`, not merely `member.uid`, is what hides it.
+ it("keeps offering a resend to an Admin after the same flip", async () => {
+ provisionResolvesWith({ email: "ana@jci.bo", actionLink: "" });
+ const { refetchMember } = renderPage();
+ await userEvent.click(screen.getByRole("button", { name: "Invitar acceso" }));
+ expect(await screen.findByText("Invitación enviada por correo.")).toBeInTheDocument();
+
+ memberQuery.data = member({ uid: "minted-uid" });
+ refetchMember();
+
+ expect(screen.getByRole("button", { name: "Reenviar acceso" })).toBeInTheDocument();
+ expect(screen.getByText("Invitación enviada por correo.")).toBeInTheDocument();
+ });
+});
+
+// The four call sites all pass `allowReplacePowerCargo={isAdmin}` — a value the two form unit
+// tests receive as a prop and therefore cannot police. These cover the profile page's two, the
+// only place a delegate meets a seated member.
+describe("MemberProfilePage — cargo editor for a board-seat delegate", () => {
+ const term = currentTermKey();
+ const seatedOnPower = () =>
+ member({ positions: { [term]: { cargoId: POWER_CARGO.id, comisionIds: [] } } });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ memberQuery.data = seatedOnPower();
+ });
+
+ // update:BoardSeat lifts the NEW-side conjunct only. Passing it as allowReplacePowerCargo
+ // would open the picker on a write positionsAssignmentSafe() always denies.
+ it("BLOCKING: locks the full MemberForm's cargo picker for a delegate", () => {
+ renderPage({ roles: ["Member"], perms: ["update:Member", "update:BoardSeat"] });
+ expect(screen.getByLabelText("Cargo")).toBeDisabled();
+ expect(screen.getByText(/Solo un administrador puede cambiar el cargo/i)).toBeInTheDocument();
+ });
+
+ it("BLOCKING: locks the positions-only form's cargo picker for a delegate", () => {
+ renderPage({ roles: ["Member"], perms: ["update:Position", "update:BoardSeat"] });
+ expect(screen.getByLabelText("Cargo")).toBeDisabled();
+ expect(screen.getByText(/Solo un administrador puede cambiar los cargos/i)).toBeInTheDocument();
+ });
+
+ it("leaves both open for an Admin on the same seat", () => {
+ renderPage();
+ expect(screen.getByLabelText("Cargo")).not.toBeDisabled();
+ expect(screen.queryByText(/Solo un administrador puede cambiar/i)).not.toBeInTheDocument();
+ });
+});
+
+// The finding, at the call site that produces the argument. `isSelfAssignment` is computed
+// HERE — `member.uid !== undefined && member.uid === uid` — so the two form unit tests, which
+// receive it as a prop, cannot police it. The mocked useAuth returns uid "admin"; a member doc
+// carrying that same uid IS the caller.
+describe("MemberProfilePage — a delegate seating themselves", () => {
+ const MINT_PENDING_COPY = /no se aplicarán hasta que un administrador confirme la asignación/i;
+
+ // Vacant, not seated: the delegate is picking a cargo nobody holds, which is precisely the
+ // write firestore.rules' boardSeatDelegate() permits. Seating them on an occupied power
+ // cargo would lock the picker and never reach the note.
+ const self = () => member({ uid: "admin" });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ memberQuery.data = self();
+ });
+
+ // BLOCKING: a delegate holding update:Position + update:BoardSeat opens THEIR OWN profile and
+ // seats themselves on a vacant Secretario — a power cargo that does NOT grant Admin. The
+ // write succeeds, the seat publishes to the Directiva, and resolveTrustedGrants mints nothing
+ // because `selfAssigned && !assignerIsAdmin`. syncMemberClaims is a background trigger, so
+ // nothing in the save path can report it. Before the fix the warning keyed on
+ // `grants.includes("Admin")` alone and no note rendered at all.
+ it("BLOCKING: warns on the positions-only form when the delegate is the member", async () => {
+ renderPage({ roles: ["Member"], perms: ["update:Position", "update:BoardSeat"] });
+ expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument();
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ await userEvent.click(await screen.findByText(/Secretari[ao]/));
+ expect(screen.getByText(MINT_PENDING_COPY)).toBeInTheDocument();
+ });
+
+ it("BLOCKING: warns on the full member form when the delegate is the member", async () => {
+ renderPage({ roles: ["Member"], perms: ["update:Member", "update:BoardSeat"] });
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ await userEvent.click(await screen.findByText(/Secretari[ao]/));
+ expect(screen.getByText(MINT_PENDING_COPY)).toBeInTheDocument();
+ });
+
+ // The control that keeps the page's `isSelfAssignment` expression honest: the SAME delegate
+ // on SOMEONE ELSE's profile (member.uid !== the caller's uid) is silent, because
+ // update:BoardSeat does mint a Secretary seat for another member. If the page hardcoded
+ // `true`, or compared the wrong pair of ids, this is the case that catches it.
+ it("BLOCKING: stays silent for that delegate on someone else's profile", async () => {
+ memberQuery.data = member({ uid: "someone-else" });
+ renderPage({ roles: ["Member"], perms: ["update:Position", "update:BoardSeat"] });
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ await userEvent.click(await screen.findByText(/Secretari[ao]/));
+ expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument();
+ });
+
+ // …and a member with NO uid at all is not the caller either, however the comparison is
+ // written. `undefined === undefined` would be true for a signed-out caller; the page guards
+ // that explicitly, and an unlinked member is the commonest doc shape in the collection.
+ it("BLOCKING: stays silent for a member who has no uid", async () => {
+ memberQuery.data = member();
+ renderPage({ roles: ["Member"], perms: ["update:Position", "update:BoardSeat"] });
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ await userEvent.click(await screen.findByText(/Secretari[ao]/));
+ expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument();
+ });
+
+ it("stays silent for an ADMIN on their own profile — they mint what they assign", async () => {
+ renderPage();
+ await userEvent.click(screen.getByLabelText("Cargo"));
+ await userEvent.click(await screen.findByText(/Secretari[ao]/));
+ expect(screen.queryByText(MINT_PENDING_COPY)).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/backstage/src/features/members/components/member-profile-page.tsx b/apps/backstage/src/features/members/components/member-profile-page.tsx
index fd4da41e..96f41e73 100644
--- a/apps/backstage/src/features/members/components/member-profile-page.tsx
+++ b/apps/backstage/src/features/members/components/member-profile-page.tsx
@@ -16,7 +16,6 @@ import { useActivitiesByTerm } from "../../activities/hooks/use-activities-by-te
import { useInitiativesByTerm } from "../../initiatives/hooks/use-initiatives-by-term";
import { summarizeParticipations } from "../lib/participation-summary";
import { pointsRank } from "../../../lib/points-rank";
-import { requestPasswordReset } from "../../../lib/auth/request-password-reset";
import { useProvisionMemberLogin } from "../hooks/use-provision-member-login";
import { useUpdateMember } from "../hooks/use-update-member";
import { useSetMemberPositions } from "../hooks/use-set-member-positions";
@@ -27,9 +26,11 @@ import { MemberPermissionsPanel } from "./member-permissions-panel";
import { MemberPositionHistory } from "./member-position-history";
import { MemberPointsSummary } from "./member-points-summary";
import { ParticipationLedger } from "./participation-ledger";
-import { effectiveRoles } from "../lib/member-permissions";
+import { effectiveRoles, isSelfMember } from "../lib/member-permissions";
import { memberEditMode } from "../lib/member-edit-gate";
import { provisionErrorMessage } from "../lib/provision-error";
+import { memberProvisionBlocked } from "../lib/provision-gate";
+import { useCopyToClipboard } from "../../../lib/use-copy-to-clipboard";
import { memberFormDefaults } from "../lib/member-form-defaults";
// qrcode.react (~13 kB gz) lazy so it leaves the always-loaded index shell.
@@ -56,7 +57,11 @@ export function MemberProfilePage() {
const gate = useCan();
const uid = useAuth().user?.uid;
const { data: member, isLoading, isError, error, refetch } = useMember(memberId);
- const { data: positions } = usePositions();
+ // isError, not just data: `memberProvisionBlocked` fails CLOSED on an unresolvable cargo, so
+ // a failed catalog query (a rules regression, permission-denied — which TanStack does not
+ // retry) silently removes the invite affordance from every seated member with nothing said.
+ // Guardrail #3: loading, error and absent are three states, and only one of them is "wait".
+ const { data: positions, isError: positionsFailed } = usePositions();
const { data: points } = useMemberPoints(memberId, termId);
const { data: participations } = useMemberParticipations(memberId, termId);
const { data: allPoints } = useMemberPointsByTerm(termId);
@@ -107,7 +112,11 @@ export function MemberProfilePage() {
const showPositionsOnly = editMode === "positions";
// Member editing is split across two rules lanes; point the caller at the other one
// instead of leaving "where do I edit this" to depend on whose profile it is.
- const isSelf = member.uid !== undefined && member.uid === uid;
+ const isSelf = isSelfMember(member, uid);
+ // Fails closed while the catalog is still loading: an unresolvable cargo counts as
+ // power-conferring, so a delegate sees the invite appear once positions land rather than
+ // seeing it offered and then denied. An Admin is unaffected — the predicate short-circuits.
+ const inviteBlocked = memberProvisionBlocked(member, (id) => positionsById.get(id), gate.isAdmin);
const handleEdit = (data: MemberInput) =>
updateMember.mutateAsync({
@@ -130,11 +139,35 @@ export function MemberProfilePage() {
{member.status && {member.status}}
{/* provisionMemberLogin is requireAdminOrPerm(create:MemberLogin) — the Admin
- role or that exact code, never the manage:all perm. */}
- {/* `!member.uid` mirrors beacon's adoption guard: a delegate may only mint a NEW
- login, so "Reenviar acceso" would 403 on every click for them. */}
-
-
+ role or that exact code, never the manage:all perm. memberProvisionBlocked
+ mirrors every refusal the callable applies to a non-Admin, so a delegate is not
+ shown a button that 403s on every click. Same predicate as the row menu and the
+ invite drawer, deliberately. */}
+ {/* Gated on the PERM only. `inviteBlocked` goes to InviteAccess as a prop rather
+ than gating the mount, because a successful invite FLIPS it: beacon writes
+ member.uid, the next refetch makes memberProvisionBlocked true (hasLogin), and
+ unmounting here would destroy the component's own "enviada" / "no se pudo
+ enviar el correo" state mid-flight — deleting, for a delegate, the only notice
+ that the account exists with no password mail sent. */}
+
+ {/* An Admin is subject to none of the refusals `inviteBlocked` mirrors, so the
+ failed catalog cannot mislead them. For everyone else it decides the
+ affordance, and "we could not check" must not render as "not allowed". */}
+ {positionsFailed && !gate.isAdmin ? (
+
+ No se pudo cargar el catálogo de cargos, así que no podemos verificar si este
+ miembro puede recibir acceso. Recarga la página.
+
+ ) : (
+ /* key: the mount gate used to be `!inviteBlocked`, which ALSO happened to reset
+ this component between members. It no longer does, and TanStack Router renders
+ the same MemberProfilePage instance across a /members/A → /members/B
+ navigation (no key on Match), while `isLoading` skips the unmount whenever B
+ is warm in cache. Without this, B's header shows A's "Invitación enviada".
+ Stable across the refetch that sets member.uid, so it does not reintroduce
+ the flip-erases-its-own-result bug. */
+
+ )}
}
@@ -144,18 +177,39 @@ export function MemberProfilePage() {
{positions && canEdit && (
+ {/* key, for the same reason as InviteAccess above and MemberDrawer's copy: RHF
+ reads `defaultValues` once at mount, and this page is NOT remounted across a
+ /members/A → /members/B param change when B is warm in cache. Without it the
+ form keeps A's name/email/status while `member.id` and `handleEdit` have moved
+ to B — «Guardar cambios» then writes A's identity onto B's document. */}
)}
+ {/* Both editors below are gated on `positions` being present. Without this an editor
+ whose catalog query FAILED gets a page with no form and no explanation — the
+ absent/error conflation guardrail #3 names. */}
+ {positionsFailed && (canEdit || showPositionsOnly) && (
+
+
+ No se pudo cargar el catálogo de cargos, así que el formulario no está disponible.
+ Recarga la página.
+
+
+ )}
+
{isSelf && !canEdit && (
@@ -174,10 +228,16 @@ export function MemberProfilePage() {
Cargos
+ {/* Same reason as MemberForm above: without the key this form would save A's
+ cargo and comisiones onto B. */}
(null);
- const [sent, setSent] = useState(false);
- const [error, setError] = useState(null);
+ const [dismissed, setDismissed] = useState(false);
+ const { copyState, copy, resetCopyState } = useCopyToClipboard();
const label = member.uid ? "Reenviar acceso" : "Invitar acceso";
+ const result = provision.data;
+ // Only present when the mail did NOT go out — the hook nulls it otherwise, because sending
+ // the mail invalidates this oobCode. See InviteResult.fallbackLink.
+ const link = result?.fallbackLink ?? null;
+ const error = provision.isError
+ ? provisionErrorMessage(provision.error, "No se pudo generar el acceso.")
+ : result && !result.emailSent
+ ? "Se creó el acceso, pero no se pudo enviar el correo. " +
+ (result.fallbackLink
+ ? "Comparte el enlace manualmente."
+ : "Pídele a un administrador que lo reenvíe.")
+ : null;
- // beacon withholds the action link from a non-Admin caller (it is a bearer credential for
- // the account). The client then does what the invite drawer already does — send the reset
- // mail itself through the unprivileged sendPasswordResetEmail — so a delegate's invite
- // still lands. Without this the delegate got an empty code block and a copy button that
- // copied nothing, with the account already created and no way to set a password.
const invite = () => {
- setError(null);
- provision.mutate(member.id, {
- onSuccess: (result) => {
- if (result.actionLink) {
- setLink(result.actionLink);
- setOpen(true);
- return;
- }
- setSent(false);
- void requestPasswordReset(result.email)
- .then(() => setSent(true))
- .catch((err: unknown) => {
- console.error("No se pudo enviar el correo de acceso", err);
- setError(
- "Se creó el acceso, pero no se pudo enviar el correo. Pídele a un Admin que lo reenvíe.",
- );
- });
- },
- onError: (err) => setError(provisionErrorMessage(err, "No se pudo generar el acceso.")),
- });
+ setDismissed(false);
+ resetCopyState();
+ provision.mutate(member.id);
};
return (
<>
-
+ {/* The BUTTON goes away when the callable would refuse; the feedback below does not.
+ `blocked` becomes true the moment this invite succeeds (the member now has a uid and
+ the hook invalidates the query), so gating the whole component on it would erase the
+ result of the click that set it. */}
+ {!blocked && (
+
+ )}
{error && (