diff --git a/apps/backstage/src/components/initiative-form.test.tsx b/apps/backstage/src/components/initiative-form.test.tsx index 12a389b3..6a568627 100644 --- a/apps/backstage/src/components/initiative-form.test.tsx +++ b/apps/backstage/src/components/initiative-form.test.tsx @@ -83,4 +83,44 @@ describe("InitiativeForm", () => { expect(screen.queryByLabelText("Estado")).not.toBeInTheDocument(); expect(screen.getByText(/no se puede reabrir/i)).toBeInTheDocument(); }); + + // `featured` curation is gated (rules' canCurateFeatured): Admin by role, everyone else by + // the update:Showcase perm. A non-curator must not even see the control — the write would + // be denied by firestore.rules, taking the whole save down with it. + it("renders the destacar checkbox only when the caller may curate", () => { + const { unmount } = render( + , + ); + expect(screen.getByLabelText(/destacar en \/programas/i)).toBeInTheDocument(); + unmount(); + + render( + , + ); + expect(screen.queryByLabelText(/destacar en \/programas/i)).not.toBeInTheDocument(); + }); + + it("hides the destacar checkbox when canFeature is not passed at all", () => { + render( + , + ); + expect(screen.queryByLabelText(/destacar en \/programas/i)).not.toBeInTheDocument(); + }); }); diff --git a/apps/backstage/src/components/initiative-form.tsx b/apps/backstage/src/components/initiative-form.tsx index 4d85cd27..ae180aa2 100644 --- a/apps/backstage/src/components/initiative-form.tsx +++ b/apps/backstage/src/components/initiative-form.tsx @@ -39,8 +39,11 @@ interface InitiativeFormProps { isSaving: boolean; onSubmit: (data: InitiativeInput) => void; lockStatus?: boolean; - /** Whether the caller may set `featured` — Admin/ProjectManager only, mirroring - * the rules' `featuredUpdateSafe`. A direction/perm editor sees it disabled. */ + /** Whether the caller may set `featured` — the Admin ROLE, or the `update:Showcase` + * PERM, mirroring the rules' `canCurateFeatured()`. Not a ProjectManager role check: + * the seed grants that role the perm, so deactivating the role now revokes curation, + * and a custom role carrying `update:Showcase` gains it. A direction/perm editor + * without either sees it disabled. */ canFeature?: boolean; } @@ -207,9 +210,10 @@ export function InitiativeForm({ )} - {/* `featured` curation is Admin/ProjectManager-only (rules' featuredUpdateSafe); - a non-curator can never set it here, so hide the control entirely. The form - still submits the initiative's current value (unchanged), which the rule allows. */} + {/* `featured` curation needs the Admin role or the `update:Showcase` perm (rules' + canCurateFeatured); a non-curator can never set it here, so hide the control + entirely. The form still submits the initiative's current value (unchanged), + which the rule allows. */} {canFeature && (
{ expect(canSee("/positions", claimsFor("Treasury"))).toBe(false); }); + it("admits an update:Position custom role to /positions, matching the catalog's own rule", () => { + // The catalog arms are canDo('update','Position') / canDo('create','Position'), and + // canDo treats manage:Position as satisfying update:Position — so keying `orCan` on + // `update` widens nothing the rules did not already allow, and stops the nav from + // hiding a page whose writes this principal can actually make (guardrail #6). + const orgChartEditor: AuthClaims = { roles: [], perms: ["update:Position"] }; + expect(canSee("/positions", orgChartEditor)).toBe(true); + // read:Position is what a plain Member carries; it must still not open the page. + expect(canSee("/positions", { roles: [], perms: ["read:Position"] })).toBe(false); + }); + + it("gates /members on read:Member alone — update:Position opens nothing here", () => { + // Cargo assignment happens ON /members (the member roster), and the nav probes + // read:Member there. So the perm that carries the members-positions LANE is inert for + // reaching the page: a custom role holding only update:Position cannot get to the + // capability the owner-op hands it, which is why owner-op 1 mandates BOTH perms. + // Both halves are load-bearing and falsifiable: read:Member alone is what opens the + // page (drop it from the nav gate and the first line goes red), update:Position alone + // is what does not (add it as an `orCan` and the second goes red). The pair assertion + // this replaced was neither — read:Member already satisfied it, so deleting + // update:Position from it could not turn anything red. + expect(canSee("/members", { roles: [], perms: ["read:Member"] })).toBe(true); + expect(canSee("/members", { roles: [], perms: ["update:Position"] })).toBe(false); + }); + it("shows /notificaciones to a compose-only principal (create:Notification, no read)", () => { // The page's history list gates on read:Notification, but a compose-only principal // holds only create:Notification. The item's `subject: Notification` read would hide diff --git a/apps/backstage/src/components/nav-config.ts b/apps/backstage/src/components/nav-config.ts index 5ad740df..8f6a2ae9 100644 --- a/apps/backstage/src/components/nav-config.ts +++ b/apps/backstage/src/components/nav-config.ts @@ -124,10 +124,13 @@ export const NAV_GROUPS: NavGroup[] = [ // Members can read Position (chip resolution on /me), and Membership shares // ONLY that same read grant — so no perm cleanly separates catalog viewers // from Members; hence the built-in allowlist. `orCan` re-admits a dynamic - // custom role that manages the org chart (manage:Position) but carries no - // built-in role name, so the route guard doesn't lock the perms system out. + // custom role that edits the org chart but carries no built-in role name, so + // the route guard doesn't lock the perms system out. Keyed on `update` to match + // the catalog's own rules (canDo('update','Position')), which canDo already + // treats manage:Position as satisfying — so this admits no principal the rules + // did not already let write. roles: ["Admin", "Membership", "ExecutiveCommittee"], - orCan: { action: "manage", subject: "Position" }, + orCan: { action: "update", subject: "Position" }, }, { to: "/permisos", label: "Permisos", icon: "lock", roles: ["Admin"] }, ], 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 24a9f55b..0fa5bdde 100644 --- a/apps/backstage/src/features/members/components/member-form.test.tsx +++ b/apps/backstage/src/features/members/components/member-form.test.tsx @@ -1,8 +1,9 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { Position } from "@luminova/types"; +import type { MemberInput, Position } from "@luminova/types"; import { MemberForm } from "./member-form"; +import { toMemberUpdateDoc } from "../repositories/member-mapper"; import { pickDate } from "../../../test/pick-date"; const positions: Position[] = [ @@ -91,8 +92,13 @@ describe("MemberForm", () => { expect(onSubmit).not.toHaveBeenCalled(); }); + // allowPowerGrants: pos-pres is a grant-free CEL cargo, which only an Admin may assign + // (rules' cargoAssignableByNonAdmin). This case is about the LABELS, so give it the + // authority that renders them all. it("shows gendered cargo labels and excludes comisiones from the cargo options", async () => { - render(); + render( + , + ); await userEvent.click(screen.getByRole("button", { name: "Femenino" })); await userEvent.click(screen.getByLabelText("Cargo")); expect(await screen.findByText("Presidenta")).toBeInTheDocument(); @@ -183,9 +189,12 @@ describe("MemberForm", () => { ); }); + // allowPowerGrants for the same reason: picking a CEL cargo at all is an Admin flow. it("locks comisiones as Comité Ejecutivo Local and clears them for a CEL cargo", 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" })); @@ -229,6 +238,148 @@ describe("MemberForm", () => { expect(await screen.findByText("Tesorero (inactivo)")).toBeInTheDocument(); }); + // Mirror of firestore.rules cargoAssignableByNonAdmin() on the CREATE lane + // (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(); + await userEvent.click(screen.getByLabelText("Cargo")); + expect(await screen.findByText("Director de Área")).toBeInTheDocument(); + expect(screen.queryByText("Presidente")).not.toBeInTheDocument(); + }); + + it("shows a grant-free CEL cargo to an Admin", async () => { + render( + , + ); + await userEvent.click(screen.getByLabelText("Cargo")); + expect(await screen.findByText("Presidente")).toBeInTheDocument(); + }); + + // The lock, not just the option list: every save re-stamps the assigned cargoId, so a + // non-Admin editing a member already seated on a grant-free CEL cargo is denied on the + // positions slot. Locking it keeps the bio fields savable (the mapper omits the + // unchanged slot) instead of failing the whole form with no explanation. + // BLOCKING: the rules conjuncts are asymmetric. Keeping a grant-free CEL seat is denied + // (`cargoAssignableByNonAdmin`), but CLEARING it is allowed on purpose — + // `currentCargoGrantsEmpty()` is not category-gated, because denying it "would strand a + // takedown behind an Admin". So the seat renders disabled rather than locked or dropped. + const celSeated = { + name: "Ana Pérez", + email: "ana@jci.bo", + gender: "Femenino" as const, + joinDate: "2020-03-15", + birthdate: "1992-07-15", + status: "Activo" as const, + cargoId: "pos-pres", + comisionIds: [], + }; + + 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(); + }); + + // Dropping the seat from the options handed it to the `(inactivo)` fallback, which re-added + // an ACTIVE cargo under an inactive label — and re-offered it to the very non-Admin whose + // write the rules reject. The two member forms must answer the same rules predicate. + it("BLOCKING: never labels the active grant-free CEL seat '(inactivo)' to a non-Admin", () => { + render( + , + ); + const trigger = screen.getByLabelText("Cargo"); + expect(trigger).toHaveTextContent("Presidente"); + expect(trigger).not.toHaveTextContent(/inactivo/i); + }); + + it("BLOCKING: reaches the takedown of a grant-free CEL seat and submits cargoId null", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i })); + expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo"); + await userEvent.click(screen.getByRole("button", { name: /guardar/i })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ cargoId: null })); + }); + + it("BLOCKING: a non-Admin cannot re-assign the grant-free CEL seat once cleared", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i })); + await userEvent.click(screen.getByLabelText("Cargo")); + const seat = await screen.findByRole("option", { name: "Presidenta" }); + expect(seat).toHaveAttribute("aria-disabled", "true"); + await userEvent.click(seat); + await userEvent.keyboard("{Escape}"); + expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo"); + await userEvent.click(screen.getByRole("button", { name: /guardar/i })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ cargoId: null })); + }); + + // The other half of "never submittable": leaving the seat untouched is the one state that + // still carries the CEL cargoId out of the form, and it must never become a positions + // WRITE. Asserted through the mapper the edit lane actually uses, not by inspection — the + // form's safety here is entirely toMemberUpdateDoc omitting an unchanged slot. + it("BLOCKING: an untouched CEL seat never reaches the positions write", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: /guardar/i })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + const [submitted] = onSubmit.mock.calls[0]! as [MemberInput]; + expect(submitted.cargoId).toBe("pos-pres"); + const doc = toMemberUpdateDoc(submitted, "uid-editor", { + cargoId: "pos-pres", + comisionIds: [], + }); + expect(Object.keys(doc).some((key) => key.startsWith("positions."))).toBe(false); + }); + + 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(); + }); + it("renders comisión option as 'sigla — title' when sigla is present", async () => { render( Promise; showPreview?: boolean; avatarSeed?: string; - /** Whether the editor may assign power-granting cargos — Admin only (rules' - * `cargoGrantsEmpty` / `createPositionsSafe`). Non-Admin sees only grant-free + /** Whether the editor may assign the cargos the rules reserve to an Admin — power-granting + * 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; children?: ReactNode; @@ -99,31 +103,29 @@ export function MemberForm({ // only when the user actively switches TO a CEL cargo (see the Cargo onChange) — never // force-cleared at submit, so a bio edit of a legacy CEL member with stored comisiones // doesn't trigger a positions write the editor may not be allowed to make. - const isExecutiveCommitteeCargo = - positions.find((p) => p.id === currentCargoId)?.category === "CEL"; - const term = currentTermKey(); - // Keep the member's ORIGINALLY-assigned cargo selectable for a non-Admin even if it - // grants power — but off the static default, not the reactive selection, so switching - // away and back still works (matches MemberPositionsForm). + const selectedCargo = positions.find((p) => p.id === currentCargoId); + const isExecutiveCommitteeCargo = selectedCargo?.category === "CEL"; + // Keep the member's ORIGINALLY-assigned cargo visible for a non-Admin even when the rules + // reserve it to an Admin — off the static default, not the reactive selection, so switching + // away and back still works (shared with MemberPositionsForm via cargoOptionsForEditor: a + // 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; - // If that assigned cargo grants power and the editor isn't Admin, any positions write - // is rule-denied (cargoGrantsEmpty) — lock the cargo/comisiones so bio edits still save - // (the mapper omits the unchanged slot) but a futile positions change can't be attempted. - const positionsLocked = - !allowPowerGrants && (positions.find((p) => p.id === assignedCargoId)?.grants.length ?? 0) > 0; - const activeCargoOptions = positions - .filter( - (p) => p.active && p.category !== "Comision" && (p.term === null || String(p.term) === term), - ) - .filter((p) => allowPowerGrants || p.grants.length === 0 || p.id === assignedCargoId) - .map((p) => ({ value: p.id, label: positionTitle(p, gender) })); - const assignedInactiveCargo = - currentCargoId && !activeCargoOptions.some((o) => o.value === currentCargoId) - ? positions - .filter((p) => p.id === currentCargoId) - .map((p) => ({ value: p.id, label: `${positionTitle(p, gender)} (inactivo)` })) - : []; - const cargoOptions = [...activeCargoOptions, ...assignedInactiveCargo]; + // 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 + // 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); + const cargoTakedown = cargoTakedownOnly(selectedCargo, allowPowerGrants); + const cargoOptions = cargoOptionsForEditor({ + positions, + gender, + allowPowerGrants, + assignedCargoId, + }); const comisionLabel = (p: Position) => (p.sigla ? `${p.sigla} — ${p.title}` : p.title); const activeComisionOptions = positions @@ -230,21 +232,34 @@ export function MemberForm({ control={control} name="cargoId" render={({ field }) => ( - { - field.onChange(v); - // Switching to a CEL cargo drops any picked comisiones (CEL members - // belong to the Comité Ejecutivo Local, not a comisión). - if (positions.find((p) => p.id === v)?.category === "CEL") { - setValue("comisionIds", []); - } - }} - placeholder="Sin cargo" - disabled={positionsLocked} - /> +
+ { + field.onChange(v); + // Switching to a CEL cargo drops any picked comisiones (CEL members + // belong to the Comité Ejecutivo Local, not a comisión). + if (positions.find((p) => p.id === v)?.category === "CEL") { + setValue("comisionIds", []); + } + }} + placeholder="Sin cargo" + disabled={positionsLocked} + /> + {cargoTakedown && ( + + )} +
)} /> @@ -284,8 +299,15 @@ export function MemberForm({ )} {positionsLocked && (

- Solo un Admin puede cambiar el cargo de un miembro con permisos. Puedes editar el resto - de sus datos. + Solo un Admin puede cambiar el cargo de un miembro cuyo cargo otorga permisos. Puedes + editar el resto de sus datos. +

+ )} + {cargoTakedown && ( +

+ Este cargo es del Comité Ejecutivo Local: solo un Admin puede asignarlo. Puedes + quitárselo con «Quitar cargo» o dejarlo como está; el resto de sus datos se guarda + igual.

)} { expect(screen.getByRole("button", { name: /guardar/i })).not.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. + const celFree = pos("presidente_libre", "CEL"); + + it("hides a grant-free CEL cargo from a non-Admin", async () => { + render( + , + ); + await userEvent.click(screen.getByLabelText("Cargo")); + // The paired JDL dirección proves the filter is the CEL conjunct, not the list closing + // on grant-free board cargos generally — that exposure is accepted and must survive. + expect(await screen.findByText("dir")).toBeInTheDocument(); + expect(screen.queryByText("presidente_libre")).not.toBeInTheDocument(); + }); + + it("shows a grant-free CEL cargo to an Admin", async () => { + render( + , + ); + await userEvent.click(screen.getByLabelText("Cargo")); + expect(await screen.findByText("presidente_libre")).toBeInTheDocument(); + }); + + // Not just the option list: `locked` has to cover it too. Every save re-stamps the + // assigned cargoId into the merged doc, so with the form unlocked a comisiones-only edit + // on a CEL-seated member is denied — no lock, no note, one generic error. + // BLOCKING: the two rules conjuncts are asymmetric, so the client must not mirror the + // wrong one. `cargoAssignableByNonAdmin()` denies KEEPING a grant-free CEL seat, but + // `currentCargoGrantsEmpty()` is deliberately not category-gated, so CLEARING it is + // allowed — firestore.rules says denying that "would strand a takedown behind an Admin". + // Locking the form here would strand exactly that takedown in the UI instead. + const celSeatedProps = { + positions: [celFree, pos("etica", "Comision")], + gender: "Masculino" as const, + allowPowerGrants: false, + defaultValues: { cargoId: "presidente_libre", comisionIds: [] }, + }; + + // 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 + // gesture (re-select the selected option) was unreachable because that option did not exist. + it("BLOCKING: names the grant-free CEL seat a non-Admin holds instead of 'Sin cargo'", () => { + 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.getByRole("button", { name: /guardar/i })).toBeDisabled(); + }); + + it("BLOCKING: reaches the takedown a grant-free CEL seat allows and submits cargoId null", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i })); + expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo"); + const save = screen.getByRole("button", { name: /guardar/i }); + expect(save).toBeEnabled(); + await userEvent.click(save); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ cargoId: null, comisionIds: [] })); + }); + + it("BLOCKING: a non-Admin cannot re-assign the grant-free CEL seat once cleared", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + await userEvent.click(screen.getByRole("button", { name: /quitar cargo/i })); + await userEvent.click(screen.getByLabelText("Cargo")); + const seat = await screen.findByRole("option", { name: "presidente_libre" }); + expect(seat).toHaveAttribute("aria-disabled", "true"); + await userEvent.click(seat); + await userEvent.keyboard("{Escape}"); + expect(screen.getByLabelText("Cargo")).toHaveTextContent("Sin cargo"); + await userEvent.click(screen.getByRole("button", { name: /guardar/i })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ cargoId: null, comisionIds: [] })); + }); + + it("locks the form for a non-Admin when the current cargo GRANTS power (nothing succeeds)", () => { + const granting: Position = { ...pos("tesorero", "CEL"), grants: ["Treasury"] }; + render( + , + ); + expect(screen.getByRole("button", { name: /guardar/i })).toBeDisabled(); + expect(screen.getByText(/Solo un Admin puede cambiar los cargos/i)).toBeInTheDocument(); + }); + + it("does NOT lock a non-Admin editing a member seated on a grant-free JDL dirección", () => { + render( + , + ); + expect(screen.getByRole("button", { name: /guardar/i })).not.toBeDisabled(); + }); + it("shows error alert when onSubmit throws", async () => { const onSubmit = vi.fn().mockRejectedValue(new Error("fail")); render( 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 708784db..bc02f796 100644 --- a/apps/backstage/src/features/members/components/member-positions-form.tsx +++ b/apps/backstage/src/features/members/components/member-positions-form.tsx @@ -3,7 +3,12 @@ import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button, Combobox, Field, MultiSelect } from "@luminova/ui"; -import { positionTitle, currentTermKey, type MemberGender, type Position } from "@luminova/types"; +import { type MemberGender, type Position } from "@luminova/types"; +import { + cargoOptionsForEditor, + cargoTakedownOnly, + positionsLockedForNonAdmin, +} from "../lib/assignable-cargo"; const positionsSchema = z.object({ cargoId: z.string().min(1).nullable(), @@ -22,9 +27,10 @@ export function MemberPositionsForm({ positions: Position[]; gender: MemberGender | undefined; defaultValues: PositionsInput; - /** Whether the caller may assign power-granting cargos. Only Admin may (rules' - * `cargoGrantsEmpty`); a non-Admin sees only grant-free cargos (plus the current - * assignment, so an existing selection still renders). */ + /** Whether the caller may assign the cargos the rules reserve to an Admin — power-granting + * ones and CEL seats alike (rules' `cargoAssignableByNonAdmin`). A non-Admin sees only + * 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; onSubmit: (data: PositionsInput) => Promise; }) { @@ -32,22 +38,26 @@ export function MemberPositionsForm({ const { control, handleSubmit, + watch, formState: { isSubmitting }, } = useForm({ resolver: zodResolver(positionsSchema), defaultValues }); - const term = currentTermKey(); - // A non-Admin can't write positions at all for a member whose current cargo grants - // power: the write re-stamps that cargoId and the rules' `cargoGrantsEmpty` denies it - // (comisiones can't be changed either — the whole slot is rejected). Lock the form. - const assignedCargoHasGrants = - (positions.find((p) => p.id === defaultValues.cargoId)?.grants.length ?? 0) > 0; - const locked = !allowPowerGrants && assignedCargoHasGrants; - const cargoOptions = positions - .filter( - (p) => p.active && p.category !== "Comision" && (p.term === null || String(p.term) === term), - ) - .filter((p) => allowPowerGrants || p.grants.length === 0 || p.id === defaultValues.cargoId) - .map((p) => ({ value: p.id, label: positionTitle(p, gender) })); + // 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); + const cargoOptions = cargoOptionsForEditor({ + positions, + gender, + allowPowerGrants, + assignedCargoId: defaultValues.cargoId, + }); + const selectedCargo = positions.find((p) => p.id === watch("cargoId")); + const takedownOnly = cargoTakedownOnly(selectedCargo, allowPowerGrants); const comisionOptions = positions .filter((p) => p.active && p.category === "Comision") .map((p) => ({ value: p.id, label: p.sigla ? `${p.sigla} — ${p.title}` : p.title })); @@ -68,14 +78,27 @@ export function MemberPositionsForm({ control={control} name="cargoId" render={({ field }) => ( - +
+ + {takedownOnly && ( + + )} +
)} />
@@ -96,7 +119,13 @@ export function MemberPositionsForm({ {locked && (

- Solo un Admin puede cambiar los cargos de un miembro con permisos. + Solo un Admin puede cambiar los cargos de un miembro cuyo cargo otorga permisos. +

+ )} + {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.

)} {formError && ( @@ -104,10 +133,14 @@ export function MemberPositionsForm({ {formError}
)} + {/* takedownOnly disables the save, not the form: every positions write this page makes + re-stamps the whole slot (MemberRepository.setPositions), so saving while the CEL seat + is still selected is the 403 the rules promise. Clearing it (or picking another cargo) + re-enables the save — that takedown is exactly what the rules keep open. */}