Add campaign password reset, profile photo, and RS+ follow-up plan.

Deliver self-service forgot/reset via Resend, logged-in password change, and optional avatar on /campanha/perfil; register post-simplify engineering debts as RS+.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 05:26:56 -03:00
parent e1203a665e
commit 3b463173da
35 changed files with 10858 additions and 52 deletions

View file

@ -16,6 +16,7 @@ alwaysApply: false
- **B2 Mapa Fase 1 (geometrias) entregue (2026-07-18):** TopoJSON estático municípios IBGE (`bahia-municipalities.topo.json`, ~132 KB, object `municipalities`) + TIs por dissolução (`bahia-identity-territories.topo.json`, ~15 KB, object `territories`); tabela `bahiaMunicipalityCodes` + `codeForMunicipality` / `municipalityForCode`; helpers `bahiaGeometries` (`getMunicipalityFeature` / `getTerritoryFeature`); script re-executável `pnpm build:geometries` (cache `data/geometries/`, sem DB); testes int de bijeção/cobertura/teto de bytes. Sem migration, sem UI (Leaflet = B3). Plano as-built: [`docs/plans/mapa-bahia-geometrias.md`](../../../docs/plans/mapa-bahia-geometrias.md).
- **B5 registrado (2026-07-18):** débitos de escala/DRY pós-B2 do `/simplify` que não entraram no cleanup (lazy load mun/TI + helper `ensureCachedDownload` para `build:geometries` / `db:seed:tse`) — plano [`docs/plans/escala-dry-pos-b2.md`](../../../docs/plans/escala-dry-pos-b2.md); F1 preferencialmente com B3; F2 cortável.
- **C7 registrado (2026-07-18) / entregue (2026-07-19):** débitos de escala/DRY pós-C3 do `/simplify` — plano [`docs/plans/escala-dry-pos-c3.md`](../../../docs/plans/escala-dry-pos-c3.md); composição territorial, shell assíncrono de contato, leituras por aba, short-circuit de hooks. Débitos pós-`/simplify` → **C11** ([`docs/plans/escala-dry-pos-c7.md`](../../../docs/plans/escala-dry-pos-c7.md)).
- **Fill-in reset de senha + foto de perfil (2026-07-19):** MVP implementado — plano [`docs/plans/reset-senha-foto-perfil.md`](../../../docs/plans/reset-senha-foto-perfil.md) (trocar senha logado + forgot por e-mail Resend + fallback convite; `/campanha/perfil` com avatar). Débitos pós-`/simplify` → **RS+** ([`escala-dry-pos-reset-senha-perfil.md`](../../../docs/plans/escala-dry-pos-reset-senha-perfil.md)): auth read `depth: 0`, avatar fora da TX, `CampaignAuthPageShell`, schemas de senha unificados.
- **Trilha E registrada (2026-07-19) — mapa de projeção por município:** plano E1–E5 para equiparar e superar as planilhas "Mapa de projeção de votos Solla 2026" (`docs/sheets/`): E1 metas em cenários (`voteGoals` Bom/Regular/Mínimo) + `priority` no núcleo (migration compartilhada com E3), E2 série histórica TSE 2014/2018 + `computeVoteTrend` derivado (sem migration), E3 `dobradinhaNotes`/`nextSteps` staff-only na inteligência, E4 import único `pnpm db:seed:mapa` (sem criar `Contact`/`leadership`), E5 Salvador por bairro registrado como futuro. Plano: [`docs/plans/mapa-projecao-municipios.md`](../../../docs/plans/mapa-projecao-municipios.md).
- **E2 entregue (2026-07-19):** seed TSE parametrizado (`pnpm db:seed:tse -- --year=2014|2018|2022`; anos históricos só dep. federal T1 BA), `computeVoteTrend` ±10% (`increase|stable|decline|noBaseline`), loader candidate-only multi-ano, série + badge no `NucleusElectoralBaseline`, Alert de tendência no `NucleusInsights`, card "Tendência histórica" no overview da lista. Sem migration.
- Fluxos operacionais cobertos: login isolado, CRUD/arquivo de núcleos por slug, designação de coordenador (`geral`), CRM de lideranças, estimativas sugerir/confirmar com versão UUID, feed de atualizações, convites WhatsApp (`wa.me` + hash de uso único), dashboard com agregados só de núcleos ativos.

View file

@ -22,5 +22,10 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Vercel project env for production. Generate with e.g. `openssl rand -hex 32`.
REVALIDATE_SECRET=YOUR_REVALIDATE_SECRET_HERE
# Campaign password reset emails (Resend). Required in production for forgot-password.
RESEND_API_KEY=YOUR_RESEND_API_KEY_HERE
CAMPAIGN_EMAIL_FROM=campanha@jorgesolla.com.br
CAMPAIGN_EMAIL_FROM_NAME=Campanha Jorge Solla
# Note: the automated test suite ignores this file and reads .env.test instead,
# and only ever runs against a database whose name ends with "_test".

View file

@ -0,0 +1,107 @@
# Escala e DRY pós-reset de senha + foto de perfil
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (RS+, fill-in de engenharia pós-MVP)
Responsável: —
## Contexto
O fill-in **Reset de senha self-service + foto de perfil** ([reset-senha-foto-perfil.md](reset-senha-foto-perfil.md)) entregou: adapter Resend + `forgotPassword`/`resetPassword`, `/campanha/esqueci-senha` e `/campanha/redefinir-senha`, troca de senha logada e upload/remoção de `avatar` em `/campanha/perfil`, shell com avatar/iniciais e migration `20260719_061302_add_campaign_user_avatar`. Duas passagens `/simplify` (2026-07-19) aplicaram cleanup pontual; os revisores (quality / performance / reuse) deixaram débitos **maiores que cleanup** — registrados aqui.
**Já resolvido no simplify (não reabrir):** `CampaignFormActionState` compartilhado; constantes de copy (`CAMPAIGN_PASSWORD_RESET_*`, `CAMPAIGN_LEADERSHIP_FORGOT_PASSWORD_MESSAGE`, `CAMPAIGN_SESSION_EXPIRED_MESSAGE`); `campaignUserShellView` / `CampaignUserShellView`; `canUpdateCampaignUserAvatar` delegando para `canUpdateCampaignUser`; `fieldError` nos forms; `FormActionStatus` no perfil; remoção de wrappers `passwordFormError`/`profileFormError`; exports internos de `resetCampaignPassword`/`changeCampaignPassword`; teste int renomeado para login com senha errada.
## Objetivos
- Auth hot path sem join de `media` em rotas que não exibem avatar (núcleos, apoiadores, planos, etc.).
- Upload de avatar sem segurar transação Postgres aberta durante I/O de blob/sharp.
- Shell compartilhado das páginas públicas de auth (`login`, `esqueci-senha`, `redefinir-senha`).
- Um único módulo de validação/campos de senha reutilizado por convite, reset e perfil.
- Login alinhado ao shape `CampaignFormActionState` + `mapCampaignFormActionError` das demais actions de campanha.
- Guardrails: sem novo `Consent`; sem SMS/WhatsApp para token; comportamento de produto 1C inalterado salvo bugs encontrados.
## Decisões travadas
- **Um plano RS+, cinco fases ordenadas.** Mesmo racional de VR+/O0+/C8: um registro no roadmap, PRs por fase. Ordem: auth read perf → transação/revalidate avatar → DRY UI/senha/login → consolidação forms avatar → opcionais.
- **Dependência suave do fill-in Reset senha + perfil ✓.** Não bloqueia uso do MVP; melhora I/O, consistência visual e manutenção.
- **Sem migration nova** salvo índice/campo não previsto — escopo é refactor de leitura/escrita e componentes.
- **Cortável:** Fases 3–5 até outra vertical de auth ganhar terceiro consumidor; **Fase 1** (auth read) é a mais valiosa se `/campanha` ficar lento com muitos usuários simultâneos.
- **i18n e naming** (AGENTS.md): identificadores em inglês (`getCampaignUserWithAvatar`, `CampaignAuthPageShell`, `CampaignPasswordFields`), strings visíveis em pt-BR.
## Questões em aberto
- **`depth: 0` + `getCampaignUserWithAvatar()` vs `select` parcial em `authenticateCampaignToken`?** Hoje `depth: 1` popula `avatar` em todo request autenticado. **Recomendação:** `depth: 0` no gate + helper `getCampaignUserWithAvatar()` só no layout `(app)` e `/perfil`; páginas públicas usam check de sessão leve (`payload.auth` sem `findByID` completo).
- **Upload fora da transação: apagar `media` órfão em falha?** **Recomendação:** upload/commit blob primeiro; TX curta para `campaignUser.avatar` + delete do anterior; em falha pós-blob, job best-effort de limpeza (log + delete órfão) — documentar no plano de produto se volume crescer.
- **Unificar schemas de senha com `invite.ts` agora?** Três surfaces (convite login, reset, perfil). **Recomendação:** Fase 3 — extrair `campaignPasswordFields.ts` (schema + `refineMatchingPasswords`) consumido por `campaignPassword.ts` e `invite.ts`; não bloqueia RS+ Fase 1–2.
- **Migrar `LoginForm` para `CampaignFormActionState`?** Login é outlier com `{ error?: string }`. **Recomendação:** Fase 3 junto com `CampaignAuthPageShell`; baixo risco, alto alinhamento com forgot/reset/perfil.
## Abordagem proposta
```mermaid
flowchart TD
RS["Reset senha + perfil MVP ✓"] --> F1
F1["Fase 1 — Auth read perf<br/>depth 0 + getCampaignUserWithAvatar<br/>sessão leve em rotas públicas"]
F1 --> F2["Fase 2 — Avatar write perf<br/>blob fora da TX + revalidate estreito"]
F2 --> F3["Fase 3 — DRY auth UI/senha<br/>CampaignAuthPageShell + password fields + login state"]
F3 --> F4["Fase 4 — DRY copy/role + actions<br/>campaignRoleOptions + getCampaignActionContext"]
F4 --> F5["Fase 5 — Opcional<br/>avatar intent único + int change password"]
```
### Fase 1 — Auth read performance
- **`src/utilities/campaignAuth.ts`:** `authenticateCampaignToken` volta a `depth: 0` com `select` mínimo (`name`, `role`, `email`, `username`, `id`); novo `getCampaignUserWithAvatar()` (ou `populateCampaignUserAvatar`) com `depth: 1` só onde necessário.
- **`src/app/(campaign)/campanha/(app)/layout.tsx`:** trocar `getCampaignUser()` por leitura com avatar para o shell.
- **`src/app/(campaign)/campanha/esqueci-senha/page.tsx`**, **`redefinir-senha/page.tsx`:** substituir `getCampaignUser()` por check leve de sessão (redirect se já autenticado sem carregar `media`).
- Atualizar **`tests/int/campaignAuth.int.spec.ts`** (`depth: 0` no gate; teste separado para layout avatar se necessário).
### Fase 2 — Avatar write performance
- **`src/app/(campaign)/campanha/actions/profile.ts`:** criar `media` (blob) **antes** de `withPayloadTransaction`; TX curta só para `campaignUser.update` + `media.delete` do anterior.
- Trocar `revalidatePath('/campanha', 'layout')` por tag/path mais estreito (`/campanha/perfil` + invalidação do layout só quando sidebar precisar — avaliar `revalidateTag` por usuário).
- Paralelizar `getPayload` + `getCampaignUser` onde aplicável (`Promise.all` ou `getCampaignActionContext` na Fase 4).
### Fase 3 — DRY UI e senha
- Extrair **`CampaignAuthPageShell`** (`src/components/campaign/CampaignAuthPageShell.tsx`): markup compartilhado de `login/page.tsx`, `esqueci-senha/page.tsx`, `redefinir-senha/page.tsx` (incluindo card de token inválido).
- Extrair **`CampaignPasswordFields`** (ou `CampaignNewPasswordFieldGroup`): campos `password` + `passwordConfirmation` com `errorProps` — reuso em `ResetPasswordForm`, bloco de senha do perfil e `CampaignInviteForm` (`kind === 'login'`).
- **`src/lib/schemas/campaignPassword.ts` + `invite.ts`:** factory compartilhada `refineMatchingPasswords` / `passwordSchema` (substituir check manual no convite).
- **`LoginForm.tsx` + `actions/auth.ts`:** migrar para `CampaignFormActionState` + `mapCampaignFormActionError`.
### Fase 4 — DRY copy, role e action context
- **`campaignRoleLabels`** ↔ opções do campo `role` em `CampaignUser.ts` (fonte única `campaignRoleOptions`).
- Propagar para **`NucleusUpdateFeed.tsx`** e outros `roleLabels` locais quando esses arquivos forem tocados.
- **`password.ts` / `profile.ts`:** adotar `getCampaignActionContext()` (`src/utilities/campaignActionContext.ts`) como `supporter.ts` / `actionPlan.ts`.
### Fase 5 — Opcional / cortável
- **`CampaignProfileSettings`:** um `useActionState` com `intent` (`upload` | `remove`) em vez de dois hooks de avatar.
- Validador de avatar retornando `Result` em vez de `throw` + `safeMessages`.
- Teste int de `changeCampaignPassword` com cookie de sessão simulado (hoje só cobre `payload.login` com senha errada).
- Fila assíncrona para e-mail de forgot-password (cold path; só se latência Resend incomodar).
## Dependências
- **Reset senha + perfil MVP ✓** ([reset-senha-foto-perfil.md](reset-senha-foto-perfil.md); engenharia pronta em branch local).
- Reusa `campaignAuth.ts`, `campaignPasswordReset.ts`, `campaignUserProfile.ts`, `campaignFormActionError.ts`, `campaignFormFields.ts`, `campaignActionContext.ts`.
- Sem migration, sem `Consent` novo.
## Não escopo
- Forgot por SMS/WhatsApp → permanece convite `login` ([reset-senha-foto-perfil.md](reset-senha-foto-perfil.md)).
- Edição de nome/telefone/e-mail no perfil → item futuro separado.
- RBAC em `users` (admin Payload) → [roadmap](../roadmap.md) Admin Payload.
- Unificar **todo** o ciclo 1 de auth (convite + login + admin) num único mega-refactor — RS+ cobre só o fill-in entregue e vizinhos diretos.
## Referências
- [reset-senha-foto-perfil.md](reset-senha-foto-perfil.md) — MVP entregue
- [escala-dry-pos-visitados-recentemente.md](escala-dry-pos-visitados-recentemente.md) — precedente RS+/VR+
- `src/utilities/campaignAuth.ts`, `src/app/(campaign)/campanha/actions/password.ts`, `profile.ts`
- `src/components/campaign/CampaignProfileSettings.tsx`, `CampaignSidebar.tsx`, `CampaignUserAvatar.tsx`
- `src/app/(campaign)/campanha/login/`, `esqueci-senha/`, `redefinir-senha/`
- `src/lib/schemas/campaignPassword.ts`, `src/lib/schemas/invite.ts`
- `src/collections/CampaignUser.ts`
- `tests/int/campaignPasswordReset.int.spec.ts`, `tests/int/campaignAuth.int.spec.ts`
- `docs/roadmap.md` (fill-ins RS+)
- AGENTS.md — Campaign auth, naming, `overrideAccess: false`, transações

View file

@ -0,0 +1,84 @@
# Reset de senha self-service + foto de perfil
Status: implementado
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins — UX adiada do ciclo 1)
Responsável: —
## Contexto
O MVP de Núcleos (ciclo 1) entregou login por e-mail ou celular e recuperação de acesso via convite WhatsApp `login` pelo coordenador, mas adiou explicitamente o link “Esqueceu?” no login e a foto de perfil no shell mobile ([plano MVP](file:///Users/francisco.solla/.cursor/plans/núcleos_eleitorais_mvp_7f788d29.plan.md)). Hoje `campaignUser` tem colunas Payload de reset (`resetPasswordToken`, …) sem UI; não há email adapter; não há `/campanha/perfil` nem campo `avatar`. Media + Vercel Blob já existem para uploads.
## Objetivos
- Trocar senha **já autenticado** em `/campanha/perfil`.
- Forgot por **e-mail** (Payload `forgotPassword` / `resetPassword` + Resend) com resposta anti-enumeration.
- Quem acessa só com celular (`lideranca` sem e-mail) continua pelo convite `login` — copy explícita no login e em `/campanha/esqueci-senha`.
- Foto de perfil opcional (`avatar` → `media`) com upload/remoção no perfil e exibição no shell.
- Sem `Consent` novo; sem SMS/WhatsApp para token.
## Decisões travadas
- **1C (produto 2026-07-19):** trocar senha logado + forgot por e-mail + fallback convite para sem e-mail.
- **Adapter Resend** (`@payloadcms/email-resend`), indicado para Vercel.
- **Self-update** de `campaignUser` liberado para o titular com hook que bloqueia alteração de `role`, `name`, `email`, `username`, `phone` (só `avatar` via UI neste MVP; senha via actions dedicadas).
- **Após reset por e-mail:** login automático (cookie `campaign-token`), mesmo padrão do convite `login`.
- **Avatar:** jpeg/png/webp, ≤ 2 MB; `alt` = nome do usuário.
- **i18n:** identificadores em inglês; strings visíveis em pt-BR.
## Questões em aberto
- **Perfil na bottom nav mobile?** **Recomendação:** link “Meu perfil” no rodapé da sidebar com avatar; não adicionar 5º item na bottom nav (evita poluição). Validar com produto.
- **Apagar `media` órfã ao trocar avatar?** **Recomendação:** sim, deletar documento anterior após commit bem-sucedido. Validar com produto se volume justificar fila.
## Abordagem proposta
```mermaid
flowchart LR
Login["/campanha/login"]
Forgot["/campanha/esqueci-senha"]
Reset["/campanha/redefinir-senha"]
Perfil["/campanha/perfil"]
Resend["Resend"]
Invite["Convite login"]
Login --> Forgot
Login -.-> Invite
Forgot --> Resend
Resend --> Reset
Reset --> Perfil
Perfil --> Perfil
```
Componentes:
- **`src/utilities/campaignPasswordReset.ts`:** `buildCampaignPasswordResetUrl`, `isCampaignEmailConfigured`, constantes de copy.
- **`src/lib/schemas/campaignPassword.ts`:** Zod para forgot / reset / change.
- **`src/app/(campaign)/campanha/actions/password.ts`:** `requestCampaignPasswordReset`, `resetCampaignPassword`, `changeCampaignPassword` + form actions.
- **`src/app/(campaign)/campanha/actions/profile.ts`:** `updateCampaignAvatar`, `removeCampaignAvatar`.
- **`src/collections/CampaignUser.ts`:** `auth.forgotPassword` HTML/subject; campo `avatar`; hook `preventSelfServicePrivilegedFields`; access self-update.
- Rotas públicas: `/campanha/esqueci-senha`, `/campanha/redefinir-senha` (`noindex`).
- **`/campanha/(app)/perfil`:** senha + avatar; shell com avatar/iniciais.
**Migration:** `add_campaign_user_avatar` — FK `campaign_user.avatar_id` → `media`.
## Dependências
- Nenhuma de outro plano. Reusa `campaignAuth`, `campaignInviteOrigin.getCampaignInviteBaseURL`, `Media` + Blob, `mapCampaignFormActionError`.
## Não escopo
- Forgot por SMS/WhatsApp Business.
- Edição de nome/telefone/e-mail no perfil.
- Forgot para collection `users` (admin Payload).
- Sino de notificações ([notifications.md](notifications.md)).
- E-mail obrigatório para `lideranca`.
## Referências
- `docs/roadmap.md` (Fill-ins)
- [escala-dry-pos-reset-senha-perfil.md](escala-dry-pos-reset-senha-perfil.md) — débitos de escala/DRY pós-`/simplify` (RS+)
- `src/collections/CampaignUser.ts`
- `src/app/(campaign)/campanha/actions/auth.ts`
- `src/utilities/campaignInviteOrigin.ts`
- AGENTS.md — Campaign auth, migrations, naming

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md))
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **Reset senha + foto de perfil MVP** implementado (fill-in, Resend + `/campanha/perfil`) — [plano](plans/reset-senha-foto-perfil.md); **RS+ registrado** — escala/DRY pós-reset senha/perfil do `/simplify` — [plano](plans/escala-dry-pos-reset-senha-perfil.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md))
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
@ -120,6 +120,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| C11 Escala e DRY pós-C7 | — (sem design novo; reusa os de C3 + padrões de núcleos/C6/C9) | [escala-dry-pos-c7.md](plans/escala-dry-pos-c7.md) |
| O0+ Escala e DRY pós-Onda 0 | — (sem design novo; reusa `/privacidade` + padrão revalidate `posts`) | [escala-dry-pos-onda0.md](plans/escala-dry-pos-onda0.md) |
| VR+ Escala e DRY pós-visitados | — (sem design novo; reusa dashboard + padrões de lista de núcleos) | [escala-dry-pos-visitados-recentemente.md](plans/escala-dry-pos-visitados-recentemente.md) |
| RS+ Escala e DRY pós-reset senha/perfil | — (sem design novo; reusa login + perfil + padrões de form de campanha) | [escala-dry-pos-reset-senha-perfil.md](plans/escala-dry-pos-reset-senha-perfil.md) |
| B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) |
| E6 Escala e DRY pós-E1+E3 | — (sem design novo; reusa Metas 2026 + overview B1) | [escala-dry-pos-e1.md](plans/escala-dry-pos-e1.md) |
| E7 Escala e DRY pós-E2 | — (sem design novo; reusa Baseline + Insights + overview tendência) | [escala-dry-pos-e2.md](plans/escala-dry-pos-e2.md) |
@ -241,7 +242,7 @@ flowchart TD
E6 -.geo cache.-> E7
```
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓) e fill-ins (listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), **A8** (após A1 ✓; suave B2 ✓ para `codarea` — paralelizável agora), E2 (após A4 ✓), **E7 (após E2 merge; Fase 2 int preferível antes de E4)**, **E6 (após merge E1+E3 em `main`)**, B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import; suave: E6 F1 e E7 F2 reduzem custo/risco do overview antes do import em massa).
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓), **RS+** (após Reset senha + perfil ✓) e fill-ins (listas globais, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. Fill-ins entregues em branch local (2026-07-19): Visitados recentemente, Reset senha + foto de perfil. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), **A8** (após A1 ✓; suave B2 ✓ para `codarea` — paralelizável agora), E2 (após A4 ✓), **E7 (após E2 merge; Fase 2 int preferível antes de E4)**, **E6 (após merge E1+E3 em `main`)**, B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import; suave: E6 F1 e E7 F2 reduzem custo/risco do overview antes do import em massa).
### Sequência de execução por janela do calendário
@ -302,11 +303,12 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0
- **O0+ Escala e DRY pós-Onda 0** (revalidate globals, chaves Consent, testes SQL provision, DRY Lexical/layout) — engenharia pós-MVP; paralelizável com smoke Onda 0. → [detalhes](plans/escala-dry-pos-onda0.md)
- **Visitados recentemente** ✓ (client-side, `localStorage`; MVP entregue 2026-07-19). → [detalhes](plans/visitados-recentemente.md)
- **VR+ Escala e DRY pós-visitados recentemente** (refresh bfcache/storage, DRY tempo relativo, `CampaignLinkListRow`, shell do dashboard) — engenharia pós-MVP; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-visitados-recentemente.md)
- **Reset de senha self-service + foto de perfil** ✓ (Resend + `/campanha/perfil`; MVP entregue 2026-07-19). → [detalhes](plans/reset-senha-foto-perfil.md)
- **RS+ Escala e DRY pós-reset senha/perfil** (auth read `depth: 0`, avatar fora da TX, `CampaignAuthPageShell`, schemas de senha unificados, login em `CampaignFormActionState`) — engenharia pós-MVP; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-reset-senha-perfil.md)
- **Listas globais** (lideranças, atualizações, territórios no nível raiz). _(plano MVP; sem plano detalhado ainda)_
- **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **A8 perfis IBGE** (a aba Eleitorado e `voterProfiles` manuais já existem; o calculado só acelera o ponto de partida), **E7 F1 e F3** (dedup visual/API — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend — evita regressão silenciosa antes de E4) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **A8 perfis IBGE** (a aba Eleitorado e `voterProfiles` manuais já existem; o calculado só acelera o ponto de partida), **E7 F1 e F3** (dedup visual/API — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), **RS+ Fases 3–5** (shell auth / campos de senha compartilhados / avatar intent único — Fase 1 auth read é a mais valiosa se `/campanha` ficar lento), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **RS+ Fase 1** (auth read sem join de avatar em rotas operacionais), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend — evita regressão silenciosa antes de E4) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
### Itens consolidados/removidos nesta revisão (2026-07-17)

View file

@ -50,6 +50,7 @@
"@base-ui/react": "^1.6.0",
"@hookform/resolvers": "^5.2.2",
"@payloadcms/db-postgres": "3.82.0",
"@payloadcms/email-resend": "3.82.0",
"@payloadcms/live-preview-react": "3.82.0",
"@payloadcms/next": "3.82.0",
"@payloadcms/richtext-lexical": "3.82.0",

View file

@ -17,6 +17,9 @@ importers:
'@payloadcms/db-postgres':
specifier: 3.82.0
version: 3.82.0(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))
'@payloadcms/email-resend':
specifier: 3.82.0
version: 3.82.0(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))
'@payloadcms/live-preview-react':
specifier: 3.82.0
version: 3.82.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -1678,6 +1681,12 @@ packages:
peerDependencies:
payload: 3.82.0
'@payloadcms/email-resend@3.82.0':
resolution: {integrity: sha512-XLA56zJ3/FiJwkMXobtOH6GEH9Qazfca6jZObX0O+m/zBRd0jskbbkHb6VySy8nnxJpO+awZtP8tLGQTkzhzTQ==}
engines: {node: ^18.20.2 || >=20.9.0}
peerDependencies:
payload: 3.82.0
'@payloadcms/graphql@3.82.0':
resolution: {integrity: sha512-RfDOOzPxqjiuOrKCBaqn2Dxiyc97uSv0DWZMQ9yvmPDmA9gTRafqkyg0BMerQxlopGGASLzfbmmdskNDuZBa8g==}
hasBin: true
@ -7910,6 +7919,10 @@ snapshots:
- sql.js
- sqlite3
'@payloadcms/email-resend@3.82.0(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))':
dependencies:
payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3)
'@payloadcms/graphql@3.82.0(graphql@16.12.0)(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(typescript@5.7.3)':
dependencies:
graphql: 16.12.0

View file

@ -7,6 +7,7 @@ import { InstallPwaToast } from '@/components/campaign/InstallPwaToast'
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/Sidebar'
import { Toaster } from '@/components/ui/Toaster'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { campaignUserShellView } from '@/utilities/campaignUserProfile'
export default async function CampaignAppLayout({ children }: { children: React.ReactNode }) {
const user = await getCampaignUser()
@ -17,7 +18,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
return (
<SidebarProvider className="h-svh min-h-0 overflow-hidden">
<CampaignSidebar user={{ name: user.name, email: user.email, role: user.role }} />
<CampaignSidebar user={campaignUserShellView(user)} />
<SidebarInset className="h-svh min-h-0 overflow-hidden">
<header className="flex min-h-14 shrink-0 items-center gap-3 bg-primary px-4 text-primary-foreground md:hidden">
<SidebarTrigger

View file

@ -0,0 +1,37 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { CampaignProfileSettings } from '@/components/campaign/CampaignProfileSettings'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { campaignUserShellView } from '@/utilities/campaignUserProfile'
export const metadata: Metadata = {
title: 'Meu perfil | Campanha',
robots: {
index: false,
follow: false,
},
}
type CampaignProfilePageProps = {
searchParams: Promise<{ passwordReset?: string }>
}
export default async function CampaignProfilePage({ searchParams }: CampaignProfilePageProps) {
const [user, params] = await Promise.all([getCampaignUser(), searchParams])
if (!user) {
redirect('/campanha/login')
}
return (
<CampaignProfileSettings
user={{
...campaignUserShellView(user),
email: user.email,
username: user.username,
}}
passwordResetBanner={params.passwordReset === '1'}
/>
)
}

View file

@ -0,0 +1,212 @@
'use server'
import config from '@payload-config'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { requiredFormSecret, requiredFormText } from '@/lib/formData'
import {
campaignChangePasswordSchema,
campaignPasswordResetRequestSchema,
campaignPasswordResetSchema,
} from '@/lib/schemas/campaignPassword'
import {
assertCampaignEmailConfigured,
CAMPAIGN_LEADERSHIP_FORGOT_PASSWORD_MESSAGE,
CAMPAIGN_PASSWORD_RESET_GENERIC_MESSAGE,
CAMPAIGN_PASSWORD_RESET_INVALID_TOKEN_MESSAGE,
isCampaignEmailConfigured,
} from '@/utilities/campaignPasswordReset'
import { campaignLoginCredentials, getCampaignUser, setCampaignAuthCookie } from '@/utilities/campaignAuth'
import {
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import { normalizeBrazilianPhone } from '@/utilities/phone'
export const requestCampaignPasswordReset = async (
input: { email: string },
): Promise<CampaignFormActionState> => {
const parsed = campaignPasswordResetRequestSchema.safeParse(input)
if (!parsed.success) {
return mapCampaignFormActionError({
error: parsed.error,
genericMessage: CAMPAIGN_PASSWORD_RESET_GENERIC_MESSAGE,
})
}
const payload = await getPayload({ config })
try {
assertCampaignEmailConfigured()
await payload.forgotPassword({
collection: 'campaignUser',
data: { email: parsed.data.email },
disableEmail: !isCampaignEmailConfigured(),
})
} catch {
// Anti-enumeration: never reveal whether the account exists or email failed.
}
return {
status: 'success',
message: CAMPAIGN_PASSWORD_RESET_GENERIC_MESSAGE,
}
}
export const requestCampaignPasswordResetFormAction = async (
_previousState: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const email = requiredFormText(formData, 'email')
const phone = normalizeBrazilianPhone(email)
if (phone) {
return {
fieldErrors: {
email: [CAMPAIGN_LEADERSHIP_FORGOT_PASSWORD_MESSAGE],
},
}
}
return requestCampaignPasswordReset({ email })
} catch (error) {
return mapCampaignFormActionError({
error,
genericMessage: CAMPAIGN_PASSWORD_RESET_GENERIC_MESSAGE,
})
}
}
const resetCampaignPassword = async (
input: { token: string; password: string; passwordConfirmation: string },
): Promise<CampaignFormActionState> => {
const parsed = campaignPasswordResetSchema.safeParse(input)
if (!parsed.success) {
return mapCampaignFormActionError({
error: parsed.error,
genericMessage: 'Não foi possível redefinir a senha.',
})
}
const payload = await getPayload({ config })
try {
const result = await payload.resetPassword({
collection: 'campaignUser',
data: {
token: parsed.data.token,
password: parsed.data.password,
},
overrideAccess: true,
})
if (!result.token) {
return { message: 'Não foi possível redefinir a senha.' }
}
await setCampaignAuthCookie(result.token, payload)
} catch {
return { message: CAMPAIGN_PASSWORD_RESET_INVALID_TOKEN_MESSAGE }
}
return { status: 'success' as const }
}
export const resetCampaignPasswordFormAction = async (
token: string,
_previousState: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const result = await resetCampaignPassword({
token,
password: requiredFormSecret(formData, 'password'),
passwordConfirmation: requiredFormSecret(formData, 'passwordConfirmation'),
})
if (result.status === 'success') {
redirect('/campanha/perfil?passwordReset=1')
}
return result
} catch (error) {
if (error instanceof Error && error.message === 'NEXT_REDIRECT') throw error
return mapCampaignFormActionError({
error,
genericMessage: 'Não foi possível redefinir a senha.',
})
}
}
const changeCampaignPassword = async (
input: { currentPassword: string; password: string; passwordConfirmation: string },
): Promise<CampaignFormActionState> => {
const user = await getCampaignUser()
if (!user) {
return { message: CAMPAIGN_SESSION_EXPIRED_MESSAGE }
}
const parsed = campaignChangePasswordSchema.safeParse(input)
if (!parsed.success) {
return mapCampaignFormActionError({
error: parsed.error,
genericMessage: 'Não foi possível alterar a senha.',
})
}
const payload = await getPayload({ config })
const credentials = campaignLoginCredentials(user, parsed.data.currentPassword)
if (!credentials) {
return { message: 'Não foi possível validar a conta atual.' }
}
try {
await payload.login({
collection: 'campaignUser',
data: credentials,
})
} catch {
return {
fieldErrors: {
currentPassword: ['Senha atual incorreta.'],
},
}
}
try {
await payload.update({
collection: 'campaignUser',
id: user.id,
data: { password: parsed.data.password },
overrideAccess: true,
user,
})
} catch {
return { message: 'Não foi possível alterar a senha.' }
}
return {
status: 'success',
message: 'Senha alterada com sucesso.',
}
}
export const changeCampaignPasswordFormAction = async (
_previousState: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
return changeCampaignPassword({
currentPassword: requiredFormSecret(formData, 'currentPassword'),
password: requiredFormSecret(formData, 'password'),
passwordConfirmation: requiredFormSecret(formData, 'passwordConfirmation'),
})
} catch (error) {
return mapCampaignFormActionError({
error,
genericMessage: 'Não foi possível alterar a senha.',
})
}
}

View file

@ -0,0 +1,151 @@
'use server'
import config from '@payload-config'
import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import {
assertCampaignAvatarFile,
CAMPAIGN_AVATAR_SAFE_ERROR_MESSAGES,
} from '@/utilities/campaignUserProfile'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { relationshipId } from '@/utilities/relationship'
const deleteAvatarMedia = async (
payload: Payload,
req: PayloadTransactionRequest,
mediaID: number | null,
): Promise<void> => {
if (mediaID === null) return
await payload.delete({
collection: 'media',
id: mediaID,
overrideAccess: true,
req,
})
}
export const updateCampaignAvatarFormAction = async (
_previousState: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
const user = await getCampaignUser()
if (!user) {
return { message: CAMPAIGN_SESSION_EXPIRED_MESSAGE }
}
const file = formData.get('avatar')
if (!(file instanceof File)) {
return { fieldErrors: { avatar: ['Selecione uma imagem.'] } }
}
try {
assertCampaignAvatarFile(file)
} catch (error) {
return mapCampaignFormActionError({
error,
genericMessage: 'Não foi possível atualizar a foto.',
safeMessages: CAMPAIGN_AVATAR_SAFE_ERROR_MESSAGES,
})
}
const payload = await getPayload({ config })
const previousAvatarID = relationshipId(user.avatar)
try {
await withPayloadTransaction(payload, async ({ req }) => {
const media = await payload.create({
collection: 'media',
data: { alt: user.name },
file: {
data: Buffer.from(await file.arrayBuffer()),
mimetype: file.type,
name: file.name,
size: file.size,
},
overrideAccess: true,
req,
})
await payload.update({
collection: 'campaignUser',
id: user.id,
data: { avatar: media.id },
depth: 0,
overrideAccess: true,
user,
req,
})
if (previousAvatarID !== null && previousAvatarID !== media.id) {
await deleteAvatarMedia(payload, req, previousAvatarID)
}
})
} catch (error) {
return mapCampaignFormActionError({
error,
genericMessage: 'Não foi possível atualizar a foto.',
safeMessages: CAMPAIGN_AVATAR_SAFE_ERROR_MESSAGES,
})
}
revalidatePath('/campanha', 'layout')
return {
status: 'success',
message: 'Foto de perfil atualizada.',
}
}
export const removeCampaignAvatarFormAction = async (
_previousState: CampaignFormActionState,
_formData: FormData,
): Promise<CampaignFormActionState> => {
const user = await getCampaignUser()
if (!user) {
return { message: CAMPAIGN_SESSION_EXPIRED_MESSAGE }
}
const previousAvatarID = relationshipId(user.avatar)
if (previousAvatarID === null) {
return { status: 'success', message: 'Foto de perfil removida.' }
}
const payload = await getPayload({ config })
try {
await withPayloadTransaction(payload, async ({ req }) => {
await payload.update({
collection: 'campaignUser',
id: user.id,
data: { avatar: null },
depth: 0,
overrideAccess: true,
user,
req,
})
await deleteAvatarMedia(payload, req, previousAvatarID)
})
} catch (error) {
return mapCampaignFormActionError({
error,
genericMessage: 'Não foi possível remover a foto.',
safeMessages: CAMPAIGN_AVATAR_SAFE_ERROR_MESSAGES,
})
}
revalidatePath('/campanha', 'layout')
return {
status: 'success',
message: 'Foto de perfil removida.',
}
}

View file

@ -0,0 +1,69 @@
'use client'
import Link from 'next/link'
import { useActionState } from 'react'
import { requestCampaignPasswordResetFormAction } from '@/app/(campaign)/campanha/actions/password'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
export const ForgotPasswordForm = () => {
const [state, formAction, pending] = useActionState(
requestCampaignPasswordResetFormAction,
{} satisfies CampaignFormActionState,
)
return (
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Esqueceu a senha?</CardTitle>
<CardDescription>
Informe o e-mail cadastrado na sua conta. Se você acessa só com celular, peça um novo
convite ao coordenador.
</CardDescription>
</CardHeader>
<CardContent>
{state.status === 'success' && state.message ? (
<p className="text-sm text-muted-foreground" role="status">
{state.message}
</p>
) : (
<form action={formAction}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
inputMode="email"
required
className="min-h-11"
/>
</Field>
{state.message ? <FieldError>{state.message}</FieldError> : null}
{fieldError(state.fieldErrors, 'email') ? (
<FieldError>{fieldError(state.fieldErrors, 'email')}</FieldError>
) : null}
<Button type="submit" className="min-h-11 w-full" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
<span aria-live="polite">{pending ? 'Enviando...' : 'Enviar link'}</span>
</Button>
<p className="text-center text-sm">
<Link href="/campanha/login" className="text-primary underline-offset-4 hover:underline">
Voltar ao login
</Link>
</p>
</FieldGroup>
</form>
)}
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,36 @@
import { Megaphone } from 'lucide-react'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { ForgotPasswordForm } from '@/app/(campaign)/campanha/esqueci-senha/ForgotPasswordForm'
import { getCampaignUser } from '@/utilities/campaignAuth'
export const metadata: Metadata = {
title: 'Esqueci a senha | Campanha',
robots: {
index: false,
follow: false,
},
}
export default async function CampaignForgotPasswordPage() {
const user = await getCampaignUser()
if (user) {
redirect('/campanha/perfil')
}
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex items-center gap-2 self-center font-semibold">
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Megaphone className="size-4" />
</div>
Campanha
</div>
<ForgotPasswordForm />
</div>
</main>
)
}

View file

@ -1,5 +1,6 @@
'use client'
import Link from 'next/link'
import { useActionState } from 'react'
import { loginCampaignFormAction } from '@/app/(campaign)/campanha/actions/auth'
@ -47,6 +48,14 @@ export const LoginForm = () => {
className="min-h-11"
/>
</Field>
<p className="text-sm">
<Link
href="/campanha/esqueci-senha"
className="text-primary underline-offset-4 hover:underline"
>
Esqueceu a senha?
</Link>
</p>
<Field>
{state.error ? <FieldError>{state.error}</FieldError> : null}
<Button type="submit" className="min-h-11" disabled={pending}>

View file

@ -0,0 +1,75 @@
'use client'
import Link from 'next/link'
import { useActionState } from 'react'
import { resetCampaignPasswordFormAction } from '@/app/(campaign)/campanha/actions/password'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
export const ResetPasswordForm = ({ token }: { token: string }) => {
const [state, formAction, pending] = useActionState(
resetCampaignPasswordFormAction.bind(null, token),
{} satisfies CampaignFormActionState,
)
return (
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Redefinir senha</CardTitle>
<CardDescription>Escolha uma nova senha com pelo menos 8 caracteres.</CardDescription>
</CardHeader>
<CardContent>
<form action={formAction}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="password">Nova senha</FieldLabel>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={8}
className="min-h-11"
/>
</Field>
<Field>
<FieldLabel htmlFor="passwordConfirmation">Confirmar nova senha</FieldLabel>
<Input
id="passwordConfirmation"
name="passwordConfirmation"
type="password"
autoComplete="new-password"
required
minLength={8}
className="min-h-11"
/>
</Field>
{state.message ? <FieldError>{state.message}</FieldError> : null}
{fieldError(state.fieldErrors, 'password') ? (
<FieldError>{fieldError(state.fieldErrors, 'password')}</FieldError>
) : null}
{fieldError(state.fieldErrors, 'passwordConfirmation') ? (
<FieldError>{fieldError(state.fieldErrors, 'passwordConfirmation')}</FieldError>
) : null}
<Button type="submit" className="min-h-11 w-full" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
<span aria-live="polite">{pending ? 'Salvando...' : 'Salvar nova senha'}</span>
</Button>
<p className="text-center text-sm">
<Link href="/campanha/login" className="text-primary underline-offset-4 hover:underline">
Voltar ao login
</Link>
</p>
</FieldGroup>
</form>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,71 @@
import { Megaphone } from 'lucide-react'
import type { Metadata } from 'next'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { CAMPAIGN_PASSWORD_RESET_TOKEN_MIN_LENGTH } from '@/lib/schemas/campaignPassword'
import { ResetPasswordForm } from '@/app/(campaign)/campanha/redefinir-senha/ResetPasswordForm'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { CAMPAIGN_PASSWORD_RESET_INVALID_TOKEN_MESSAGE } from '@/utilities/campaignPasswordReset'
export const metadata: Metadata = {
title: 'Redefinir senha | Campanha',
robots: {
index: false,
follow: false,
},
}
type CampaignResetPasswordPageProps = {
searchParams: Promise<{ token?: string }>
}
export default async function CampaignResetPasswordPage({
searchParams,
}: CampaignResetPasswordPageProps) {
const user = await getCampaignUser()
if (user) {
redirect('/campanha/perfil')
}
const { token } = await searchParams
if (!token || token.length < CAMPAIGN_PASSWORD_RESET_TOKEN_MIN_LENGTH) {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Link inválido</CardTitle>
<CardDescription>{CAMPAIGN_PASSWORD_RESET_INVALID_TOKEN_MESSAGE}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Button asChild className="min-h-11 w-full">
<Link href="/campanha/esqueci-senha">Solicitar novo link</Link>
</Button>
<Button asChild variant="outline" className="min-h-11 w-full">
<Link href="/campanha/login">Voltar ao login</Link>
</Button>
</CardContent>
</Card>
</div>
</main>
)
}
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex items-center gap-2 self-center font-semibold">
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Megaphone className="size-4" />
</div>
Campanha
</div>
<ResetPasswordForm token={token} />
</div>
</main>
)
}

View file

@ -6,8 +6,11 @@ import {
canReadCampaignUserIdentity,
canReadCampaignUserPhone,
canReadCampaignUsers,
canUpdateCampaignUser,
canUpdateCampaignUserAvatar,
canUpdateCampaignUserPhone,
} from '@/utilities/campaignAccess'
import { buildCampaignPasswordResetUrl } from '@/utilities/campaignPasswordReset'
import { normalizeBrazilianPhone } from '@/utilities/phone'
import {
APIError,
@ -75,6 +78,25 @@ const preventAssignedCoordinatorDowngrade: CollectionBeforeChangeHook<CampaignUs
return data
}
const preventSelfServicePrivilegedFields: CollectionBeforeChangeHook<CampaignUserDocument> = async ({
data,
operation,
originalDoc,
req,
}) => {
if (operation !== 'update' || !originalDoc || req.user?.collection !== 'campaignUser') {
return data
}
if (String(req.user.id) !== String(originalDoc.id)) return data
for (const field of ['role', 'name', 'email', 'username', 'phone'] as const) {
if (field in data) delete data[field]
}
return data
}
export const CampaignUser: CollectionConfig = {
slug: 'campaignUser',
labels: {
@ -91,15 +113,42 @@ export const CampaignUser: CollectionConfig = {
requireEmail: false,
requireUsername: false,
},
forgotPassword: {
generateEmailSubject: () => 'Redefinir sua senha — Campanha Jorge Solla',
generateEmailHTML: (args) => {
const token = args?.token
const user = args?.user
if (!token) {
throw new Error('Password reset token is required to generate email HTML.')
}
const resetPasswordURL = buildCampaignPasswordResetUrl(token)
const greeting = user?.email ? `Olá, ${user.email}` : 'Olá'
return `
<!doctype html>
<html lang="pt-BR">
<body style="font-family: sans-serif; line-height: 1.5; color: #111827;">
<p>${greeting}</p>
<p>Recebemos um pedido para redefinir a senha da sua conta na ferramenta de campanha.</p>
<p>
<a href="${resetPasswordURL}">Redefinir senha</a>
</p>
<p>Se você não solicitou esta alteração, ignore este e-mail.</p>
<p style="font-size: 12px; color: #6b7280;">${resetPasswordURL}</p>
</body>
</html>
`
},
},
},
access: {
create: canManageCampaignUsers,
read: canReadCampaignUsers,
update: canManageCampaignUsers,
update: canUpdateCampaignUser,
delete: canManageCampaignUsers,
},
hooks: {
beforeChange: [preventAssignedCoordinatorDowngrade],
beforeChange: [preventAssignedCoordinatorDowngrade, preventSelfServicePrivilegedFields],
afterRead: [removePrivateAuthFields],
},
fields: [
@ -109,6 +158,16 @@ export const CampaignUser: CollectionConfig = {
label: 'Nome',
required: true,
},
{
name: 'avatar',
type: 'upload',
relationTo: 'media',
label: 'Foto de perfil',
access: {
read: () => true,
update: canUpdateCampaignUserAvatar,
},
},
{
name: 'role',
type: 'select',
@ -133,6 +192,7 @@ export const CampaignUser: CollectionConfig = {
index: true,
access: {
read: canReadCampaignUserIdentity,
update: canManageCampaignUserRole,
},
},
{
@ -168,6 +228,7 @@ export const CampaignUser: CollectionConfig = {
index: true,
access: {
read: canReadCampaignUserIdentity,
update: canManageCampaignUserRole,
},
hooks: {
beforeValidate: [

View file

@ -0,0 +1,215 @@
'use client'
import { useActionState } from 'react'
import { changeCampaignPasswordFormAction } from '@/app/(campaign)/campanha/actions/password'
import {
removeCampaignAvatarFormAction,
updateCampaignAvatarFormAction,
} from '@/app/(campaign)/campanha/actions/profile'
import { CampaignUserAvatar } from '@/components/campaign/CampaignUserAvatar'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import type { CampaignUser } from '@/payload-types'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
import { campaignRoleLabels, type CampaignUserShellView } from '@/utilities/campaignUserProfile'
import { formatLeadershipPhone } from '@/utilities/leadershipUi'
type CampaignProfileSettingsProps = {
user: CampaignUserShellView & {
email?: string | null
username?: string | null
}
passwordResetBanner?: boolean
}
const FormActionStatus = ({ state }: { state: CampaignFormActionState }) => {
if (!state.message) return null
return (
<p
className={
state.status === 'success' ? 'text-sm text-foreground' : 'text-sm text-destructive'
}
role="status"
>
{state.message}
</p>
)
}
export const CampaignProfileSettings = ({
user,
passwordResetBanner = false,
}: CampaignProfileSettingsProps) => {
const [passwordState, passwordAction, passwordPending] = useActionState(
changeCampaignPasswordFormAction,
{} satisfies CampaignFormActionState,
)
const [avatarState, avatarAction, avatarPending] = useActionState(
updateCampaignAvatarFormAction,
{} satisfies CampaignFormActionState,
)
const [removeAvatarState, removeAvatarAction, removeAvatarPending] = useActionState(
removeCampaignAvatarFormAction,
{} satisfies CampaignFormActionState,
)
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Meu perfil</h1>
<p className="text-sm text-muted-foreground">
Gerencie sua foto e senha de acesso à ferramenta de campanha.
</p>
</div>
{passwordResetBanner ? (
<p className="rounded-md border border-border bg-muted/50 px-4 py-3 text-sm" role="status">
Senha redefinida com sucesso. Você já está autenticado.
</p>
) : null}
<Card>
<CardHeader>
<CardTitle>Identificação</CardTitle>
<CardDescription>Dados da sua conta neste ciclo.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center">
<CampaignUserAvatar name={user.name} avatarUrl={user.avatarUrl} size="lg" />
<dl className="grid gap-2 text-sm">
<div>
<dt className="text-muted-foreground">Nome</dt>
<dd className="font-medium">{user.name}</dd>
</div>
<div>
<dt className="text-muted-foreground">Papel</dt>
<dd>{campaignRoleLabels[user.role]}</dd>
</div>
{user.email ? (
<div>
<dt className="text-muted-foreground">E-mail</dt>
<dd>{user.email}</dd>
</div>
) : null}
{user.username ? (
<div>
<dt className="text-muted-foreground">Celular de acesso</dt>
<dd>{formatLeadershipPhone(user.username)}</dd>
</div>
) : null}
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Foto de perfil</CardTitle>
<CardDescription>JPEG, PNG ou WebP, até 2 MB.</CardDescription>
</CardHeader>
<CardContent>
<form action={avatarAction} className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="avatar">Nova foto</FieldLabel>
<Input
id="avatar"
name="avatar"
type="file"
accept="image/jpeg,image/png,image/webp"
className="min-h-11"
/>
</Field>
<FormActionStatus state={avatarState} />
{fieldError(avatarState.fieldErrors, 'avatar') ? (
<FieldError>{fieldError(avatarState.fieldErrors, 'avatar')}</FieldError>
) : null}
<div className="flex flex-col gap-2 sm:flex-row">
<Button type="submit" className="min-h-11" disabled={avatarPending}>
{avatarPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
<span>{avatarPending ? 'Enviando...' : 'Atualizar foto'}</span>
</Button>
</div>
</form>
<FormActionStatus state={removeAvatarState} />
{user.avatarUrl ? (
<form action={removeAvatarAction} className="mt-2">
<Button type="submit" variant="outline" className="min-h-11" disabled={removeAvatarPending}>
Remover foto
</Button>
</form>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Alterar senha</CardTitle>
<CardDescription>
{user.email
? 'Use sua senha atual para definir uma nova.'
: 'Se você esqueceu a senha, peça um novo convite de acesso ao coordenador.'}
</CardDescription>
</CardHeader>
<CardContent>
<form action={passwordAction}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="currentPassword">Senha atual</FieldLabel>
<Input
id="currentPassword"
name="currentPassword"
type="password"
autoComplete="current-password"
required
className="min-h-11"
/>
</Field>
<Field>
<FieldLabel htmlFor="password">Nova senha</FieldLabel>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={8}
className="min-h-11"
/>
</Field>
<Field>
<FieldLabel htmlFor="passwordConfirmation">Confirmar nova senha</FieldLabel>
<Input
id="passwordConfirmation"
name="passwordConfirmation"
type="password"
autoComplete="new-password"
required
minLength={8}
className="min-h-11"
/>
</Field>
<FormActionStatus state={passwordState} />
{fieldError(passwordState.fieldErrors, 'currentPassword') ? (
<FieldError>{fieldError(passwordState.fieldErrors, 'currentPassword')}</FieldError>
) : null}
{fieldError(passwordState.fieldErrors, 'password') ? (
<FieldError>{fieldError(passwordState.fieldErrors, 'password')}</FieldError>
) : null}
{fieldError(passwordState.fieldErrors, 'passwordConfirmation') ? (
<FieldError>{fieldError(passwordState.fieldErrors, 'passwordConfirmation')}</FieldError>
) : null}
<Button type="submit" className="min-h-11" disabled={passwordPending}>
{passwordPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
<span>{passwordPending ? 'Salvando...' : 'Salvar nova senha'}</span>
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
)
}

View file

@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation'
import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth'
import { CampaignLogo } from '@/components/campaign/campaign-logo'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { CampaignUserAvatar } from '@/components/campaign/CampaignUserAvatar'
import { getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav'
import { Button } from '@/components/ui/button'
import {
@ -21,21 +22,12 @@ import {
SidebarMenuItem,
useSidebar,
} from '@/components/ui/Sidebar'
import type { CampaignUser } from '@/payload-types'
import type { CampaignUserShellView } from '@/utilities/campaignUserProfile'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient'
import { clearRecentVisits } from '@/utilities/recentVisits'
export type CampaignSidebarUser = {
name: string
email?: string | null
role: CampaignUser['role']
}
const roleLabels: Record<CampaignUser['role'], string> = {
geral: 'Coordenação geral',
coordenador: 'Coordenador',
lideranca: 'Liderança',
}
export type CampaignSidebarUser = CampaignUserShellView
export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
const pathname = usePathname()
@ -61,7 +53,7 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
<SidebarContent>
<SidebarGroup>
<CampaignScopeBadge className="mb-3 w-fit">{roleLabels[user.role]}</CampaignScopeBadge>
<CampaignScopeBadge className="mb-3 w-fit">{campaignRoleLabels[user.role]}</CampaignScopeBadge>
<SidebarGroupContent>
<SidebarMenu>
{getCampaignNav(user.role).map((item) => (
@ -85,14 +77,17 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border">
<div className="flex min-w-0 flex-col gap-0.5 leading-none">
<span className="truncate text-sm font-medium text-sidebar-foreground">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">{roleLabels[user.role]}</span>
{user.email ? (
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
) : null}
</div>
<form action={logoutCampaign} onSubmit={handleLogout}>
<Link
href="/campanha/perfil"
className="flex min-w-0 items-center gap-3 rounded-md px-1 py-2 outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
>
<CampaignUserAvatar name={user.name} avatarUrl={user.avatarUrl} size="sm" />
<div className="flex min-w-0 flex-col gap-0.5 leading-none">
<span className="truncate text-sm font-medium text-sidebar-foreground">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">Meu perfil</span>
</div>
</Link>
<form onSubmit={handleLogout}>
<Button
type="submit"
variant="outline"

View file

@ -0,0 +1,22 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
import { cn } from '@/lib/utils'
type CampaignUserAvatarProps = {
name: string
avatarUrl?: string | null
size?: 'default' | 'sm' | 'lg'
className?: string
}
export const CampaignUserAvatar = ({
name,
avatarUrl,
size = 'default',
className,
}: CampaignUserAvatarProps) => (
<Avatar size={size} className={cn(className)}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={name} /> : null}
<AvatarFallback aria-hidden="true">{campaignUserInitials(name)}</AvatarFallback>
</Avatar>
)

View file

@ -0,0 +1,51 @@
import { z } from 'zod'
import type { RefinementCtx } from 'zod'
export const CAMPAIGN_PASSWORD_RESET_TOKEN_MIN_LENGTH = 20
const passwordSchema = z
.string()
.min(8, 'A senha deve ter pelo menos 8 caracteres.')
.max(128, 'A senha deve ter no máximo 128 caracteres.')
const refineMatchingPasswords = (
data: { password: string; passwordConfirmation: string },
context: RefinementCtx,
) => {
if (data.password !== data.passwordConfirmation) {
context.addIssue({
code: 'custom',
message: 'As senhas não coincidem.',
path: ['passwordConfirmation'],
})
}
}
export const campaignPasswordResetRequestSchema = z.object({
email: z.email('Informe um e-mail válido.').trim(),
})
export const campaignPasswordResetSchema = z
.object({
token: z.string().min(CAMPAIGN_PASSWORD_RESET_TOKEN_MIN_LENGTH).max(256),
password: passwordSchema,
passwordConfirmation: passwordSchema,
})
.superRefine(refineMatchingPasswords)
export const campaignChangePasswordSchema = z
.object({
currentPassword: z.string().min(1, 'Informe a senha atual.'),
password: passwordSchema,
passwordConfirmation: passwordSchema,
})
.superRefine((data, context) => {
refineMatchingPasswords(data, context)
if (data.currentPassword === data.password) {
context.addIssue({
code: 'custom',
message: 'A nova senha deve ser diferente da atual.',
path: ['password'],
})
}
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "campaign_user" ADD COLUMN IF NOT EXISTS "avatar_id" integer;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'campaign_user_avatar_id_media_id_fk'
) THEN
ALTER TABLE "campaign_user"
ADD CONSTRAINT "campaign_user_avatar_id_media_id_fk"
FOREIGN KEY ("avatar_id") REFERENCES "public"."media"("id")
ON DELETE set null ON UPDATE no action;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "campaign_user_avatar_idx"
ON "campaign_user" USING btree ("avatar_id");
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "campaign_user" DROP CONSTRAINT IF EXISTS "campaign_user_avatar_id_media_id_fk";
DROP INDEX IF EXISTS "campaign_user_avatar_idx";
ALTER TABLE "campaign_user" DROP COLUMN IF EXISTS "avatar_id";
`)
}

View file

@ -14,6 +14,7 @@ import * as migration_20260719_020000_add_contact_trgm_index from './20260719_02
import * as migration_20260719_054522_add_nucleus_goals_strategy from './20260719_054522_add_nucleus_goals_strategy'
import * as migration_20260719_054706_add_privacy_policy_global from './20260719_054706_add_privacy_policy_global'
import * as migration_20260719_054707_seed_onda0_consent_and_privacy from './20260719_054707_seed_onda0_consent_and_privacy'
import * as migration_20260719_061302_add_campaign_user_avatar from './20260719_061302_add_campaign_user_avatar'
export const migrations = [
{
@ -96,4 +97,9 @@ export const migrations = [
down: migration_20260719_054707_seed_onda0_consent_and_privacy.down,
name: '20260719_054707_seed_onda0_consent_and_privacy',
},
{
up: migration_20260719_061302_add_campaign_user_avatar.up,
down: migration_20260719_061302_add_campaign_user_avatar.down,
name: '20260719_061302_add_campaign_user_avatar',
},
]

View file

@ -234,6 +234,7 @@ export interface User {
export interface CampaignUser {
id: number;
name: string;
avatar?: (number | null) | Media;
role: 'geral' | 'coordenador' | 'lideranca';
phone?: string | null;
updatedAt: string;
@ -256,6 +257,28 @@ export interface CampaignUser {
password?: string | null;
collection: 'campaignUser';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
/**
* Descrição da imagem para acessibilidade.
*/
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignInvite".
@ -676,28 +699,6 @@ export interface ElectionCandidate {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
/**
* Descrição da imagem para acessibilidade.
*/
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "petition".
@ -1086,6 +1087,7 @@ export interface UsersSelect<T extends boolean = true> {
*/
export interface CampaignUserSelect<T extends boolean = true> {
name?: T;
avatar?: T;
role?: T;
phone?: T;
updatedAt?: T;

View file

@ -6,6 +6,7 @@ import sharp from 'sharp'
import { fileURLToPath } from 'url'
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
import { resendAdapter } from '@payloadcms/email-resend'
import { pt } from 'payload/i18n/pt'
import { ActionPlan } from './collections/ActionPlan'
import { CampaignInvite } from './collections/CampaignInvite'
@ -35,6 +36,11 @@ import { SiteSettings } from './globals/SiteSettings'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const campaignEmailFromAddress =
process.env.CAMPAIGN_EMAIL_FROM?.trim() || 'campanha@jorgesolla.com.br'
const campaignEmailFromName =
process.env.CAMPAIGN_EMAIL_FROM_NAME?.trim() || 'Campanha Jorge Solla'
export default buildConfig({
admin: {
user: Users.slug,
@ -85,6 +91,11 @@ export default buildConfig({
push: false,
}),
sharp,
email: resendAdapter({
defaultFromAddress: campaignEmailFromAddress,
defaultFromName: campaignEmailFromName,
apiKey: process.env.RESEND_API_KEY || '',
}),
plugins: [
vercelBlobStorage({
collections: {

View file

@ -76,6 +76,22 @@ export const canReadCampaignUserIdentity: FieldAccess = ({ req, id }) => {
return id !== undefined && String(id) === String(req.user.id)
}
const canSelfOrStaffUpdateCampaignUser = async (
req: PayloadRequest,
id: string | number | undefined,
): Promise<boolean> => {
if (isPayloadAdmin(req.user)) return true
if (isCampaignGeneral(await getFreshCampaignUser(req))) return true
if (!isCampaignUser(req.user) || id === undefined) return false
return String(id) === String(req.user.id)
}
export const canUpdateCampaignUser: Access = async ({ req, id }) =>
canSelfOrStaffUpdateCampaignUser(req, id)
export const canUpdateCampaignUserAvatar: FieldAccess = (args) => canUpdateCampaignUser(args)
export const canCreateElectoralNucleus: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true

View file

@ -28,7 +28,7 @@ export const authenticateCampaignToken = async (
const currentUser = await payload.findByID({
collection: 'campaignUser',
id: user.id,
depth: 0,
depth: 1,
})
return {
@ -69,3 +69,12 @@ export const setCampaignAuthCookie = async (
maxAge: tokenExpiration,
})
}
export const campaignLoginCredentials = (
user: { email?: string | null; username?: string | null },
password: string,
): { email: string; password: string } | { username: string; password: string } | null => {
if (user.email) return { email: user.email, password }
if (user.username) return { username: user.username, password }
return null
}

View file

@ -9,6 +9,12 @@ export type CampaignFormErrorState<Values> = {
revision?: number
}
export type CampaignFormActionState = CampaignFormErrorState<undefined> & {
status?: 'success'
}
export const CAMPAIGN_SESSION_EXPIRED_MESSAGE = 'Sessão expirada. Entre novamente.'
export type MapCampaignFormActionErrorOptions<Values> = {
error: unknown
safeMessages?: readonly string[]

View file

@ -0,0 +1,31 @@
import 'server-only'
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
export const CAMPAIGN_PASSWORD_RESET_GENERIC_MESSAGE =
'Se existir uma conta com este e-mail, você receberá um link para redefinir a senha em instantes.'
export const CAMPAIGN_PASSWORD_RESET_INVALID_TOKEN_MESSAGE =
'Este link de redefinição é inválido ou expirou. Solicite um novo e-mail ou peça um convite ao coordenador.'
export const CAMPAIGN_LEADERSHIP_FORGOT_PASSWORD_MESSAGE =
'Se você acessa com celular, peça um novo convite de acesso ao seu coordenador. A recuperação por e-mail só funciona para contas com e-mail cadastrado.'
export const buildCampaignPasswordResetUrl = (token: string): string => {
const baseURL = getCampaignInviteBaseURL()
return `${baseURL}/campanha/redefinir-senha?token=${encodeURIComponent(token)}`
}
export const isCampaignEmailConfigured = (): boolean => {
const apiKey = process.env.RESEND_API_KEY?.trim()
const fromAddress = process.env.CAMPAIGN_EMAIL_FROM?.trim()
return Boolean(apiKey && fromAddress)
}
export const assertCampaignEmailConfigured = (): void => {
if (process.env.NODE_ENV !== 'production') return
if (isCampaignEmailConfigured()) return
throw new Error('Campaign password reset email is not configured in production.')
}

View file

@ -0,0 +1,69 @@
import type { CampaignUser, Media } from '@/payload-types'
export const CAMPAIGN_AVATAR_MAX_BYTES = 2 * 1024 * 1024
export const CAMPAIGN_AVATAR_UNSUPPORTED_MIME_MESSAGE = 'Envie uma imagem JPEG, PNG ou WebP.'
export const CAMPAIGN_AVATAR_MAX_SIZE_MESSAGE = 'A imagem deve ter no máximo 2 MB.'
export const CAMPAIGN_AVATAR_EMPTY_FILE_MESSAGE = 'O arquivo enviado está vazio.'
export const campaignRoleLabels: Record<CampaignUser['role'], string> = {
geral: 'Coordenação geral',
coordenador: 'Coordenador',
lideranca: 'Liderança',
}
export type CampaignUserShellView = {
name: string
role: CampaignUser['role']
avatarUrl: string | null
}
export const campaignUserShellView = (
user: Pick<CampaignUser, 'name' | 'role' | 'avatar'>,
): CampaignUserShellView => ({
name: user.name,
role: user.role,
avatarUrl: mediaDocumentUrl(user.avatar),
})
export const CAMPAIGN_AVATAR_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
])
export const campaignUserInitials = (name: string): string => {
const parts = name
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
if (parts.length === 0) return '?'
return parts.map((part) => part[0]?.toUpperCase() ?? '').join('')
}
export const mediaDocumentUrl = (media: number | Media | null | undefined): string | null => {
if (!media || typeof media === 'number') return null
return media.url ?? null
}
export const assertCampaignAvatarFile = (file: File): void => {
if (!CAMPAIGN_AVATAR_MIME_TYPES.has(file.type)) {
throw new Error(CAMPAIGN_AVATAR_UNSUPPORTED_MIME_MESSAGE)
}
if (file.size > CAMPAIGN_AVATAR_MAX_BYTES) {
throw new Error(CAMPAIGN_AVATAR_MAX_SIZE_MESSAGE)
}
if (file.size === 0) {
throw new Error(CAMPAIGN_AVATAR_EMPTY_FILE_MESSAGE)
}
}
export const CAMPAIGN_AVATAR_SAFE_ERROR_MESSAGES = [
CAMPAIGN_AVATAR_UNSUPPORTED_MIME_MESSAGE,
CAMPAIGN_AVATAR_MAX_SIZE_MESSAGE,
CAMPAIGN_AVATAR_EMPTY_FILE_MESSAGE,
] as const

View file

@ -155,7 +155,7 @@ describe('campaign authentication foundation', () => {
expect(findByID).toHaveBeenCalledWith({
collection: 'campaignUser',
id: staleJwtUser.id,
depth: 0,
depth: 1,
})
expect(result?.email).toBe('')
expect(result?.role).toBe('coordenador')

View file

@ -0,0 +1,87 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { requestCampaignPasswordReset } from '@/app/(campaign)/campanha/actions/password'
import config from '@/payload.config'
import { getPayload } from 'payload'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('campaign password reset and change', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('issues a reset token via forgotPassword with email disabled', async () => {
const email = `${campaignFixtures().value('reset')}@example.com`
const password = campaignFixtures().value('password')
await payload.create({
collection: 'campaignUser',
data: {
name: 'Reset target',
email,
password,
role: 'geral',
},
})
const token = await payload.forgotPassword({
collection: 'campaignUser',
data: { email },
disableEmail: true,
})
expect(typeof token).toBe('string')
expect(token.length).toBeGreaterThan(20)
const reset = await payload.resetPassword({
collection: 'campaignUser',
data: { token, password: `${password}-new` },
overrideAccess: true,
})
expect(reset.token).toBeTruthy()
})
it('returns a generic success message for forgot-password requests', async () => {
const result = await requestCampaignPasswordReset({
email: `${campaignFixtures().value('forgot')}@example.com`,
})
expect(result.status).toBe('success')
expect(result.message).toMatch(/Se existir uma conta/)
})
it('rejects login when password is wrong', async () => {
const email = `${campaignFixtures().value('change')}@example.com`
const password = campaignFixtures().value('password')
await payload.create({
collection: 'campaignUser',
data: {
name: 'Change password target',
email,
password,
role: 'geral',
},
})
await expect(
payload.login({
collection: 'campaignUser',
data: { email, password: 'wrong-password' },
}),
).rejects.toThrow()
})
})

View file

@ -0,0 +1,34 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
campaignChangePasswordSchema,
campaignPasswordResetRequestSchema,
campaignPasswordResetSchema,
} from '@/lib/schemas/campaignPassword'
describe('campaign password schemas', () => {
it('accepts a valid forgot-password email', () => {
const parsed = campaignPasswordResetRequestSchema.parse({ email: 'staff@example.com' })
expect(parsed.email).toBe('staff@example.com')
})
it('rejects reset when confirmation does not match', () => {
const result = campaignPasswordResetSchema.safeParse({
token: 'a'.repeat(32),
password: 'new-password',
passwordConfirmation: 'other-password',
})
expect(result.success).toBe(false)
})
it('rejects change when new password equals current password', () => {
const result = campaignChangePasswordSchema.safeParse({
currentPassword: 'same-password',
password: 'same-password',
passwordConfirmation: 'same-password',
})
expect(result.success).toBe(false)
})
})

View file

@ -0,0 +1,37 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
assertCampaignAvatarFile,
campaignUserInitials,
campaignUserShellView,
mediaDocumentUrl,
} from '@/utilities/campaignUserProfile'
describe('campaignUserProfile helpers', () => {
it('builds initials from the user name', () => {
expect(campaignUserInitials('Maria Silva')).toBe('MS')
expect(campaignUserInitials('João')).toBe('J')
})
it('builds shell view from a campaign user', () => {
expect(
campaignUserShellView({
name: 'Maria',
role: 'geral',
avatar: { id: 1, url: 'https://example.com/a.jpg' } as never,
}),
).toEqual({
name: 'Maria',
role: 'geral',
avatarUrl: 'https://example.com/a.jpg',
})
expect(mediaDocumentUrl(42)).toBeNull()
})
it('rejects unsupported avatar mime types', () => {
const file = new File(['x'], 'avatar.gif', { type: 'image/gif' })
expect(() => assertCampaignAvatarFile(file)).toThrow(/JPEG, PNG ou WebP/)
})
})