diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 387939e1..9cd03d1e 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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. diff --git a/.env.example b/.env.example index 34d113da..b89de944 100644 --- a/.env.example +++ b/.env.example @@ -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". diff --git a/docs/plans/escala-dry-pos-reset-senha-perfil.md b/docs/plans/escala-dry-pos-reset-senha-perfil.md new file mode 100644 index 00000000..aad7e9f9 --- /dev/null +++ b/docs/plans/escala-dry-pos-reset-senha-perfil.md @@ -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
depth 0 + getCampaignUserWithAvatar
sessão leve em rotas públicas"] + F1 --> F2["Fase 2 — Avatar write perf
blob fora da TX + revalidate estreito"] + F2 --> F3["Fase 3 — DRY auth UI/senha
CampaignAuthPageShell + password fields + login state"] + F3 --> F4["Fase 4 — DRY copy/role + actions
campaignRoleOptions + getCampaignActionContext"] + F4 --> F5["Fase 5 — Opcional
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 diff --git a/docs/plans/reset-senha-foto-perfil.md b/docs/plans/reset-senha-foto-perfil.md new file mode 100644 index 00000000..9529d701 --- /dev/null +++ b/docs/plans/reset-senha-foto-perfil.md @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index c04a9abb..bfe2eb92 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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) diff --git a/package.json b/package.json index 86bcad93..9ed6f674 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 924d7cb7..1fbe2abe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx index 08b70ce4..3bd451f3 100644 --- a/src/app/(campaign)/campanha/(app)/layout.tsx +++ b/src/app/(campaign)/campanha/(app)/layout.tsx @@ -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 ( - +
+} + +export default async function CampaignProfilePage({ searchParams }: CampaignProfilePageProps) { + const [user, params] = await Promise.all([getCampaignUser(), searchParams]) + + if (!user) { + redirect('/campanha/login') + } + + return ( + + ) +} diff --git a/src/app/(campaign)/campanha/actions/password.ts b/src/app/(campaign)/campanha/actions/password.ts new file mode 100644 index 00000000..e65bd77c --- /dev/null +++ b/src/app/(campaign)/campanha/actions/password.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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.', + }) + } +} diff --git a/src/app/(campaign)/campanha/actions/profile.ts b/src/app/(campaign)/campanha/actions/profile.ts new file mode 100644 index 00000000..59bf3581 --- /dev/null +++ b/src/app/(campaign)/campanha/actions/profile.ts @@ -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 => { + if (mediaID === null) return + + await payload.delete({ + collection: 'media', + id: mediaID, + overrideAccess: true, + req, + }) +} + +export const updateCampaignAvatarFormAction = async ( + _previousState: CampaignFormActionState, + formData: FormData, +): Promise => { + 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 => { + 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.', + } +} diff --git a/src/app/(campaign)/campanha/esqueci-senha/ForgotPasswordForm.tsx b/src/app/(campaign)/campanha/esqueci-senha/ForgotPasswordForm.tsx new file mode 100644 index 00000000..f6fb88b8 --- /dev/null +++ b/src/app/(campaign)/campanha/esqueci-senha/ForgotPasswordForm.tsx @@ -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 ( + + + Esqueceu a senha? + + Informe o e-mail cadastrado na sua conta. Se você acessa só com celular, peça um novo + convite ao coordenador. + + + + {state.status === 'success' && state.message ? ( +

+ {state.message} +

+ ) : ( +
+ + + E-mail + + + {state.message ? {state.message} : null} + {fieldError(state.fieldErrors, 'email') ? ( + {fieldError(state.fieldErrors, 'email')} + ) : null} + +

+ + Voltar ao login + +

+
+
+ )} +
+
+ ) +} diff --git a/src/app/(campaign)/campanha/esqueci-senha/page.tsx b/src/app/(campaign)/campanha/esqueci-senha/page.tsx new file mode 100644 index 00000000..73e600e0 --- /dev/null +++ b/src/app/(campaign)/campanha/esqueci-senha/page.tsx @@ -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 ( +
+
+
+
+ +
+ Campanha +
+ +
+
+ ) +} diff --git a/src/app/(campaign)/campanha/login/LoginForm.tsx b/src/app/(campaign)/campanha/login/LoginForm.tsx index ad052a98..00bb06d9 100644 --- a/src/app/(campaign)/campanha/login/LoginForm.tsx +++ b/src/app/(campaign)/campanha/login/LoginForm.tsx @@ -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" /> +

+ + Esqueceu a senha? + +

{state.error ? {state.error} : null} +

+ + Voltar ao login + +

+ + + + + ) +} diff --git a/src/app/(campaign)/campanha/redefinir-senha/page.tsx b/src/app/(campaign)/campanha/redefinir-senha/page.tsx new file mode 100644 index 00000000..73948989 --- /dev/null +++ b/src/app/(campaign)/campanha/redefinir-senha/page.tsx @@ -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 ( +
+
+ + + Link inválido + {CAMPAIGN_PASSWORD_RESET_INVALID_TOKEN_MESSAGE} + + + + + + +
+
+ ) + } + + return ( +
+
+
+
+ +
+ Campanha +
+ +
+
+ ) +} diff --git a/src/collections/CampaignUser.ts b/src/collections/CampaignUser.ts index 072197ff..2b0c8f7f 100644 --- a/src/collections/CampaignUser.ts +++ b/src/collections/CampaignUser.ts @@ -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 = 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 ` + + + +

${greeting}

+

Recebemos um pedido para redefinir a senha da sua conta na ferramenta de campanha.

+

+ Redefinir senha +

+

Se você não solicitou esta alteração, ignore este e-mail.

+

${resetPasswordURL}

+ + + ` + }, + }, }, 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: [ diff --git a/src/components/campaign/CampaignProfileSettings.tsx b/src/components/campaign/CampaignProfileSettings.tsx new file mode 100644 index 00000000..bdf6caa1 --- /dev/null +++ b/src/components/campaign/CampaignProfileSettings.tsx @@ -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 ( +

+ {state.message} +

+ ) +} + +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 ( +
+
+

Meu perfil

+

+ Gerencie sua foto e senha de acesso à ferramenta de campanha. +

+
+ + {passwordResetBanner ? ( +

+ Senha redefinida com sucesso. Você já está autenticado. +

+ ) : null} + + + + Identificação + Dados da sua conta neste ciclo. + + + +
+
+
Nome
+
{user.name}
+
+
+
Papel
+
{campaignRoleLabels[user.role]}
+
+ {user.email ? ( +
+
E-mail
+
{user.email}
+
+ ) : null} + {user.username ? ( +
+
Celular de acesso
+
{formatLeadershipPhone(user.username)}
+
+ ) : null} +
+
+
+ + + + Foto de perfil + JPEG, PNG ou WebP, até 2 MB. + + +
+ + Nova foto + + + + {fieldError(avatarState.fieldErrors, 'avatar') ? ( + {fieldError(avatarState.fieldErrors, 'avatar')} + ) : null} +
+ +
+ + + {user.avatarUrl ? ( +
+ +
+ ) : null} +
+
+ + + + Alterar senha + + {user.email + ? 'Use sua senha atual para definir uma nova.' + : 'Se você esqueceu a senha, peça um novo convite de acesso ao coordenador.'} + + + +
+ + + Senha atual + + + + Nova senha + + + + Confirmar nova senha + + + + {fieldError(passwordState.fieldErrors, 'currentPassword') ? ( + {fieldError(passwordState.fieldErrors, 'currentPassword')} + ) : null} + {fieldError(passwordState.fieldErrors, 'password') ? ( + {fieldError(passwordState.fieldErrors, 'password')} + ) : null} + {fieldError(passwordState.fieldErrors, 'passwordConfirmation') ? ( + {fieldError(passwordState.fieldErrors, 'passwordConfirmation')} + ) : null} + + +
+
+
+
+ ) +} diff --git a/src/components/campaign/CampaignSidebar.tsx b/src/components/campaign/CampaignSidebar.tsx index 245aee91..75c70bdc 100644 --- a/src/components/campaign/CampaignSidebar.tsx +++ b/src/components/campaign/CampaignSidebar.tsx @@ -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 = { - 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 }) => { - {roleLabels[user.role]} + {campaignRoleLabels[user.role]} {getCampaignNav(user.role).map((item) => ( @@ -85,14 +77,17 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => { -
- {user.name} - {roleLabels[user.role]} - {user.email ? ( - {user.email} - ) : null} -
-
+ + +
+ {user.name} + Meu perfil +
+ +