diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index cdbe9cb7..9de829ec 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**C141 — perfis de permissão por assessor: Visão × Edição configuráveis (2026-08-19):** o coordenador/candidato define por assessor, em `/campanha/assessores` (coluna "Permissão" com badge + popover, seção no detalhe e no draft de novo assessor), o que ele **vê** (`carteira|tudo`) e o que ele **edita** (`carteira|tudo|somente_leitura`) — contas novas e existentes seguem no padrão Carteira·Edita carteira até configurarem. Enforcement fail-closed no servidor: a leitura alarga com Visão "Tudo" via prólogo novo `resolveProfileScopedRead` (municípios, lideranças, updates, pledges, atividades, contatos derivados), a escrita segue o eixo de Edição (`advisorEditingAccess`) nos `canUpdate*`/`canCreate*` de todos os domínios, no check do giro e no create individual de atividade (ambos contra `getWritableMunicipalityIds`), e a ficha de contatos passou a ser autorizada pelo escopo de escrita (`assertPersonContactWritable` — antes era "você edita o que vê", que a Visão "Tudo" alargaria em escrita de PII). Caps mantidos: demandas seguem a regra própria (C143), apoiadores (PII) e telefones de contas ficam na carteira mesmo com Tudo, e escritas de coordenação (contas, `municipality.advisors`, nível de envolvimento E14, `allocationDecision`) continuam restritas. Combinação incoerente (Edição "Tudo" sem Visão "Tudo") é rejeitada no zod, na action e no hook `enforceCoherentAdvisorProfile` (REST/admin incluídos). Migration `20260819_214755_add_campaign_user_advisor_permission_profile` (duas colunas select com default `'carteira'`, sem backfill). + **OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. Durante o diagnóstico descobriu-se que a premissa do plano ("produção nunca recebeu o fix OPS69") estava errada: o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap **no histórico**, mas o commit S9 (`8d2352c0`, captura de novidades na home) tinha regenerado o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo o erro `getFromImportMap: PayloadComponent not found in importMap` — o admin abria em branco de novo ("funcionou e no deploy seguinte parou"). Verificado também que o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71): o GitHub tinha o fix + OPS71, mas não as features S8 (seção "Nossa história") e S9 (captura de novidades) que produção já rodava — deployar o main como estava regrediria as duas. A entrega: PR #751 re-homou no GitHub main as features S8+S9 (cherry-pick dos commits aprovados no Forgejo `8d2352c0` + `488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); CI verde; aguardando o merge. Deploy pós-merge via o pipeline canônico do OPS71 (dispatch manual do `deploy.yml`: `verify` full → job `deploy` no runner self-hosted recém-instalado no homeserver — 2 tentativas "stale run" por merges paralelos no meio do verify, terceira aplicou `dba75ff8`); migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: container na revision `dba75ff8`, healthy, **log sem nenhum `getFromImportMap`**, `/admin` renderizando o formulário de login (2 inputs email/senha + botão Login, título "Login - Payload", 0 erros de console), e a home pública mantendo "Nossa história" (S8) e o form de novidades (S9). Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições registradas: a regeneração do importMap sem envs `S3_*` re-órfão o handler (classe exata que o guard #87 deve impedir no CI); divergência de plataformas pós-cutover — workspace do homeserver ainda apontava para o Forgejo local (`localhost:3000`) e o S10 (pixel) segue pendente de re-home no GitHub. **OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. O diagnóstico mostrou que a premissa do plano (fix nunca tinha chegado a prod) estava errada em dois pontos: (1) o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap no histórico, mas o commit S9 (`8d2352c0`) regenerou o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo `getFromImportMap: PayloadComponent not found` (admin em branco de novo); (2) o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71) — o GitHub tinha o fix + OPS71, mas não as features S8 ("Nossa história") e S9 (captura de novidades) que produção já rodava. A entrega (PR #751): re-home do S8+S9 no GitHub main (cherry-pick `8d2352c0`+`488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); deploy via `deploy.yml` no runner self-hosted do homeserver (`dba75ff8`), após 2 "stale run" por merges paralelos no verify; migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: revision `dba75ff8`, healthy, **log sem `getFromImportMap`**, `/admin` renderizando o login (2 inputs + botão, título "Login - Payload", 0 erros de console), home mantendo S8/S9. Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições: regeneração do importMap sem envs `S3_*` re-órfão o handler (classe do guard OPS70/#87); divergência de plataformas pós-cutover (workspace homeserver ainda aponta Forgejo local; S10 pixel pendente de re-home). diff --git a/docs/changelog/2026-08-19-c141.md b/docs/changelog/2026-08-19-c141.md new file mode 100644 index 00000000..6d1ec87e --- /dev/null +++ b/docs/changelog/2026-08-19-c141.md @@ -0,0 +1 @@ +**C141 — perfis de permissão por assessor: Visão × Edição configuráveis (2026-08-19):** o coordenador/candidato define por assessor, em `/campanha/assessores` (coluna "Permissão" com badge + popover, seção no detalhe e no draft de novo assessor), o que ele **vê** (`carteira|tudo`) e o que ele **edita** (`carteira|tudo|somente_leitura`) — contas novas e existentes seguem no padrão Carteira·Edita carteira até configurarem. Enforcement fail-closed no servidor: a leitura alarga com Visão "Tudo" via prólogo novo `resolveProfileScopedRead` (municípios, lideranças, updates, pledges, atividades, contatos derivados), a escrita segue o eixo de Edição (`advisorEditingAccess`) nos `canUpdate*`/`canCreate*` de todos os domínios, no check do giro e no create individual de atividade (ambos contra `getWritableMunicipalityIds`), e a ficha de contatos passou a ser autorizada pelo escopo de escrita (`assertPersonContactWritable` — antes era "você edita o que vê", que a Visão "Tudo" alargaria em escrita de PII). Caps mantidos: demandas seguem a regra própria (C143), apoiadores (PII) e telefones de contas ficam na carteira mesmo com Tudo, e escritas de coordenação (contas, `municipality.advisors`, nível de envolvimento E14, `allocationDecision`) continuam restritas. Combinação incoerente (Edição "Tudo" sem Visão "Tudo") é rejeitada no zod, na action e no hook `enforceCoherentAdvisorProfile` (REST/admin incluídos). Migration `20260819_214755_add_campaign_user_advisor_permission_profile` (duas colunas select com default `'carteira'`, sem backfill). diff --git a/docs/plans/permissao-granular-assessores-impl.md b/docs/plans/permissao-granular-assessores-impl.md new file mode 100644 index 00000000..91f4340b --- /dev/null +++ b/docs/plans/permissao-granular-assessores-impl.md @@ -0,0 +1,129 @@ +# Impl: Perfis de permissão por assessor (visão × edição), configuráveis pelo coordenador/candidato + +Status: aprovado +Atualizado em: 2026-08-19 +Issue: #105 (C141) +Intenção: docs/plans/permissao-granular-assessores.md +Appetite restante: herdado (~2–3 dias eng) — cortes abaixo no "Rabbit holes / Não escopo" + +## Leitura da intenção + +- **Outcome:** coordenador/candidato define por assessor **Visão** (`Tudo | Carteira`) e **Edição** (`Tudo | Carteira | Somente leitura`) em `/campanha/assessores` (lista + detalhe + novo), com enforcement fail-closed no servidor; default = comportamento atual (Carteira · Edita carteira). +- **O que NÃO negociar:** + - Default Carteira·Carteira para contas novas e existentes — zero mudança até configurar. + - Demandas ficam FORA dos eixos: Visão "Tudo" não abre demandas (regra de responsáveis = C143; aqui: carteira segue sendo o teto). + - Apoiadores (PII) capados na carteira para todo staff abaixo de candidato/coordenador, mesmo com Visão "Tudo" — e por simetria, mesmo com Edição "Tudo". + - Edição "Tudo" não libera escritas de coordenação (contas, `municipality.advisors`, nível de envolvimento E14, decisão de demandas escaladas). + - Combinação incoerente (editar o que não vê) não é oferecida — e é rejeitada no servidor. + - Leader lockdown e assimetria de votos (estimativas staff-only) intactos. +- **O que reavaliar:** a hipótese de direção apontava "perfis na conta + enforcement nos módulos de acesso + gates nas actions + UI nos três pontos" — confirma-se, com duas correções de engenharia: (a) **`getAccessibleMunicipalityIds` NÃO pode virar o escopo de visão** (ele alimenta superfícies PII — apoiadores, telefones, feeds — que o gate capa na carteira); (b) o prologue `resolveActorScopedRead` compartilhado com demandas **não pode alargar em bloco** (vazaria demandas com Visão Tudo) — prologue irmão, profile-aware, para as coleções não sensíveis. + +## Abordagem recomendada + +```mermaid +flowchart LR + F["CampaignUser.visibility/editing
(default carteira/carteira, hook coerência)"] + L["src/lib/campaignAdvisorProfile.ts
(opções, rótulos, coerência — client-safe)"] + R["Leitura: resolveProfileScopedRead + branches
municípios, lideranças, updates, pledges, atividades, contatos"] + W["Escrita: eixo Edição nos access (canUpdate*/canCreate*)
+ giro check write-scoped"] + A["actions/advisor.ts: updateAdvisorPermission
+ createAdvisor com perfil"] + U["UI: coluna Permissão + popover editor + detalhe + draft row"] + M["Migration: add_campaign_user_advisor_permission_profile"] + + F --> R; F --> W + L --> U + A --> U + M --> F +``` + +**Opções consideradas:** A (perfis sobre o papel `advisor` em dois selects na conta) | B (role novo `viewer`) | C (matriz por coleção) | D (alargar `getAccessibleMunicipalityIds` por visão) +**Recomendação:** A — dois selects (`visibility`, `editing`) em `campaignUser` + eixo de leitura profile-aware nos access de coleção + eixo de escrita por `editing`; UI em três pontos com o mesmo editor. Mantém o modelo de papéis congelado, enforcement centralizado onde o código já decide escopo (`overrideAccess: false` + `user`), e as superfícies PII intactas. +**Rejeitadas:** + +- B porque recria seed/modelo de papéis (rabbit hole do gate). +- C porque explode em matriz N×M (anti-goal do gate). +- D porque `getAccessibleMunicipalityIds` alimenta apoiadores, telefones de contas e feeds — alargá-lo abriria PII além da carteira, violando o gate; o escopo "carteira" continua sendo O escopo de operação, e a visão ampla é um eixo separado. + +### Componentes / mudanças + +**Modelo — `src/collections/CampaignUser.ts`** + +- `visibility`: select `'carteira' | 'tudo'`, default `'carteira'`, label "Visão" (pt-BR no admin), `admin.condition` só p/ `role === 'advisor'`, `access.update: canManageCampaignUserRole` (unrestricted/admin). +- `editing`: select `'carteira' | 'tudo' | 'somente_leitura'`, default `'carteira'`, mesma condição/access. +- `beforeValidate` novo `enforceCoherentAdvisorProfile`: `role === 'advisor' && editing === 'tudo' && visibility !== 'tudo'` → APIError (fail-closed também via REST/admin, não só na action). +- `preventSelfServicePrivilegedFields`: adicionar `visibility`, `editing` ao strip. +- **Migration:** `add_campaign_user_advisor_permission_profile` (duas colunas select com default `'carteira'`; sem backfill — default cobre linhas existentes). + +**Lib — `src/lib/campaignAdvisorProfile.ts`** (novo, client-safe) + +- Tipos `AdvisorVisibility` / `AdvisorEditing`; `ADVISOR_VISIBILITY_OPTIONS` / `ADVISOR_EDITING_OPTIONS` (value + label + description pt-BR do draft: "Tudo — enxerga todos os municípios e lideranças.", "Somente leitura — nenhum controle de edição em lugar nenhum.", …); `advisorProfileLabel(v, e)` ("Carteira · Edita carteira", "Tudo · Somente leitura", "Tudo · Edita tudo"); `isCoherentAdvisorProfile(v, e)` (`e === 'tudo' ⇒ v === 'tudo'`). Combinar com `campaignRoles.ts` (papéis), não duplicar. + +**Access — `src/utilities/access/`** + +- `shared.ts`: + - `resolveProfileScopedRead(req, scopeField, loadAccessibleIds)` — mesmo prólogo de `resolveActorScopedRead`, mas advisor com `visibility === 'tudo'` → `true`. `resolveActorScopedRead` fica como está (demandas continuam carteira — C143 substitui depois). + - `advisorEditingAccess(currentUser)` → `'none' | 'carteira' | 'tudo'` a partir de `editing` (somente_leitura → none; tudo → tudo; senão carteira; não-assessor → none). + - `resolveAccessibleIds`: permitir `compute` retornar `null` (= "tudo") — necessário para `getAccessibleLeadershipIds`/`getAccessibleContactIds` ampliarem sem virar `true` (que abriria PII). +- `municipalities.ts`: `canReadMunicipality` — advisor com `visibility === 'tudo'` → `true`; `canUpdateMunicipality` — eixo: none → `false`, tudo → `true`, carteira → `{ advisors: { contains: self } }` (where atual inalterado). +- `leaderships.ts`: `canReadLeadership` → `resolveProfileScopedRead`; `getAccessibleLeadershipIds` — advisor com visão tudo → `null`; `canCreateLeadership`/`canManageLeadership` — eixo de escrita. +- `municipalityUpdates.ts` (+ alias `allocationDecisions.ts`): `canReadMunicipalityUpdate`/`canReadAllocationDecision` → `resolveProfileScopedRead`; `canCreateMunicipalityUpdate` — eixo. +- `votePledges.ts`: `canReadVotePledge` → `resolveProfileScopedRead`; `canCreateVotePledge`/`canUpdateVotePledge` — eixo (tudo → `true`; none → `false`; carteira → fragmento atual). +- `activities.ts`: `canReadActivity` — advisor com visão tudo → `true`; `canCreateActivity`/`canUpdateActivity` — eixo (carteira mantém o or-where `responsible`/`municipality`; tudo → `true`; none → `false`). +- `demands.ts`: **inalterado na leitura** (carteira — C143 dono). Só `canUpdateCampaignDemand`/`canCreateCampaignDemand`: eixo none → `false`; tudo/carteira → comportamento atual (carteira; C143 substitui a regra depois). +- `supporters.ts`: `canCreateSupporter`/`canManageSupporter` — eixo none → `false`; senão comportamento atual (carteira SEMPRE; PII cap nos dois eixos). `canReadSupporter` inalterado. +- `organizations.ts` / `stateDeputies.ts`: booleans staff → eixo (none → `false` para advisor; senão comportamento atual). +- `contacts.ts`: `getAccessibleContactIds` — ramo lideranças: advisor com visão tudo → `where: {}` (dobradinhas já são todas); ramo apoiadores: SEMPRE carteira (PII cap). `canReadContacts` segue derivando ids. +- `calendarFeeds.ts`, `campaignUsers.ts` (phone): **inalterados** — feeds são superfície pessoal (contrato C96 re-deriva escopo na leitura) e telefone é PII; continuam carteira. + +**Actions — `src/app/(campaign)/campanha/actions/`** + +- `advisor.ts`: `updateAdvisorPermission` (novo) — `reloadUnrestrictedActor` + `assertTargetAdvisor` + `payload.update({ visibility, editing }, user, overrideAccess: false)` (field access `canManageCampaignUserRole` re-checa); `createAdvisorRecord` aceita `visibility`/`editing` opcionais (default carteira). `revalidateAdvisorPaths`. +- `activity.ts` `createTourDraftActivitiesRecord`: trocar o check de escopo do giro de **leitura** para **escrita** — hoje lê municípios com access (com visão tudo o read alargaria e um assessor "Carteira" comporia giro fora da carteira). Usar `getWritableMunicipalityIds` (novo helper em `municipalities.ts`: unrestricted/tudo → `null`; none → `[]`; carteira → ids da carteira). +- `formActions.ts` (assessores): `updateAdvisorPermissionFormAction`; `createAdvisorFormAction` lê `visibility`/`editing` do FormData. + +**Dados/view models — `src/utilities/advisorData.ts`** + +- `AdvisorRowViewModel` / `AdvisorDetailViewModel` ganham `visibility`/`editing`; `loadAdvisorListPageData`/`loadAdvisorDetail` incluem os campos no `select`. + +**UI — `src/components/campaign/advisor/`** (Impeccable B — encaixe na superfície existente) + +- `AdvisorPermissionEditor` (novo, client): dois `NativeSelect` (Visão/Edição) + nota de demandas + Cancelar/Salvar; ao escolher Edição "Tudo" com Visão "Carteira", auto-eleva Visão para "Tudo" (só combos coerentes são oferecidos); salva via formAction + `router.refresh()`. +- `AdvisorPermissionBadge` (novo, client): badge "Carteira · Edita carteira" etc. (botão) abrindo Popover com o editor — reusado na lista e no draft row. +- `AdvisorsTable`: coluna **`permission`** (mandatory, `headerClassName` ~`w-48`) com o badge; draft row ganha célula de permissão (estado local, default carteira/carteira) e `saveDraft` envia os campos; `DraftAdvisor` ganha `visibility`/`editing`. +- Detalhe `[id]/page.tsx`: seção "Permissão da conta" (título + editor inline + nota de demandas/coordenação), mesma formAction. +- Coluna participa do picker como mandatory (contrato B197+ — body sempre renderiza). + +### Dados → forma (se aplicável) + +- Sem KPI/dado novo: superfície de configuração. Rótulo "Permissão" em badge curto (coluna) e textos descritivos no editor — forma do draft do gate, reutilizada como vocabulário único (`campaignAdvisorProfile.ts`). + +## Fases verificáveis + +1. **Tracer — schema + leitura** (~0,5 dia): campos + hook + migration + `campaignAdvisorProfile.ts` + `resolveProfileScopedRead`/`advisorEditingAccess` + leitura dos access (municípios, lideranças, updates, pledges, atividades, contatos) + `generate:types` + int spec de leitura (visão tudo alarga; carteira não; demandas/apoiadores continuam carteira). +2. **Escrita** (~0,5–0,75 dia): eixo `editing` nos `canUpdate*`/`canCreate*` + `getWritableMunicipalityIds` + giro + int spec (somente_leitura nega tudo; tudo permite fora da carteira exceto PII/coordenação; carteira = hoje). +3. **Actions + dados** (~0,25 dia): `updateAdvisorPermission`, create com perfil, zod, form actions, view models/loaders. +4. **UI** (~0,75–1 dia): editor + badge + coluna + draft row + detalhe; shape→craft→critique→polish; unit specs dos componentes/labels. +5. **Gates**: `pnpm migrate:create` + `pnpm migrate` (local), `generate:types` (com `S3_*` dummy setados), `tsc --noEmit`, `pnpm lint`, `format:check`, `knip`, `check:cycles`, `pnpm test` (unit+int), `pnpm build`. e2e roda no CI. + +## Rabbit holes / Não escopo (engenharia) + +- Redesenho mobile da lista de assessores em cards (draft cena 2) — a tabela atual rola; apresentação por perfil é o item irmão C142. Não escopo aqui. +- Mudar a regra de demandas (responsáveis) — C143 é o dono; aqui só o bloqueio "somente leitura" e o teto carteira. +- Histórico/auditoria de mudanças de perfil; autoatendimento; perfil para coordinator/candidate/leader. +- `saveToJWT` para os campos novos — desnecessário: `getFreshCampaignUser` recarrega por ID. +- Migration de backfill — default cobre linhas existentes. + +## Riscos e mitigação + +- **Mapa/quantis/dashboard** leem `loadMunicipalityScope` (access-driven) — alargam automaticamente com visão tudo; esperado e desejado ("mesa de trabalho completa"). Mitigação: int spec pina que o escopo do mapa acompanha o perfil. +- **`getAccessibleLeadershipIds` alargada** abre convites para todas as lideranças com visão tudo — coerente com a visão; registrar no resumo (C142 apresenta; se a mesa discordar, item sucessor). +- **Sollinha** usa as mesmas queries/access — segue automaticamente; conferir B185/B186 (fragmentos allowlistados por escopo já resolvido) em fase 2. +- **Contatos**: a lista de pessoas deriva de lideranças (alarga) + apoiadores (carteira) — composição por ids, nunca `true` (evita abrir PII). Teste pina o ramo apoiadores. +- **Combinação incoerente via API** — hook no `beforeValidate` + zod; teste pina o 400. +- **Giro (tour)** — o check read→write é a única correção fora dos access; teste específico. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (dois eixos, default carteira, fail-closed, PII/demandas/coordenação intocados) +- [ ] Invariantes AGENTS/engineering-standards: Local API com `user` → `overrideAccess: false`; sem re-escrita do fragmento P3-D (guarda em `codebaseConventions`); identificadores em inglês, valores/UI pt-BR +- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: `campaignAdvisorPermission.int.spec.ts` + unit dos helpers de perfil diff --git a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx index d698d1da..5900aa1b 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx @@ -6,6 +6,7 @@ import { getPayload } from 'payload' import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell' import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton' +import { AdvisorPermissionEditor } from '@/components/campaign/advisor/AdvisorPermissionEditor' import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor' import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' @@ -18,6 +19,7 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { sendAdvisorPasswordResetFormAction, updateAdvisorContactFormAction, + updateAdvisorPermissionFormAction, updateAdvisorProfileFormAction, } from '../formActions' @@ -89,6 +91,27 @@ export default async function AdvisorDetailPage({ params }: AdvisorDetailPagePro +
+
+

+ Permissão da conta +

+
+

+ O que este assessor vê e o que pode editar nas telas do /campanha. +

+ +
+
=> runCampaignFormAction({ execute: async () => { + const rawVisibility = optionalFormText(formData, 'visibility') + const rawEditing = optionalFormText(formData, 'editing') + if ( + (rawVisibility !== undefined && !isAdvisorVisibilityValue(rawVisibility)) || + (rawEditing !== undefined && !isAdvisorEditingValue(rawEditing)) + ) { + throw new Error(INVALID_FIELD_MESSAGE) + } + const created = await createAdvisor({ name: requiredFormText(formData, 'name'), email: requiredFormText(formData, 'email'), phone: nullableFormText(formData, 'phone') ?? undefined, + visibility: rawVisibility, + editing: rawEditing, }) const municipalityIds = repeatedRelationshipFormValues(formData, 'municipalityIds') @@ -136,3 +150,30 @@ export const createAdvisorFormAction = async ( safeMessages: advisorSafeMessages, genericMessage: 'Não foi possível criar o assessor. Verifique os dados e tente novamente.', }) + +/** + * C141 — the advisor permission profile (Visão × Edição), from the list + * popover and the detail section. + */ +export const updateAdvisorPermissionFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + const rawVisibility = requiredFormText(formData, 'visibility') + const rawEditing = requiredFormText(formData, 'editing') + if (!isAdvisorVisibilityValue(rawVisibility) || !isAdvisorEditingValue(rawEditing)) { + throw new Error(INVALID_FIELD_MESSAGE) + } + + await updateAdvisorPermission({ + id: requiredRelationshipFormValue(formData, 'advisorId'), + visibility: rawVisibility, + editing: rawEditing, + }) + return { message: 'Permissão atualizada.' } + }, + safeMessages: advisorSafeMessages, + genericMessage: 'Não foi possível atualizar a permissão.', + }) diff --git a/src/app/(campaign)/campanha/(app)/assessores/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/page.tsx index 4583372a..272be1a0 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/page.tsx @@ -27,6 +27,7 @@ import { createAdvisorFormAction, sendAdvisorPasswordResetFormAction, setAdvisorMunicipalitiesFormAction, + updateAdvisorPermissionFormAction, updateAdvisorProfileFormAction, } from './formActions' @@ -82,6 +83,7 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) municipalitiesAction={setAdvisorMunicipalitiesFormAction} createAction={createAdvisorFormAction} passwordResetAction={sendAdvisorPasswordResetFormAction} + updatePermissionAction={updateAdvisorPermissionFormAction} autoCreateDraft={autoCreateDraft} /> diff --git a/src/app/(campaign)/campanha/actions/activity.ts b/src/app/(campaign)/campanha/actions/activity.ts index 76347934..06aeb9f9 100644 --- a/src/app/(campaign)/campanha/actions/activity.ts +++ b/src/app/(campaign)/campanha/actions/activity.ts @@ -4,6 +4,7 @@ import type { Payload } from 'payload' import { ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE, + ACTIVITY_OUT_OF_SCOPE_MESSAGE, ACTIVITY_RESCHEDULE_FAILED_MESSAGE, ACTIVITY_RESULT_REQUIRED_MESSAGE, ACTIVITY_RESULT_STAFF_MESSAGE, @@ -31,7 +32,11 @@ import { import { mapActivityOverlayError } from '@/utilities/activityOverlayErrors' import { loadActivityAgendaEventsData } from '@/utilities/activityPageData' import { activityFormSelect, toActivityFormViewModel } from '@/utilities/activityViewModels' -import { canCampaignUserRescheduleActivity, isCampaignStaff } from '@/utilities/campaignAccess' +import { + canCampaignUserRescheduleActivity, + getWritableMunicipalityIds, + isCampaignStaff, +} from '@/utilities/campaignAccess' import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' import { hookFilledCreateData } from '@/utilities/hookFilledData' import { withPayloadTransaction } from '@/utilities/payloadTransaction' @@ -114,6 +119,17 @@ export const createActivityRecord = async ( payload, async ({ req }) => { const currentActor = await reloadCampaignActor(payload, actor, req) + + // C141 — `canCreateActivity` is a plain staff boolean (Payload cannot + // express a per-município constraint on create), so the individual + // create must check the municipality against the WRITE scope itself — + // with Visão "Tudo" + Edição "Carteira" the read widens while the + // carteira stays the edit boundary (same rule as the giro batch). + const writableIDs = await getWritableMunicipalityIds(payload, currentActor, req) + if (writableIDs !== null && !writableIDs.includes(raw.municipality)) { + throw new Error(ACTIVITY_OUT_OF_SCOPE_MESSAGE) + } + const activity = await payload.create({ collection: 'activity', data: hookFilledCreateData<'activity'>(data), @@ -190,6 +206,15 @@ export const createTourDraftActivitiesRecord = async ( throw new Error(TOUR_OUT_OF_SCOPE_MESSAGE) } + // C141 — the read above proves the stops EXIST, but the stop set must sit + // inside the WRITE scope: with Visão "Tudo" + Edição "Carteira" the read + // widens while the carteira stays the edit boundary. `null` = whole + // catalog (unrestricted or editing=tudo); `[]` = no writes at all. + const writableIDs = await getWritableMunicipalityIds(payload, currentActor, req) + if (writableIDs !== null && !municipalityIDs.every((id) => writableIDs.includes(id))) { + throw new Error(TOUR_OUT_OF_SCOPE_MESSAGE) + } + // The names come from the same read that authorized the stops, so a stop's // title always names the município it was actually created in, and the // giro reads as one set in the Atividades list. diff --git a/src/app/(campaign)/campanha/actions/advisor.ts b/src/app/(campaign)/campanha/actions/advisor.ts index 790aa2aa..00ff4d79 100644 --- a/src/app/(campaign)/campanha/actions/advisor.ts +++ b/src/app/(campaign)/campanha/actions/advisor.ts @@ -16,11 +16,13 @@ import { advisorCreateSchema, advisorMunicipalitiesBatchSchema, advisorPasswordResetSchema, + advisorPermissionUpdateSchema, advisorProfileUpdateSchema, isPlanilhaPlaceholderEmail, type AdvisorCreateInput, type AdvisorMunicipalitiesBatchInput, type AdvisorPasswordResetInput, + type AdvisorPermissionUpdateInput, type AdvisorProfileUpdateInput, } from '@/lib/schemas/advisor' import { contactPhonesSchema } from '@/lib/schemas/contact' @@ -105,6 +107,8 @@ export const createAdvisorRecord = async ( email: data.email, role: 'advisor', password: randomBytes(24).toString('base64url'), + visibility: data.visibility, + editing: data.editing, ...(data.phone !== undefined ? { phone: data.phone } : {}), }), depth: 0, @@ -266,6 +270,38 @@ export const setAdvisorMunicipalitiesBatch = async (input: AdvisorMunicipalities return result } +/** + * C141 — set the advisor's permission profile (Visão × Edição). The coherence + * rule (Edição "Tudo" ⇒ Visão "Tudo") is enforced by the zod schema AND the + * collection's `enforceCoherentAdvisorProfile` beforeValidate hook; the field + * update access (`canManageCampaignUserRole`) re-checks the unrestricted role. + */ +export const updateAdvisorPermissionRecord = async ( + payload: Payload, + actor: CampaignUser, + input: AdvisorPermissionUpdateInput, +) => { + const { id, visibility, editing } = advisorPermissionUpdateSchema.parse(input) + const currentActor = await reloadUnrestrictedActor(payload, actor, ADVISOR_UNRESTRICTED_MESSAGE) + await assertTargetAdvisor(payload, id, currentActor.id) + + return payload.update({ + collection: 'campaignUser', + id, + data: { visibility, editing }, + depth: 0, + user: currentActor, + overrideAccess: false, + }) +} + +export const updateAdvisorPermission = async (input: AdvisorPermissionUpdateInput) => { + const { payload, actor } = await getCampaignActionContext() + const updated = await updateAdvisorPermissionRecord(payload, actor, input) + revalidateAdvisorPaths(updated.id) + return updated +} + export const sendAdvisorPasswordResetRecord = async ( payload: Payload, actor: CampaignUser, diff --git a/src/app/(campaign)/campanha/actions/contact.ts b/src/app/(campaign)/campanha/actions/contact.ts index e6834451..523fec4d 100644 --- a/src/app/(campaign)/campanha/actions/contact.ts +++ b/src/app/(campaign)/campanha/actions/contact.ts @@ -15,13 +15,13 @@ import { type ContactFullUpdateInput, } from '@/lib/schemas/contact' import type { CampaignUser, Contact } from '@/payload-types' -import { isCampaignUnrestricted } from '@/utilities/campaignAccess' import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext' import { assertContactNameAvailable } from '@/utilities/contacts/contactNameInvariant' import { withPayloadTransaction, type PayloadTransactionRequest, } from '@/utilities/payloadTransaction' +import { assertPersonContactWritable } from '@/utilities/person/personContactWriteScope' import type { Payload } from 'payload' /** @@ -33,25 +33,25 @@ import type { Payload } from 'payload' * the gate, never standalone. */ +/** + * C141 — the contacts-page cell write gate: the ficha must sit in the actor's + * WRITE scope (see `assertPersonContactWritable`). Before C141 this asserted + * "you edit what you see" via the READ access, which Visão "Tudo" would have + * widened into a statewide PII write for a carteira-editing advisor. + */ const assertContactRowEditable = async ( payload: Payload, actor: CampaignUser, contactID: number, req: PayloadTransactionRequest, ): Promise => { - if (isCampaignUnrestricted(actor)) return - - const visible = await payload.find({ - collection: 'contact', - where: { id: { equals: contactID } }, - depth: 0, - limit: 1, - pagination: false, - user: actor, - overrideAccess: false, + await assertPersonContactWritable({ + payload, + actor, + contactID, req, + errorMessage: CONTACT_CELL_NOT_IN_SCOPE_MESSAGE, }) - if (visible.docs.length === 0) throw new Error(CONTACT_CELL_NOT_IN_SCOPE_MESSAGE) } const createContactRecord = async ( diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts index f235eab0..d17760b4 100644 --- a/src/app/(campaign)/campanha/actions/leadership.ts +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -33,7 +33,7 @@ import { type MunicipalityLeadershipCreateInput, } from '@/lib/schemas/leadership' import type { CampaignUser, Contact, Leadership } from '@/payload-types' -import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess' +import { getWritableMunicipalityIds } from '@/utilities/campaignAccess' import { getCampaignActionContext, reloadStaffActor, @@ -54,7 +54,14 @@ const getFreshStaffActor = ( req?: PayloadTransactionRequest, ): Promise => reloadStaffActor(payload, actor, LEADERSHIP_STAFF_MESSAGE, req) -/** Advisors may only link leaderships to municipalities they administer. */ +/** + * C141 — advisors may only link leaderships to municipalities in their WRITE + * scope (the Edição axis): `tudo` → any municipality, `carteira` → the + * administered ones, `somente_leitura` → none. The collection access + * (`canCreateLeadership`/`canManageLeadership`) already resolves the same + * axis; this assert keeps the server action's per-municipality check honest + * (Payload cannot express a per-município constraint on create). + */ const assertMunicipalitiesWithinScope = async ( payload: Payload, actor: CampaignUser, @@ -63,8 +70,10 @@ const assertMunicipalitiesWithinScope = async ( ) => { if (actor.role !== 'advisor') return - const administered = new Set(await getAdvisorMunicipalityIds(payload, actor.id, req)) - const outside = municipalityIDs.filter((id) => !administered.has(id)) + const writable = await getWritableMunicipalityIds(payload, actor, req) + if (writable === null) return + + const outside = municipalityIDs.filter((id) => !writable.includes(id)) if (outside.length > 0) { throw new Error(LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE) } diff --git a/src/app/(campaign)/campanha/actions/person.ts b/src/app/(campaign)/campanha/actions/person.ts index a6059ca7..d99331c6 100644 --- a/src/app/(campaign)/campanha/actions/person.ts +++ b/src/app/(campaign)/campanha/actions/person.ts @@ -39,7 +39,7 @@ import { } from '@/lib/schemas/personDelete' import { nextStateDeputyAdvisorIdsAfterMembership } from '@/lib/stateDeputyAdvisorMembership' import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types' -import { getAdvisorMunicipalityIds, isCampaignUnrestricted } from '@/utilities/campaignAccess' +import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess' import { getCampaignActionContext, reloadStaffActor, @@ -63,6 +63,7 @@ import { deletePersonRecord, loadPersonDeleteManifest, } from '@/utilities/people/personDelete' +import { assertPersonContactWritable } from '@/utilities/person/personContactWriteScope' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant' import { stateDeputyPolicy } from '@/utilities/stateDeputyConflict' @@ -120,11 +121,11 @@ export const deletePersonAction = async (input: unknown) => { // --------------------------------------------------------------------------- /** - * C116 — the cell-edit scope rule for a person's ficha fields: unrestricted - * actors always pass; an advisor passes only when at least ONE entity of the - * person (leadership or dobradinha) is readable with his own access — the "you - * edit what you see" rule, mirroring the row's visibility (a staff-only person - * is not entity-manageable and therefore not editable by an advisor). + * C116 + C141 — the cell-edit scope rule for a person's ficha fields: the + * person must sit in the actor's WRITE scope (see `assertPersonContactWritable`). + * Unrestricted actors always pass; `somente_leitura` advisors never write a + * ficha; carteira advisors edit persons whose leaderships/supporters are in + * writable municipalities (dobradinhas keep their staff-wide status quo). */ const assertPersonContactEditable = async ( payload: Payload, @@ -132,34 +133,13 @@ const assertPersonContactEditable = async ( contactID: number, req: { transactionID: number | string }, ): Promise => { - if (isCampaignUnrestricted(actor)) return - - const [leaderships, deputies] = await Promise.all([ - payload.find({ - collection: 'leadership', - where: { contact: { equals: contactID } }, - depth: 0, - limit: 1, - pagination: false, - user: actor, - overrideAccess: false, - req, - }), - payload.find({ - collection: 'stateDeputy', - where: { contact: { equals: contactID } }, - depth: 0, - limit: 1, - pagination: false, - user: actor, - overrideAccess: false, - req, - }), - ]) - - if (leaderships.docs.length === 0 && deputies.docs.length === 0) { - throw new Error(PERSON_CELL_NOT_IN_SCOPE_MESSAGE) - } + await assertPersonContactWritable({ + payload, + actor, + contactID, + req, + errorMessage: PERSON_CELL_NOT_IN_SCOPE_MESSAGE, + }) } export const updatePersonContactRecord = async ( diff --git a/src/collections/CampaignUser.ts b/src/collections/CampaignUser.ts index 192e541b..0e426654 100644 --- a/src/collections/CampaignUser.ts +++ b/src/collections/CampaignUser.ts @@ -1,7 +1,15 @@ +import { + ADVISOR_EDITING_OPTIONS, + ADVISOR_VISIBILITY_OPTIONS, + isCoherentAdvisorProfile, +} from '@/lib/campaignAdvisorProfile' import { CAMPAIGN_SESSION_TTL_LONG } from '@/lib/campaignSessionTtl' import { normalizeBrazilianPhone } from '@/lib/phone' import { relationshipId } from '@/lib/relationship' -import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor' +import { + ADVISOR_PROFILE_COHERENCE_MESSAGE, + isPlanilhaPlaceholderEmail, +} from '@/lib/schemas/advisor' import type { CampaignUser as CampaignUserDocument } from '@/payload-types' import { canCreateCampaignUserPhone, @@ -22,6 +30,7 @@ import { type CollectionAfterReadHook, type CollectionBeforeChangeHook, type CollectionBeforeDeleteHook, + type CollectionBeforeValidateHook, type CollectionConfig, type Payload, type PayloadRequest, @@ -117,12 +126,45 @@ const preventSelfServicePrivilegedFields: CollectionBeforeChangeHook< if (String(req.user.id) !== String(originalDoc.id)) return data - for (const field of ['role', 'name', 'email', 'username', 'phone', 'contact'] as const) { + for (const field of [ + 'role', + 'name', + 'email', + 'username', + 'phone', + 'contact', + 'visibility', + 'editing', + ] as const) { if (field in data) delete data[field] } return data } + +/** + * C141 — the advisor permission profile must stay coherent: Edição "Tudo" + * (edits everything visible) only makes sense with Visão "Tudo". Fail-closed on + * the API itself (REST/admin included), not just in the campaign form actions. + * Non-advisor roles never consult the profile, so the rule only guards advisors. + */ +const enforceCoherentAdvisorProfile: CollectionBeforeValidateHook = async ({ + data, + originalDoc, +}) => { + if (!data) return data + + const role = data.role ?? originalDoc?.role + if (role !== 'advisor') return data + + const visibility = data.visibility ?? originalDoc?.visibility ?? 'carteira' + const editing = data.editing ?? originalDoc?.editing ?? 'carteira' + if (!isCoherentAdvisorProfile(visibility, editing)) { + throw new APIError(ADVISOR_PROFILE_COHERENCE_MESSAGE, 400) + } + + return data +} /** * Resolves the `Contact` ficha for an account that has none yet: by phone * (find-or-create, same dedupe policy as the supporter flows) or a fresh @@ -383,6 +425,7 @@ export const CampaignUser: CollectionConfig = { delete: canManageCampaignUsers, }, hooks: { + beforeValidate: [enforceCoherentAdvisorProfile], beforeChange: [ preventAssignedAdvisorDowngrade, preventSelfServicePrivilegedFields, @@ -441,6 +484,36 @@ export const CampaignUser: CollectionConfig = { update: canManageCampaignUserRole, }, }, + { + name: 'visibility', + type: 'select', + label: 'Visão', + required: true, + defaultValue: 'carteira', + admin: { + description: 'O que o assessor enxerga no /campanha.', + condition: (data) => data.role === 'advisor', + }, + options: ADVISOR_VISIBILITY_OPTIONS.map(({ value, label }) => ({ value, label })), + access: { + update: canManageCampaignUserRole, + }, + }, + { + name: 'editing', + type: 'select', + label: 'Edição', + required: true, + defaultValue: 'carteira', + admin: { + description: 'O que o assessor pode editar no /campanha.', + condition: (data) => data.role === 'advisor', + }, + options: ADVISOR_EDITING_OPTIONS.map(({ value, label }) => ({ value, label })), + access: { + update: canManageCampaignUserRole, + }, + }, { name: 'email', type: 'email', diff --git a/src/components/campaign/advisor/AdvisorPermissionBadge.tsx b/src/components/campaign/advisor/AdvisorPermissionBadge.tsx new file mode 100644 index 00000000..60199c38 --- /dev/null +++ b/src/components/campaign/advisor/AdvisorPermissionBadge.tsx @@ -0,0 +1,67 @@ +'use client' + +import { useState } from 'react' + +import { AdvisorPermissionEditor } from '@/components/campaign/advisor/AdvisorPermissionEditor' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' +import { + advisorProfileLabel, + type AdvisorEditing, + type AdvisorVisibility, +} from '@/lib/campaignAdvisorProfile' +import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' + +type AdvisorPermissionBadgeProps = { + visibility: AdvisorVisibility + editing: AdvisorEditing + /** Present = saved on the server; absent = new-advisor draft row. */ + advisorId?: number + formAction?: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise + onDraftChange?: (visibility: AdvisorVisibility, editing: AdvisorEditing) => void + /** Names the account for the badge's accessible label. */ + advisorName?: string +} + +/** + * C141 — the "Permissão" cell: a compact badge that opens the Visão × Edição + * editor in a popover. Reused in the advisors list and the new-advisor draft + * row; the detail page embeds the editor directly in its own section. + */ +export const AdvisorPermissionBadge = ({ + visibility, + editing, + advisorId, + formAction, + onDraftChange, + advisorName = 'conta', +}: AdvisorPermissionBadgeProps) => { + const [open, setOpen] = useState(false) + + return ( + + + + + + setOpen(false)} + onCancel={() => setOpen(false)} + /> + + + ) +} diff --git a/src/components/campaign/advisor/AdvisorPermissionEditor.tsx b/src/components/campaign/advisor/AdvisorPermissionEditor.tsx new file mode 100644 index 00000000..ad6a40a9 --- /dev/null +++ b/src/components/campaign/advisor/AdvisorPermissionEditor.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useState, useTransition } from 'react' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' +import { Spinner } from '@/components/ui/Spinner' +import { + ADVISOR_EDITING_OPTIONS, + ADVISOR_VISIBILITY_OPTIONS, + type AdvisorEditing, + type AdvisorVisibility, +} from '@/lib/campaignAdvisorProfile' +import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' + +type AdvisorPermissionEditorProps = { + visibility: AdvisorVisibility + editing: AdvisorEditing + /** + * Present = the profile is saved on the server through `formAction`; + * absent = draft mode (new-advisor row), where the values only update local + * state via `onDraftChange`. + */ + advisorId?: number + formAction?: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise + onDraftChange?: (visibility: AdvisorVisibility, editing: AdvisorEditing) => void + /** Fired after a successful save (or draft change) — closes the popover. */ + onSaved?: () => void + /** When provided, renders a "Cancelar" button (popover footer). */ + onCancel?: () => void + /** The popover renders its own heading; the detail section has its own h2. */ + showHeading?: boolean +} + +const EDITING_NOTE = + 'Demandas seguem regra própria: só quem é responsável pela demanda (e candidato/coordenador) a vê — mesmo com visão "Tudo". Decisões de coordenação (contas, carteiras, nível de envolvimento, demandas escaladas) continuam restritas a coordenador e candidato.' + +/** + * C141 — the Visão × Edição editor, shared by the advisors list popover, the + * advisor detail section and the new-advisor draft row. Only coherent + * combinations are offered: picking Edição "Tudo" raises Visão to "Tudo", and + * lowering Visão to "Carteira" drops Edição to "Carteira". + */ +export const AdvisorPermissionEditor = ({ + visibility: initialVisibility, + editing: initialEditing, + advisorId, + formAction, + onDraftChange, + onSaved, + onCancel, + showHeading = true, +}: AdvisorPermissionEditorProps) => { + const [visibility, setVisibility] = useState(initialVisibility) + const [editing, setEditing] = useState(initialEditing) + const [isSaving, startSave] = useTransition() + + const updateEditing = (next: AdvisorEditing) => { + if (next === 'tudo') setVisibility('tudo') + setEditing(next) + } + + const updateVisibility = (next: AdvisorVisibility) => { + setVisibility(next) + if (next === 'carteira' && editing === 'tudo') setEditing('carteira') + } + + const save = () => { + if (advisorId === undefined || formAction === undefined) { + onDraftChange?.(visibility, editing) + onSaved?.() + return + } + + const formData = new FormData() + formData.set('advisorId', String(advisorId)) + formData.set('visibility', visibility) + formData.set('editing', editing) + + startSave(async () => { + const result = await formAction({}, formData) + if (result.status === 'success') { + toast.success(result.message ?? 'Permissão atualizada.') + onSaved?.() + } else { + toast.error(result.message ?? 'Não foi possível atualizar a permissão.') + } + }) + } + + const selectedVisibility = ADVISOR_VISIBILITY_OPTIONS.find( + (option) => option.value === visibility, + ) + const selectedEditing = ADVISOR_EDITING_OPTIONS.find((option) => option.value === editing) + + return ( +
+ {showHeading ? ( +
+

Permissão da conta

+

+ Vale para todas as telas do /campanha. +

+
+ ) : null} + +
+ + updateVisibility(event.currentTarget.value as AdvisorVisibility)} + className="w-full" + > + {ADVISOR_VISIBILITY_OPTIONS.map((option) => ( + + {option.label} — {option.description} + + ))} + +

+ {selectedVisibility?.description}. +

+
+ +
+ + updateEditing(event.currentTarget.value as AdvisorEditing)} + className="w-full" + > + {ADVISOR_EDITING_OPTIONS.map((option) => ( + + {option.label} — {option.description} + + ))} + +

+ {selectedEditing?.description}. +

+
+ +
+ {EDITING_NOTE} +
+ +
+ {onCancel ? ( + + ) : null} + +
+
+ ) +} diff --git a/src/components/campaign/advisor/AdvisorsTable.tsx b/src/components/campaign/advisor/AdvisorsTable.tsx index d35dfd19..1ac02353 100644 --- a/src/components/campaign/advisor/AdvisorsTable.tsx +++ b/src/components/campaign/advisor/AdvisorsTable.tsx @@ -16,6 +16,7 @@ import { toast } from 'sonner' import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell' import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton' +import { AdvisorPermissionBadge } from '@/components/campaign/advisor/AdvisorPermissionBadge' import { CampaignCopyableCell, campaignReadCellClassName, @@ -34,6 +35,7 @@ import { TableHeader, TableRow, } from '@/components/ui/Table' +import type { AdvisorEditing, AdvisorVisibility } from '@/lib/campaignAdvisorProfile' import { resolveVisibleColumns, toCampaignColumnPickerColumns, @@ -56,6 +58,8 @@ type DraftAdvisor = { email: string phone: string municipalityIds: number[] + visibility: AdvisorVisibility + editing: AdvisorEditing } // B197+ — single source for the ``s and the picker menu: a column added @@ -78,6 +82,7 @@ const advisorTableColumns: readonly AdvisorTableColumn[] = [ { id: 'email', label: 'E-mail', headerClassName: 'w-[24%]' }, { id: 'phone', label: 'Celular', mandatory: true, headerClassName: 'w-[14%]' }, { id: 'municipalities', label: 'Municípios', mandatory: true }, + { id: 'permission', label: 'Permissão', mandatory: true, headerClassName: 'w-48' }, { id: 'actions', label: 'Ações', @@ -111,6 +116,10 @@ type AdvisorsTableProps = { state: CampaignFormActionState, formData: FormData, ) => Promise + updatePermissionAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise autoCreateDraft?: boolean } @@ -119,6 +128,8 @@ const emptyDraft = (): DraftAdvisor => ({ email: '', phone: '', municipalityIds: [], + visibility: 'carteira', + editing: 'carteira', }) const displayEmail = (email: string | null): string | null => { @@ -135,6 +146,7 @@ export const AdvisorsTable = ({ municipalitiesAction, createAction, passwordResetAction, + updatePermissionAction, autoCreateDraft = false, }: AdvisorsTableProps) => { const router = useRouter() @@ -192,6 +204,8 @@ export const AdvisorsTable = ({ const formData = new FormData() formData.set('name', draft.name.trim()) formData.set('email', draft.email.trim()) + formData.set('visibility', draft.visibility) + formData.set('editing', draft.editing) if (draft.phone.trim()) formData.set('phone', draft.phone.trim()) for (const municipalityId of draft.municipalityIds) { formData.append('municipalityIds', String(municipalityId)) @@ -333,6 +347,18 @@ export const AdvisorsTable = ({ updateErrorMessage="Não foi possível atualizar a carteira." /> + + + setDraft((current) => + current ? { ...current, visibility, editing } : current, + ) + } + /> +