Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ function EditBody({
onSubmit: (data: MemberInput) => Promise<void>;
}) {
const { onUpload, onRemove } = useMemberPhoto(member.id);
const { canAssignPowerGrants } = useCan();
const { canAssignBoardSeat } = useCan();
return (
<div className="flex flex-col gap-6">
<ImageUploader
Expand All @@ -150,7 +150,7 @@ function EditBody({
positions={positions}
defaultValues={memberFormDefaults(member)}
submitLabel="Guardar"
allowPowerGrants={canAssignPowerGrants}
allowPowerGrants={canAssignBoardSeat}
onSubmit={onSubmit}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,52 @@ describe("MemberForm", () => {
expect(screen.queryByText("Comisión de Eventos")).not.toBeInTheDocument();
});

// The empty-state the delegation exists to explain. A chapter whose every cargo carries
// grants (which is the real production shape) leaves a non-delegate with zero options, and
// the bare Combobox "Sin resultados" cannot be told apart from an empty catalog.
it("explains an empty cargo list to a non-delegate, and stays silent for a delegate", async () => {
const gatedCargo = (
id: string,
category: Position["category"],
grants: Position["grants"],
): Position => ({
id,
title: id,
titleFemale: id,
category,
grants,
term: null,
sigla: null,
description: "",
active: true,
deletedAt: null,
});
const celOnly: Position[] = [
gatedCargo("pos-cel", "CEL", []),
gatedCargo("pos-power", "JDL", ["Membership"]),
];
const { unmount } = render(
<MemberForm
positions={celOnly}
submitLabel="Crear"
onSubmit={vi.fn()}
allowPowerGrants={false}
/>,
);
expect(screen.getByRole("note")).toHaveTextContent(/Asientos de directiva/);
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(
<MemberForm positions={celOnly} submitLabel="Crear" onSubmit={vi.fn()} allowPowerGrants />,
);
expect(screen.queryByRole("note")).not.toBeInTheDocument();
await userEvent.click(screen.getByLabelText("Cargo"));
expect(screen.queryByText("Sin resultados")).not.toBeInTheDocument();
});

// The admin half of memberSchemaFor: a member enrolled before memberNameValid() existed
// must stay editable. Without the per-member schema the form blocks on a name the admin
// never touched, making the rules' touched('name') affordance unreachable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import { avatarColor } from "../lib/member-display";
import {
cargoOptionsForEditor,
cargoTakedownOnly,
noAssignableCargos,
positionsLockedForNonAdmin,
} from "../lib/assignable-cargo";
import { NoAssignableCargosNote } from "./no-assignable-cargos-note";

interface MemberFormProps {
positions: Position[];
Expand Down Expand Up @@ -310,6 +312,9 @@ export function MemberForm({
igual.
</p>
)}
{noAssignableCargos({ cargoOptions, allowPowerGrants, locked: positionsLocked }) && (
<NoAssignableCargosNote />
)}
<Field
label="Fecha de ingreso"
htmlFor="joinDate"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ReactElement, ReactNode } from "react";
import { MemberInviteDrawer } from "./member-invite-drawer";
import { AbilityProvider } from "../../../lib/authz/ability-context";
import { pickDate } from "../../../test/pick-date";

// The drawer's "Enviar acceso" checkbox is Admin-only; render as Admin so the
// provisioning path under test is available.
function renderWithAbility(ui: ReactElement) {
// The drawer's "Enviar acceso" checkbox is gated on canProvisionLogin (Admin role OR the
// exact create:MemberLogin perm); default to Admin so the provisioning path under test is
// available, and parameterize for the delegation cases below.
function renderWithAbility(
ui: ReactElement,
claims: { roles: string[]; perms?: string[] } = { roles: ["Admin"], perms: ["manage:all"] },
) {
return render(ui, {
wrapper: ({ children }: { children: ReactNode }) => (
<AbilityProvider claims={{ roles: ["Admin"], perms: ["manage:all"] }} uid="admin">
<AbilityProvider claims={claims as never} uid="admin">
{children}
</AbilityProvider>
),
Expand Down Expand Up @@ -142,4 +147,88 @@ describe("MemberInviteDrawer", () => {
);
expect(screen.getByRole("button", { name: "Copiar enlace de acceso" })).toBeInTheDocument();
});

// --- create:MemberLogin delegation ---

const drawer = (
onProvision = vi.fn().mockResolvedValue({ email: "a@b.co", actionLink: "l" }),
) => ({
node: (
<MemberInviteDrawer
open
positions={[]}
onClose={() => {}}
onCreate={async () => "idD"}
onProvision={onProvision}
/>
),
onProvision,
});

it("shows 'Enviar acceso' to a create:MemberLogin delegate, defaulted ON, and provisions", async () => {
const { node, onProvision } = drawer();
renderWithAbility(node, { roles: ["Member"], perms: ["create:Member", "create:MemberLogin"] });
const checkbox = screen.getByLabelText("Enviar acceso a la app");
expect(checkbox).toBeChecked();
await fill();
fireEvent.click(screen.getByRole("button", { name: "Enviar invitación" }));
await waitFor(() => expect(onProvision).toHaveBeenCalledWith("idD"));
});

it("hides it from a member creator without the code, and never calls onProvision", async () => {
const { node, onProvision } = drawer();
renderWithAbility(node, { roles: ["Member"], perms: ["create:Member"] });
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());
expect(onProvision).not.toHaveBeenCalled();
});

it("BLOCKING: hides it from a manage:all perm holder without the Admin role", async () => {
// The render-then-403 this gate exists to stop: beacon's requireAdminOrPerm is an exact
// code test, so the wildcard would fail server-side after the member was already created.
const { node } = drawer();
renderWithAbility(node, { roles: ["Member"], perms: ["manage:all"] });
expect(screen.queryByLabelText("Enviar acceso a la app")).not.toBeInTheDocument();
});

it("does not attempt the invite when the cargo confers permissions and the caller is a delegate", async () => {
// 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(
<MemberInviteDrawer
open
positions={powerCargo as never}
onClose={() => {}}
onCreate={async () => "idB"}
onProvision={onProvision}
/>,
{ roles: ["Member"], perms: ["create:Member", "create:MemberLogin", "update:BoardSeat"] },
);
await fill();
await userEvent.click(screen.getByLabelText("Cargo"));
// positionTitle derives the female variant from the title when titleFemale is null, and
// fill() picks "Femenino" — so the rendered label is "Secretaria".
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(onProvision).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ interface MemberInviteDrawerProps {
}

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;
name: string;
email: string;
provisioned: boolean;
Expand All @@ -34,26 +37,28 @@ export function MemberInviteDrawer({
onCreate,
onProvision,
}: MemberInviteDrawerProps) {
// Provisioning login is Admin-role-only (provisionMemberLogin → requireAdmin). A
// non-Admin may still create the member; they just can't send access here, so hide
// the option and default it off — otherwise the provision step fails silently after
// the member is already created.
const { isAdmin, canAssignPowerGrants } = useCan();
// Provisioning login is the Admin role OR the create:MemberLogin perm
// (provisionMemberLogin → requireAdminOrPerm). A member creator without either may still
// create the member; they just can't send access here, so hide the option and default it
// off — otherwise the provision step fails silently after the member is already created.
const { canProvisionLogin, canAssignBoardSeat, isAdmin } = useCan();
const [done, setDone] = useState<DoneState | null>(null);
const [sendAccess, setSendAccess] = useState(isAdmin);
const [sendAccess, setSendAccess] = useState(canProvisionLogin);
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");

// 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
// OPENS — by then isAdmin is resolved — so an Admin's first invite doesn't silently
// OPENS — by then the flag is resolved — so a provisioner's first invite doesn't silently
// default "Enviar acceso" off. Won't clobber a manual toggle (deps stable while open).
// This matters MORE now than it did for a role gate: `perms` is minted by claims-sync and
// lands in the same late token, so a perm-derived flag is false for exactly as long.
useEffect(() => {
if (open) setSendAccess(isAdmin);
}, [open, isAdmin]);
if (open) setSendAccess(canProvisionLogin);
}, [open, canProvisionLogin]);

const reset = () => {
setDone(null);
setSendAccess(isAdmin);
setSendAccess(canProvisionLogin);
setCopyState("idle");
};

Expand All @@ -68,7 +73,15 @@ export function MemberInviteDrawer({
let emailSent = false;
let actionLink: string | null = null;
let errorDetail: string | null = null;
if (sendAccess) {
// 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) {
// 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
Expand All @@ -77,7 +90,7 @@ export function MemberInviteDrawer({
try {
const result = await onProvision(id);
provisioned = true;
actionLink = result.actionLink;
actionLink = result.actionLink || null;
try {
await requestPasswordReset(data.email);
emailSent = true;
Expand All @@ -91,6 +104,7 @@ export function MemberInviteDrawer({
}
}
setDone({
blockedByCargo: sendAccess && provisionBlocked,
name: data.name,
email: data.email,
provisioned,
Expand Down Expand Up @@ -118,32 +132,42 @@ export function MemberInviteDrawer({
<p className="text-ui-md text-ink-2">
{`Invitación enviada a ${done.email}. Recibirá un correo para crear su contraseña y acceder a la app.`}
</p>
) : done.blockedByCargo ? (
<p role="alert" className="text-ui-md text-error">
{`${done.name} fue creado, pero su cargo otorga permisos: solo un Admin puede enviarle el acceso. Pídeselo para completar la invitación.`}
</p>
) : done.provisioned && !done.emailSent ? (
<>
<p role="alert" className="text-ui-md text-error">
El correo no se pudo enviar. Comparte el enlace de acceso manualmente.
{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."}
</p>
{done.errorDetail && (
<p className="text-ui-xs text-ink-3">Detalle: {done.errorDetail}</p>
)}
<Button
as="button"
type="button"
variant="secondary"
onClick={() => {
navigator.clipboard
.writeText(done.actionLink ?? "")
.then(() => setCopyState("copied"))
.catch(() => setCopyState("failed"));
}}
className="w-full justify-center"
>
{copyState === "copied" ? "Enlace copiado" : "Copiar enlace de acceso"}
</Button>
{copyState === "failed" && (
<code className="text-ui-xs break-all select-all text-ink-2">
{done.actionLink}
</code>
{done.actionLink && (
<>
<Button
as="button"
type="button"
variant="secondary"
onClick={() => {
navigator.clipboard
.writeText(done.actionLink ?? "")
.then(() => setCopyState("copied"))
.catch(() => setCopyState("failed"));
}}
className="w-full justify-center"
>
{copyState === "copied" ? "Enlace copiado" : "Copiar enlace de acceso"}
</Button>
{copyState === "failed" && (
<code className="text-ui-xs break-all select-all text-ink-2">
{done.actionLink}
</code>
)}
</>
)}
</>
) : (
Expand Down Expand Up @@ -177,11 +201,11 @@ export function MemberInviteDrawer({
submitLabel="Enviar invitación"
pendingLabel="Enviando…"
showPreview
allowPowerGrants={canAssignPowerGrants}
allowPowerGrants={canAssignBoardSeat}
defaultValues={{ joinDate: today(), status: "Activo", cargoId: null, comisionIds: [] }}
onSubmit={handleSubmit}
>
{isAdmin && (
{canProvisionLogin && (
<Checkbox
checked={sendAccess}
onChange={setSendAccess}
Expand Down
Loading
Loading