mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
e1203a665e
commit
3b463173da
35 changed files with 10858 additions and 52 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
107
docs/plans/escala-dry-pos-reset-senha-perfil.md
Normal file
107
docs/plans/escala-dry-pos-reset-senha-perfil.md
Normal 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
|
||||
84
docs/plans/reset-senha-foto-perfil.md
Normal file
84
docs/plans/reset-senha-foto-perfil.md
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
37
src/app/(campaign)/campanha/(app)/perfil/page.tsx
Normal file
37
src/app/(campaign)/campanha/(app)/perfil/page.tsx
Normal 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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
212
src/app/(campaign)/campanha/actions/password.ts
Normal file
212
src/app/(campaign)/campanha/actions/password.ts
Normal 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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
151
src/app/(campaign)/campanha/actions/profile.ts
Normal file
151
src/app/(campaign)/campanha/actions/profile.ts
Normal 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.',
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
36
src/app/(campaign)/campanha/esqueci-senha/page.tsx
Normal file
36
src/app/(campaign)/campanha/esqueci-senha/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
71
src/app/(campaign)/campanha/redefinir-senha/page.tsx
Normal file
71
src/app/(campaign)/campanha/redefinir-senha/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
215
src/components/campaign/CampaignProfileSettings.tsx
Normal file
215
src/components/campaign/CampaignProfileSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
22
src/components/campaign/CampaignUserAvatar.tsx
Normal file
22
src/components/campaign/CampaignUserAvatar.tsx
Normal 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>
|
||||
)
|
||||
51
src/lib/schemas/campaignPassword.ts
Normal file
51
src/lib/schemas/campaignPassword.ts
Normal 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'],
|
||||
})
|
||||
}
|
||||
})
|
||||
9250
src/migrations/20260719_061302_add_campaign_user_avatar.json
Normal file
9250
src/migrations/20260719_061302_add_campaign_user_avatar.json
Normal file
File diff suppressed because it is too large
Load diff
30
src/migrations/20260719_061302_add_campaign_user_avatar.ts
Normal file
30
src/migrations/20260719_061302_add_campaign_user_avatar.ts
Normal 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";
|
||||
`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
31
src/utilities/campaignPasswordReset.ts
Normal file
31
src/utilities/campaignPasswordReset.ts
Normal 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.')
|
||||
}
|
||||
69
src/utilities/campaignUserProfile.ts
Normal file
69
src/utilities/campaignUserProfile.ts
Normal 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
87
tests/int/campaignPasswordReset.int.spec.ts
Normal file
87
tests/int/campaignPasswordReset.int.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
34
tests/unit/campaignPasswordSchemas.unit.spec.ts
Normal file
34
tests/unit/campaignPasswordSchemas.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
37
tests/unit/campaignUserProfile.unit.spec.ts
Normal file
37
tests/unit/campaignUserProfile.unit.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue