Add remembered campaign sessions

Keep campaign JWT and cookie lifetimes aligned while revoking the active server session on logout.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 08:57:26 -03:00
parent 126b1abae8
commit 3892c2e0a4
17 changed files with 472 additions and 57 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,60 @@
---
target: src/app/(campaign)/campanha/login/LoginForm.tsx
total_score: 30
p0_count: 0
p1_count: 2
timestamp: 2026-07-27T03-38-47Z
slug: src-app-campaign-campanha-login-loginform-tsx
---
Method: dual-agent (A: 40f4d719-1f16-4263-b89d-6aeacd953292 · B: a1f29395-5031-4607-8fe4-d2519097813b; parent completed detector/browser evidence after B's browser restriction)
## Design Health Score
| # | Heuristic | Score | Key issue |
| --------- | ------------------------------- | --------: | ------------------------------------------------------ |
| 1 | Visibility of system status | 3 | Pending and errors are announced clearly |
| 2 | Match system / real world | 4 | Plain pt-BR copy |
| 3 | User control and freedom | 3 | Recovery remains limited for phone-only accounts |
| 4 | Consistency and standards | 4 | House Field and Checkbox patterns |
| 5 | Error prevention | 2 | Combined phone/e-mail input has pre-existing ambiguity |
| 6 | Recognition rather than recall | 3 | First-access help assumes the assessor is known |
| 7 | Flexibility and efficiency | 3 | Native autocomplete and enter hints |
| 8 | Aesthetic and minimalist design | 3 | Focused; initial TTL copy was dense |
| 9 | Error recovery | 3 | Both credential fields are marked invalid together |
| 10 | Help and documentation | 2 | Recovery help is not actionable for every role |
| **Total** | | **30/40** | **Good** |
## Anti-Patterns Verdict
The restrained campaign auth card does not read as AI-generated: no gradients, ornamental cards, or campaign kitsch. It is intentionally familiar product UI. The deterministic detector returned zero findings. Browser review at 390×844, 768×1024, and 1440×900 found no overflow.
## What's Working
- Clear logo → title → credentials → submit hierarchy.
- Native labels, focus treatment, pending feedback, and error announcement are strong.
- The new checkbox uses the shared control and keeps the real 8-hour / 14-day contract visible.
## Priority Issues
- **P1 — Checkbox target was 40×32 px.** The shared default pseudo-target was short of the field-use 44 px bar. **Resolved in polish:** this instance now has a measured 44×44 px target.
- **P2 — Session copy was policy-first and four lines on mobile.** **Resolved in polish:** outcome-first copy is shorter while retaining both exact durations and the personal-device warning.
- **P1 — Phone-first recovery ambiguity.** Pre-existing and outside B39: phone-only users cannot use e-mail reset.
- **P2 — Secondary recovery targets are compact.** Pre-existing and outside B39.
## Persona Red Flags
- **Jordan:** first-access recovery assumes they know an assessor.
- **Sam:** the B39 checkbox is labeled, described with `aria-describedby`, focus-visible, and now has a 44×44 px target.
- **Casey:** the new option is one tap and defaults safely off; the pre-existing identifier placeholder remains long on narrow phones.
## Minor Observations
- The remembered option remains visually subordinate to the primary login action.
- The final description takes two lines at desktop/tablet and wraps without overflow on mobile.
- The identifier placeholder clipping is unrelated to B39.
## Questions to Consider
- Should a future auth pass make recovery adapt to the identifier type?
- Should compact secondary auth links receive the same 44 px target policy?

View file

@ -145,10 +145,10 @@ curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" \
The internal `/campanha` area is gated by its own authentication, deliberately kept **isolated from the Payload admin (`/admin`)** so a campaign session and an admin session can coexist in the same browser.
- **`campaignUser` collection** (`src/collections/CampaignUser.ts`) — a separate Payload auth collection with `name`, optional contact `phone`, and required `role`: `coordinator` ("Coordenador Geral"), `advisor` ("Assessor"), `candidate` ("Candidato"), or `leader` ("Liderança" — default and least privilege). Staff may sign in by email; leadership accounts use the normalized 11-digit phone as `username`. It is not `users`: `admin.user` remains `users`, so campaign users cannot log into `/admin`.
- **Isolated session cookie `campaign-token`.** The flow sets its own httpOnly cookie named `campaign-token` (constant `CAMPAIGN_TOKEN_COOKIE` in `src/utilities/campaignAuth.ts`), scoped to `path: '/campanha'`, `sameSite: 'lax'`, and `secure` only in production. It is deliberately NOT the default `payload-token` cookie — using a distinct name + path is what lets a campaign session and a Payload admin session live side by side without clobbering each other.
- **`campaignUser` collection** (`src/collections/CampaignUser.ts`) — a separate Payload auth collection with `name`, optional contact `phone`, and required `role`: `coordinator` ("Coordenador Geral"), `advisor` ("Assessor"), `candidate` ("Candidato"), or `leader` ("Liderança" — default and least privilege). Staff may sign in by email; leadership accounts use the normalized 11-digit phone as `username`. It is not `users`: `admin.user` remains `users`, so campaign users cannot log into `/admin`. Its static `auth.tokenExpiration` is the 14-day ceiling because Payload's Local API does not accept a per-login TTL and `useSessions` (enabled) stores/prunes sessions using that configured expiry.
- **Isolated session cookie `campaign-token`.** The flow sets its own httpOnly cookie named `campaign-token` (constant `CAMPAIGN_TOKEN_COOKIE` in `src/utilities/campaignAuth.ts`), scoped to `path: '/campanha'`, `sameSite: 'lax'`, and `secure` only in production. It is deliberately NOT the default `payload-token` cookie — using a distinct name + path is what lets a campaign session and a Payload admin session live side by side without clobbering each other. Login defaults to **8 hours**; checking "Lembrar de mim neste dispositivo" opts into **14 days**. `setCampaignAuthCookie` owns the invariant JWT `exp` = cookie `maxAge`: it re-signs the Payload token to 8 hours with `jwtSign` or keeps the original 14-day token. Password-reset and invite login paths omit the opt-in and therefore stay at 8 hours.
- **Session verification — `getCampaignUser()`** (`src/utilities/campaignAuth.ts`). Reads the `campaign-token` cookie, calls `payload.auth({ headers })`, accepts only `user.collection === 'campaignUser'`, then reloads that document by ID. The fresh read makes role downgrades effective immediately instead of trusting a stale JWT role.
- **Login/logout server actions** (`src/app/(campaign)/campanha/actions/auth.ts`). `loginCampaign` validates input with `campaignLoginSchema` (`src/lib/schemas/campaign-login.ts`), calls `payload.login({ collection: 'campaignUser' })`, and — because the Local API returns a token WITHOUT setting a cookie — sets the `campaign-token` cookie itself (using the collection's `auth.tokenExpiration` as `maxAge`) before redirecting to `/campanha`. `logoutCampaign` clears the cookie (`maxAge: 0`) and redirects to `/campanha/login`.
- **Login/logout server actions** (`src/app/(campaign)/campanha/actions/auth.ts`). `loginCampaign` validates input with `campaignLoginSchema` (`src/lib/schemas/campaign-login.ts`), calls `payload.login({ collection: 'campaignUser' })`, chooses the 8-hour / 14-day TTL, and — because the Local API returns a token WITHOUT setting a cookie — calls `setCampaignAuthCookie` before redirecting to `/campanha`. `logoutCampaign` authenticates the current token and delegates revocation of its `_sid` to Payload's public transactional `logoutOperation`, then clears the cookie (`maxAge: 0`) and redirects to `/campanha/login`; malformed tokens or database failures never prevent local cookie/PWA cleanup.
- **Route layout.** The route group's root layout `src/app/(campaign)/layout.tsx` renders the `<html>`/`<body>` with `data-theme="campaign"`. The public login lives at `src/app/(campaign)/campanha/login/` (`page.tsx` + client `LoginForm.tsx`). Everything gated sits in the `(app)` group, whose layout (`src/app/(campaign)/campanha/(app)/layout.tsx`) calls `getCampaignUser()` and `redirect('/campanha/login')` when there is no session — that layout is the barrier.
- **Migrations.** `20260716_010420_add_campaign_user` created the auth collection; the unpublished 2026-07-17 campaign chain was consolidated into `20260718_010733_consolidate_campaign_schema`, which adds role/username auth, contact phone, and the remaining final campaign schema.

View file

@ -1,7 +1,7 @@
# Lembrar de mim / sessão longa em `/campanha`
Status: rascunho
Atualizado em: 2026-07-26
Status: entregue em código (2026-07-27)
Atualizado em: 2026-07-27
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B39)
Impeccable: B — encaixe no `LoginForm` + cookie `campaign-token`; sem rota nova
Appetite: ~0,5 dia eng; checkbox + duas durações de JWT/cookie + testes; sem migration
@ -27,18 +27,26 @@ Dados: N/A — auth/sessão; nenhum KPI/série/mapa.
## Contexto
A sessão de `/campanha` é o cookie httpOnly `campaign-token` (`path: '/campanha'`, `sameSite: 'lax'`, `secure` em produção), setado por [`setCampaignAuthCookie`](../../src/utilities/campaignAuth.ts) após `payload.login` em [`loginCampaign`](<../../src/app/(campaign)/campanha/actions/auth.ts>). O `maxAge` do cookie espelha `campaignUser.config.auth.tokenExpiration`, com fallback **`DEFAULT_TOKEN_EXPIRATION = 7200`** (2 horas). A collection [`CampaignUser`](../../src/collections/CampaignUser.ts) **não declara** `tokenExpiration` hoje — Payload + o fallback deixam a sessão curta demais para mesa/campo. Não há checkbox "Lembrar de mim"; o `LoginForm` só pede identificador + senha.
A sessão de `/campanha` era o cookie httpOnly `campaign-token` (`path: '/campanha'`, `sameSite: 'lax'`, `secure` em produção), setado por [`setCampaignAuthCookie`](../../src/utilities/campaignAuth.ts) após `payload.login` em [`loginCampaign`](<../../src/app/(campaign)/campanha/actions/auth.ts>). Antes desta entrega, Payload aplicava seu default de **7.200 s** (2 horas) ao JWT e o helper repetia esse valor no `maxAge`. Não havia checkbox "Lembrar de mim"; o `LoginForm` só pedia identificador + senha.
Pedido de produto (2026-07-26): o usuário é deslogado com frequência; oferecer "Lembrar de mim neste dispositivo" e manter a sessão por mais tempo.
PWA (D1 ✓): o SW envia o cookie automaticamente nos `fetch`; logout já limpa caches (`clearCampaignPwaCaches`). Sessão mais longa beneficia o PWA instalado sem mudar o modelo de auth.
## Revisão na entrega
- **`payload.login` não aceita TTL por chamada.** `CampaignUser.auth.tokenExpiration` passou ao teto de 14 dias e `setCampaignAuthCookie` re-assina o mesmo conjunto de claims com 8 horas no caminho padrão.
- **`useSessions` é `true` por default no Payload.** A sessão armazenada precisa nascer com o teto longo; deixá-la em 8 horas e só alongar o JWT lembrado faria outro login podar a sessão longa como expirada.
- **Logout só apagava o cookie.** Com TTL de 14 dias, isso deixaria um token reutilizável no aparelho. `logoutCampaign` agora autentica o token e delega a remoção da sessão corrente à operação transacional pública `logoutOperation` do Payload antes de apagar o cookie; falha de revogação nunca impede a limpeza local.
- **Custo aceito:** `POST /api/campaignUser/login` direto emite o `payload-token` de 14 dias. A aplicação `/campanha` não lê esse cookie: usa apenas `campaign-token`, path `/campanha`, re-assinado para 8 horas salvo opt-in.
- **Medições:** `pnpm migrate:create __probe` informou “No schema changes detected”; o smoke E2E confirmou `campaign-token.expires` em ~14 dias com o checkbox marcado.
## Objetivos
- No login, checkbox **"Lembrar de mim neste dispositivo"** (default **desmarcado**).
- Sessão **sem** lembrar: duração de **dia de trabalho** (recomendação: **8 h**), não 2 h.
- Sessão **com** lembrar: duração **longa** (recomendação: **14 dias**), JWT + cookie alinhados.
- Logout explícito continua invalidando o cookie (`maxAge: 0`) e os caches PWA.
- Logout explícito revoga a sessão no servidor, invalida o cookie (`maxAge: 0`) e mantém a limpeza de caches PWA.
- Guardrails: sem migration, sem collection, sem Consent novo; cookie permanece httpOnly / path `/campanha` / isolado de `payload-token`; `leader` e staff no mesmo contrato.
## Decisões travadas
@ -48,17 +56,17 @@ PWA (D1 ✓): o SW envia o cookie automaticamente nos `fetch`; logout já limpa
- **Sem persistir a senha no cliente.** "Lembrar" = duração do token, não Credential Manager nem `localStorage` de senha. Biometria / WebAuthn fica em **B40**. **Rejeitado:** autofill como "feature" de produto (já é do browser); guardar credenciais em plain storage.
- **i18n e naming** (AGENTS.md): identificadores `rememberMe`, `CAMPAIGN_SESSION_TTL_SHORT` / `_LONG` (ou nomes equivalentes); copy pt-BR "Lembrar de mim neste dispositivo".
## Questões em aberto
## Questões resolvidas
- **Durações exatas (8 h / 14 d)?** **Opções:** A) 8 h + 14 d _(recomendado)_ | B) 12 h + 30 d | C) 4 h + 7 d. **Recomendação:** A — dia de trabalho tipico + quinzena cobrindo giro sem re-login diário; 30 d é agressivo com PII de apoiadores ainda sob hold jurídico. _(assumido — validar com produto / assessoria se o lote jurídico quiser teto menor.)_
- **Durações:** 8 horas sem opt-in e 14 dias com “Lembrar de mim”, decididas pelo produto nesta entrega.
## Abordagem proposta
```mermaid
flowchart LR
form["LoginForm + checkbox rememberMe"] --> action["loginCampaign"]
action --> login["payload.login<br/>tokenExpiration short|long"]
login --> cookie["setCampaignAuthCookie<br/>maxAge = tokenExpiration"]
action --> login["payload.login<br/>JWT + session 14 d"]
login --> cookie["setCampaignAuthCookie<br/>re-sign 8 h ou mantém 14 d"]
cookie --> gate["getCampaignUser<br/>(layout app)"]
```
@ -67,8 +75,9 @@ Componentes:
- **`src/lib/schemas/campaign-login.ts`**: campo opcional `rememberMe` (boolean / "on").
- **`LoginForm.tsx`**: `Checkbox` + label; `name="rememberMe"`; `autoComplete` inalterado.
- **`loginCampaign` / `setCampaignAuthCookie`**: aceitar TTL explícito; alinhar JWT + cookie; constantes numéricas num módulo pequeno (`campaignSessionTtl.ts` em `lib/` se puro).
- **`CampaignUser` auth config**: se Payload exigir `tokenExpiration` estático na collection, usar o **máximo** (longo) na config e emitir tokens com `exp` menor no caminho curto — ou o mecanismo que o Payload 3 expuser sem segundo cookie. Confirmar na implementação lendo a API de `payload.login` / collection auth (depth check: não inventar refresh token).
- **Testes:** unit do schema; int do login com/sem remember (cookie `maxAge` / claim `exp` se observável); e2e smoke do checkbox opcional.
- **`CampaignUser` auth config**: `tokenExpiration = CAMPAIGN_SESSION_TTL_LONG`; o caminho curto re-assina por API pública `jwtSign`, com `payload.secret`.
- **Logout:** autentica o token, revoga só a sessão corrente via `logoutOperation` do Payload e sempre apaga o cookie.
- **Testes:** unit de claims/`exp`/`maxAge` e action/FormData; int de autenticação após revogação; e2e do checkbox e expiração longa.
- **Migration:** nenhuma.
Depth check: reusa `setCampaignAuthCookie`, `CAMPAIGN_TOKEN_COOKIE`, form action existente. Não cria sessão paralela nem collection de refresh.
@ -87,7 +96,7 @@ Depth check: reusa `setCampaignAuthCookie`, `CAMPAIGN_TOKEN_COOKIE`, form action
## Rabbit holes
- **Refresh-token / dual-cookie.** Se alguém “só completar” com refresh: segundo segredo, rotação, invalidação no logout, testes de race. **Mitigação:** uma JWT + um cookie, duas durações no emit.
- **`tokenExpiration` estático na collection vs por login.** Payload pode não aceitar TTL por request. **Mitigação:** medir na Fase 1 da implementação; se só houver TTL estático, config = longo e o caminho curto usa cookie `maxAge` menor **somente se** o JWT curto for emitível — senão documentar e cair para default único alongado + checkbox como no-op visual (falha de produto: não mentir). Preferir falhar o gate a mentir a duração.
- **`tokenExpiration` estático na collection vs por login.** Resolvido: Payload não aceita TTL por request, mas exporta `jwtSign`; config = longo e o helper re-assina para o curto preservando `id`/`collection`/`role`/`email`/`sid`.
## Adiado com gatilho
@ -97,7 +106,7 @@ Depth check: reusa `setCampaignAuthCookie`, `CAMPAIGN_TOKEN_COOKIE`, form action
## Referências
- `docs/roadmap.md` (Trilha B · B39)
- `src/utilities/campaignAuth.ts` — cookie + `DEFAULT_TOKEN_EXPIRATION`
- `src/utilities/campaignAuth.ts` — re-assinatura, cookie e revogação de sessão
- `src/app/(campaign)/campanha/actions/auth.ts` — `loginCampaign` / logout
- `src/app/(campaign)/campanha/login/LoginForm.tsx`
- `src/collections/CampaignUser.ts` — auth

View file

@ -119,7 +119,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- **B17** seletor de colunas (mostrar/ocultar) na lista de municípios — Popover “Colunas” + `localStorage`; desktop only; `name` obrigatória; **Pass 2 W1 deixou trivial:** colunas já são dado (`CampaignTableColumn.id`/`mandatory`/`defaultVisible`); soft: B15 ✓ (ids) / B16 ✓ (barra slim); prepara viewport para **E9** · ~0,5–1d · Janela 1–2 · cortável · [plano](plans/seletor-colunas-lista-municipios.md)
- **B18** filtros salvos na lista de municípios — nomear o estado URL atual (`localStorage`); **Pass 2 W1 deixou trivial:** serialização canônica pronta e congelada em `municipalityListUrl.ts`; acesso rápido de 2º nível sob Municípios no sidebar (hover desktop / expand mobile; sticky só no filtro salvo ativo) · ~1–1,5d · Janela 1–2 · soft: B16/B17 (barra) · cortável · [plano](plans/filtros-salvos-municipios.md)
- ~~**B19** gerenciar assessores — `/campanha/assessores` (lista + novo + detalhe): criar/editar contas `advisor`, ver/atribuir municípios, reenviar link de senha; **só Coordenador Geral e Candidato** (`isCampaignUnrestricted`); alinha access de update/phone do `candidate`~~ — **entregue 2026-07-24** (também alinha atribuição `municipality.advisors` a unrestricted; e-mail na lista via leitura privilegiada; auto-save da carteira) · [plano](plans/gerenciar-assessores.md)
- **B39** lembrar de mim / sessão longa em `/campanha` — pedido de produto (2026-07-26): logout frequente; hoje `DEFAULT_TOKEN_EXPIRATION = 7200` (2 h) no cookie `campaign-token`. Checkbox "Lembrar de mim neste dispositivo" (default off); sem lembrar ≈ **8 h**, com lembrar ≈ **14 d**; JWT + `maxAge` alinhados; sem migration/Consent · ~0,5d · Janela 1 · Impeccable B · soft: B40 (mesmo `LoginForm`) · **não cortável** nesta leva (atrito diário de adoção) · [plano](plans/lembrar-de-mim-sessao-campanha.md)
- ~~**B39** lembrar de mim / sessão longa em `/campanha`~~ — **entregue 2026-07-27** (checkbox default off; 8 h padrão / 14 d opt-in; `CampaignUser` configura o teto longo e `setCampaignAuthCookie` re-assina JWT + cookie para o TTL escolhido; `logoutCampaign` revoga o `sid` no servidor antes de limpar cookie/PWA; smoke E2E mede a expiração longa; sem migration/Consent) · [plano](plans/lembrar-de-mim-sessao-campanha.md)
- **B40** login com biometria (WebAuthn) em `/campanha` — após o 1º login com senha, enrollment opt-in no dispositivo; na próxima visita, platform authenticator (digital/Face ID) emite o mesmo `campaign-token` (TTL longo). Collection/credenciais server-side; senha permanece; migration leve · ~1,5–2d · Janela 1–2 · Impeccable C · soft: **B39** (TTL/copy no login) · cortável · [plano](plans/login-biometrico-campanha.md)
- **B41** scroll horizontal + coluna Município fixa na lista de municípios — em `md+`, a tabela deixa de estourar a viewport (`overflow-x-auto` + `sticky left-0` na coluna nome), espelhando **B21 ✓** `TerritoryList`; revisita o trade-off do **B16 ✓** (`overflow-x-visible` para sticky header) · ~0,5–0,75d · Janela 1 · Impeccable B · soft: B17/B38 ✓ · cortável (depois de B42 na lista de cortes) · [plano](plans/scroll-horizontal-lista-municipios.md)
- **B42** polimento mobile da lista de municípios — Tendência e Assessores abrem **Drawer** no mobile (Popover no desktop), no padrão do **B26 ✓** sinal; card mais baixo (Classe sem prosa, Cobertura `compact`, sem botão "Abrir município", tap no card abre o detalhe) · ~1d · Janela 1 · Impeccable B · soft: B24 ✓/B27 ✓/B26 ✓; paralelo a B41 · cortável · [plano](plans/polimento-mobile-lista-municipios.md)
@ -170,7 +170,7 @@ flowchart TD
B8F2["B8 F2 ✓ Polígonos<br/>zonas de Salvador"]
B14n["B14 ✓ Município mais próximo"]
B38n["B38 ✓ Sidebar recolhido<br/>em tablet"]
B39n["B39 Lembrar de mim<br/>/ sessão longa"]
B39n["B39 ✓ Lembrar de mim<br/>/ sessão longa"]
B40n["B40 Login biométrico<br/>WebAuthn"]
B41n["B41 Scroll horizontal<br/>+ sticky Município"]
B42n["B42 Polimento mobile<br/>lista municípios"]

View file

@ -9,11 +9,13 @@ import {
CAMPAIGN_ACCOUNT_LOCKED_MESSAGE,
CAMPAIGN_LOGIN_INVALID_CREDENTIALS_MESSAGE,
} from '@/lib/campaignAuthCopy'
import { requiredFormSecret, requiredFormText } from '@/lib/formData'
import { CAMPAIGN_SESSION_TTL_LONG, CAMPAIGN_SESSION_TTL_SHORT } from '@/lib/campaignSessionTtl'
import { checkboxFormValue, requiredFormSecret, requiredFormText } from '@/lib/formData'
import { campaignLoginSchema, type CampaignLoginInput } from '@/lib/schemas/campaign-login'
import {
CAMPAIGN_COOKIE_PATH,
CAMPAIGN_TOKEN_COOKIE,
clearCampaignAuthCookie,
revokeCampaignSession,
setCampaignAuthCookie,
} from '@/utilities/campaignAuth'
@ -33,11 +35,11 @@ export const loginCampaign = async (input: CampaignLoginInput): Promise<LoginRes
}
const payload = await getPayload({ config })
const { identifier, password, rememberMe } = parsed.data
let token: string | undefined
try {
const { identifier, password } = parsed.data
const credentials = identifier.includes('@')
? { email: identifier, password }
: { username: identifier, password }
@ -59,7 +61,11 @@ export const loginCampaign = async (input: CampaignLoginInput): Promise<LoginRes
return { error: CAMPAIGN_LOGIN_INVALID_CREDENTIALS_MESSAGE }
}
await setCampaignAuthCookie(token, payload)
await setCampaignAuthCookie(
token,
payload,
rememberMe ? CAMPAIGN_SESSION_TTL_LONG : CAMPAIGN_SESSION_TTL_SHORT,
)
redirect('/campanha')
}
@ -72,6 +78,7 @@ export const loginCampaignFormAction = async (
return loginCampaign({
identifier: requiredFormText(formData, 'identifier'),
password: requiredFormSecret(formData, 'password'),
rememberMe: checkboxFormValue(formData, 'rememberMe'),
})
} catch {
return { error: 'Dados inválidos.' }
@ -80,14 +87,18 @@ export const loginCampaignFormAction = async (
export const logoutCampaign = async (): Promise<void> => {
const cookieStore = await cookies()
const token = cookieStore.get(CAMPAIGN_TOKEN_COOKIE)?.value
cookieStore.set(CAMPAIGN_TOKEN_COOKIE, '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: CAMPAIGN_COOKIE_PATH,
maxAge: 0,
})
if (token) {
try {
const payload = await getPayload({ config })
await revokeCampaignSession(token, payload)
} catch {
// Cookie deletion must still succeed if the token is malformed or revocation is unavailable.
}
}
await clearCampaignAuthCookie()
redirect('/campanha/login')
}

View file

@ -7,13 +7,22 @@ import { loginCampaignFormAction, type LoginResult } from '@/app/(campaign)/camp
import { CampaignAuthCardHeader } from '@/components/campaign/auth/CampaignAuthCardHeader'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import {
CAMPAIGN_FIRST_ACCESS_HINT,
CAMPAIGN_LEADERSHIP_LOGIN_RECOVERY_HINT,
CAMPAIGN_LOGIN_SUBTITLE,
CAMPAIGN_REMEMBER_ME_DESCRIPTION,
campaignAuthMutedTextClassName,
campaignAuthTextLinkClassName,
} from '@/lib/campaignAuthCopy'
@ -76,6 +85,21 @@ export const LoginForm = () => {
aria-describedby={hasAuthError ? LOGIN_ERROR_ID : undefined}
/>
</Field>
<Field orientation="horizontal">
<Checkbox
id="remember-me"
name="rememberMe"
value="true"
aria-describedby="remember-me-description"
className="after:-inset-x-3.5 after:-inset-y-3.5"
/>
<FieldContent>
<FieldLabel htmlFor="remember-me">Lembrar de mim neste dispositivo</FieldLabel>
<FieldDescription id="remember-me-description">
{CAMPAIGN_REMEMBER_ME_DESCRIPTION}
</FieldDescription>
</FieldContent>
</Field>
<div className="flex flex-col gap-2 text-sm">
<Link href="/campanha/esqueci-senha" className={campaignAuthTextLinkClassName}>
Esqueceu a senha?

View file

@ -1,3 +1,4 @@
import { CAMPAIGN_SESSION_TTL_LONG } from '@/lib/campaignSessionTtl'
import { normalizeBrazilianPhone } from '@/lib/phone'
import type { CampaignUser as CampaignUserDocument } from '@/payload-types'
import {
@ -105,6 +106,7 @@ export const CampaignUser: CollectionConfig = {
useAsTitle: 'name',
},
auth: {
tokenExpiration: CAMPAIGN_SESSION_TTL_LONG,
loginWithUsername: {
allowEmailLogin: true,
requireEmail: false,

View file

@ -11,6 +11,9 @@ export const CAMPAIGN_FIRST_ACCESS_HINT =
export const CAMPAIGN_LOGIN_SUBTITLE = 'Use o e-mail ou o celular cadastrado na sua conta.'
export const CAMPAIGN_REMEMBER_ME_DESCRIPTION =
'Em aparelho pessoal, fique conectado por 14 dias. Desmarcado, o acesso dura 8 horas.'
/** Shown when Payload locks the account after maxLoginAttempts (default: 5 / 10 min). */
export const CAMPAIGN_ACCOUNT_LOCKED_MESSAGE =
'Conta temporariamente bloqueada após várias tentativas. Aguarde alguns minutos e tente de novo.'

View file

@ -0,0 +1,5 @@
/** Default session: one field workday without persisting login across days. */
export const CAMPAIGN_SESSION_TTL_SHORT = 8 * 60 * 60
/** Explicit "remember me" session: a fortnight on the user's own device. */
export const CAMPAIGN_SESSION_TTL_LONG = 14 * 24 * 60 * 60

View file

@ -22,6 +22,7 @@ const campaignIdentifierSchema = z
export const campaignLoginSchema = z.object({
identifier: campaignIdentifierSchema,
password: z.string().min(1, 'Informe a senha'),
rememberMe: z.boolean().optional(),
})
export type CampaignLoginInput = z.infer<typeof campaignLoginSchema>

View file

@ -4,17 +4,86 @@ import type { CampaignUser } from '@/payload-types'
import configPromise from '@payload-config'
import { cookies } from 'next/headers'
import { getPayload, type Payload } from 'payload'
import { createLocalReq, getPayload, jwtSign, logoutOperation, type Payload } from 'payload'
import { cache } from 'react'
import { CAMPAIGN_SESSION_TTL_LONG, CAMPAIGN_SESSION_TTL_SHORT } from '@/lib/campaignSessionTtl'
export const CAMPAIGN_TOKEN_COOKIE = 'campaign-token'
export const CAMPAIGN_COOKIE_PATH = '/campanha'
const DEFAULT_TOKEN_EXPIRATION = 7200
const CAMPAIGN_COOKIE_PATH = '/campanha'
type CampaignAuthPayload = Pick<Payload, 'auth' | 'findByID'>
type CampaignCookiePayload = Pick<Payload, 'collections'>
type CampaignCookiePayload = Pick<Payload, 'secret'>
export type AuthenticatedCampaignUser = CampaignUser & { email: string }
type CampaignTokenClaims = Record<string, unknown> & {
collection: 'campaignUser'
id: number | string
sid: string
}
const campaignTokenClaims = (token: string): CampaignTokenClaims => {
try {
const segments = token.split('.')
if (segments.length !== 3 || !segments[1]) throw new Error('Malformed JWT')
const base64 = segments[1].replace(/-/g, '+').replace(/_/g, '/')
const paddedBase64 = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')
const decoded = JSON.parse(atob(paddedBase64)) as unknown
if (typeof decoded !== 'object' || decoded === null || Array.isArray(decoded)) {
throw new Error('Invalid campaign claims')
}
const claims = decoded as CampaignTokenClaims
if (
(typeof claims.id !== 'number' && typeof claims.id !== 'string') ||
claims.collection !== 'campaignUser' ||
typeof claims.sid !== 'string'
) {
throw new Error('Invalid campaign claims')
}
const { exp: _exp, iat: _iat, ...fieldsToSign } = claims
return fieldsToSign
} catch {
throw new Error('Token de campanha inválido.')
}
}
const campaignSessionToken = async (
token: string,
payload: CampaignCookiePayload,
tokenExpiration: number,
): Promise<string> => {
if (tokenExpiration === CAMPAIGN_SESSION_TTL_LONG) return token
const { token: resignedToken } = await jwtSign({
fieldsToSign: campaignTokenClaims(token),
secret: payload.secret,
tokenExpiration,
})
return resignedToken
}
export const revokeCampaignSession = async (token: string, payload: Payload): Promise<void> => {
const headers = new Headers({ Authorization: `JWT ${token}` })
const { user: authenticatedUser } = await payload.auth({ headers })
if (authenticatedUser?.collection !== 'campaignUser') {
throw new Error('Token de campanha inválido.')
}
const req = await createLocalReq(
{
req: { headers },
user: authenticatedUser,
},
payload,
)
await logoutOperation({
collection: payload.collections.campaignUser,
req,
})
}
export const authenticateCampaignToken = async (
token: string,
payload: CampaignAuthPayload,
@ -53,21 +122,28 @@ export const getCampaignUserRaw = async (): Promise<AuthenticatedCampaignUser |
export const getCampaignUser = cache(getCampaignUserRaw)
const campaignCookieOptions = (maxAge: number) => ({
httpOnly: true,
maxAge,
path: CAMPAIGN_COOKIE_PATH,
sameSite: 'lax' as const,
secure: process.env.NODE_ENV === 'production',
})
export const setCampaignAuthCookie = async (
token: string,
payload: CampaignCookiePayload,
tokenExpiration: number = CAMPAIGN_SESSION_TTL_SHORT,
): Promise<void> => {
const tokenExpiration =
payload.collections.campaignUser?.config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION
const sessionToken = await campaignSessionToken(token, payload, tokenExpiration)
const cookieStore = await cookies()
cookieStore.set(CAMPAIGN_TOKEN_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: CAMPAIGN_COOKIE_PATH,
maxAge: tokenExpiration,
})
cookieStore.set(CAMPAIGN_TOKEN_COOKIE, sessionToken, campaignCookieOptions(tokenExpiration))
}
export const clearCampaignAuthCookie = async (): Promise<void> => {
const cookieStore = await cookies()
cookieStore.set(CAMPAIGN_TOKEN_COOKIE, '', campaignCookieOptions(0))
}
export const campaignLoginCredentials = (

View file

@ -0,0 +1,39 @@
import { CAMPAIGN_SESSION_TTL_LONG } from '../../src/lib/campaignSessionTtl.js'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
test('logs in with a remembered fourteen-day campaign session', async ({
campaign,
context,
page,
}) => {
test.slow()
const { fixtures } = campaign
const email = `${fixtures.value('remembered-user')}@example.com`
const password = fixtures.value('password')
await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Usuária lembrada'),
email,
password,
role: 'advisor',
},
depth: 0,
})
await page.goto('/campanha/login')
const rememberMe = page.getByRole('checkbox', {
name: 'Lembrar de mim neste dispositivo',
})
await expect(rememberMe).toBeVisible()
await expect(rememberMe).not.toBeChecked()
await page.getByLabel('E-mail ou celular').fill(email)
await page.getByLabel('Senha').fill(password)
await rememberMe.check()
const loggedInAt = Math.floor(Date.now() / 1000)
await page.getByRole('button', { name: 'Entrar' }).click()
await page.waitForURL(/\/campanha$/)
const tokenCookie = (await context.cookies()).find((cookie) => cookie.name === 'campaign-token')
expect(tokenCookie?.expires).toBeGreaterThan(loggedInAt + CAMPAIGN_SESSION_TTL_LONG - 60)
})

View file

@ -3,16 +3,17 @@
import { readFileSync } from 'node:fs'
import type { Payload, PayloadRequest, RequiredDataFromCollectionSlug } from 'payload'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { assignMunicipalityAdvisorsRecord } from '@/app/(campaign)/campanha/actions/municipality'
import { CampaignUser as CampaignUserCollection } from '@/collections/CampaignUser'
import { CAMPAIGN_SESSION_TTL_LONG } from '@/lib/campaignSessionTtl'
import { buildWhatsAppUrl, normalizeBrazilianPhone, sanitizeBrazilianPhoneInput } from '@/lib/phone'
import { campaignLoginSchema } from '@/lib/schemas/campaign-login'
import type { CampaignUser } from '@/payload-types'
import config from '@/payload.config'
import { getAccessibleMunicipalityIds, isCampaignCoordinator } from '@/utilities/campaignAccess'
import { authenticateCampaignToken } from '@/utilities/campaignAuth'
import { authenticateCampaignToken, revokeCampaignSession } from '@/utilities/campaignAuth'
import { getPayload } from 'payload'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
@ -40,6 +41,7 @@ describe('campaign authentication foundation', () => {
it('configures optional username and email login', () => {
expect(CampaignUserCollection.auth).toMatchObject({
tokenExpiration: CAMPAIGN_SESSION_TTL_LONG,
loginWithUsername: {
allowEmailLogin: true,
requireEmail: false,
@ -99,6 +101,30 @@ describe('campaign authentication foundation', () => {
expect(leadershipLogin.user?.username).toBe(phone)
})
it('stops authenticating a token after its server session is revoked', async () => {
const email = `${campaignFixtures().value('logout')}@example.com`
const password = campaignFixtures().value('password')
await payload.create({
collection: 'campaignUser',
data: {
name: 'Sessão encerrada',
email,
password,
role: 'advisor',
},
})
const login = await payload.login({
collection: 'campaignUser',
data: { email, password },
})
assert(login.token)
const token = login.token
await expect(authenticateCampaignToken(token, payload)).resolves.not.toBeNull()
await revokeCampaignSession(token, payload)
await expect(authenticateCampaignToken(token, payload)).resolves.toBeNull()
})
it('preserves leading and trailing password spaces through validation and Payload login', async () => {
const password = ` ${campaignFixtures().value('exact-password')} `
const trimmedPassword = password.trim()

View file

@ -0,0 +1,81 @@
// @vitest-environment node
import { jwtSign } from 'payload'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CAMPAIGN_SESSION_TTL_LONG, CAMPAIGN_SESSION_TTL_SHORT } from '@/lib/campaignSessionTtl'
const mocks = vi.hoisted(() => ({
cookieSet: vi.fn(),
}))
vi.mock('next/headers', () => ({
cookies: vi.fn(async () => ({ set: mocks.cookieSet })),
}))
import { CAMPAIGN_TOKEN_COOKIE, setCampaignAuthCookie } from '@/utilities/campaignAuth'
const secret = 'campaign-auth-test-secret'
const claims = {
id: 42,
collection: 'campaignUser',
role: 'advisor',
email: 'advisor@example.com',
sid: 'session-id',
}
const payload = {
secret,
}
const decodePayload = (token: string): Record<string, unknown> =>
JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8')) as Record<
string,
unknown
>
describe('setCampaignAuthCookie', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('re-signs the token and cookie to the short default while preserving auth claims', async () => {
const { token } = await jwtSign({
fieldsToSign: claims,
secret,
tokenExpiration: CAMPAIGN_SESSION_TTL_LONG,
})
await setCampaignAuthCookie(token, payload)
const [name, shortToken, options] = mocks.cookieSet.mock.calls[0] ?? []
const decoded = decodePayload(shortToken)
expect(name).toBe(CAMPAIGN_TOKEN_COOKIE)
expect(shortToken).not.toBe(token)
expect(decoded).toMatchObject(claims)
expect(Number(decoded.exp) - Number(decoded.iat)).toBe(CAMPAIGN_SESSION_TTL_SHORT)
expect(options).toMatchObject({ maxAge: CAMPAIGN_SESSION_TTL_SHORT })
})
it('keeps a long Payload token unchanged when remember me is selected', async () => {
const { token } = await jwtSign({
fieldsToSign: claims,
secret,
tokenExpiration: CAMPAIGN_SESSION_TTL_LONG,
})
await setCampaignAuthCookie(token, payload, CAMPAIGN_SESSION_TTL_LONG)
expect(mocks.cookieSet).toHaveBeenCalledWith(
CAMPAIGN_TOKEN_COOKIE,
token,
expect.objectContaining({ maxAge: CAMPAIGN_SESSION_TTL_LONG }),
)
})
it('fails closed without setting a cookie when the token is malformed', async () => {
await expect(setCampaignAuthCookie('not-a-jwt', payload)).rejects.toThrow(
'Token de campanha inválido.',
)
expect(mocks.cookieSet).not.toHaveBeenCalled()
})
})

View file

@ -67,10 +67,19 @@ const reactActEnvironment = globalThis as typeof globalThis & {
beforeAll(() => {
reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
afterAll(() => {
delete reactActEnvironment.IS_REACT_ACT_ENVIRONMENT
vi.unstubAllGlobals()
})
afterEach(() => {
@ -95,6 +104,7 @@ describe('campaign login composition', () => {
const identifier = screen.getByLabelText('E-mail ou celular')
const password = screen.getByLabelText('Senha')
const rememberMe = screen.getByLabelText('Lembrar de mim neste dispositivo')
const submit = screen.getByRole('button', { name: 'Entrar' })
expect(screen.getByRole('heading', { level: 1, name: 'Entrar na campanha' })).toBeTruthy()
@ -105,6 +115,13 @@ describe('campaign login composition', () => {
expect(password.getAttribute('name')).toBe('password')
expect(password.hasAttribute('required')).toBe(true)
expect(password.getAttribute('autocomplete')).toBe('current-password')
expect(rememberMe.getAttribute('aria-checked')).toBe('false')
expect(rememberMe.getAttribute('aria-describedby')).toBe('remember-me-description')
expect(
screen.getByText(
'Em aparelho pessoal, fique conectado por 14 dias. Desmarcado, o acesso dura 8 horas.',
),
).toBeTruthy()
expect(submit.getAttribute('type')).toBe('submit')
expect(submit.className).toContain('w-full')
})

View file

@ -3,39 +3,47 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
cookieSet: vi.fn(),
clearAuthCookie: vi.fn(),
cookieGet: vi.fn(),
login: vi.fn(),
redirect: vi.fn(),
revokeSession: vi.fn(),
setAuthCookie: vi.fn(),
}))
vi.mock('@payload-config', () => ({ default: {} }))
vi.mock('next/headers', () => ({
cookies: vi.fn(async () => ({ set: mocks.cookieSet })),
cookies: vi.fn(async () => ({ get: mocks.cookieGet })),
}))
vi.mock('next/navigation', () => ({
redirect: mocks.redirect,
}))
vi.mock('payload', () => ({
getPayload: vi.fn(async () => ({
collections: {
campaignUser: {
config: {
auth: {
tokenExpiration: 7200,
},
},
},
},
login: mocks.login,
})),
}))
vi.mock('@/utilities/campaignAuth', () => ({
CAMPAIGN_TOKEN_COOKIE: 'campaign-token',
clearCampaignAuthCookie: mocks.clearAuthCookie,
revokeCampaignSession: mocks.revokeSession,
setCampaignAuthCookie: mocks.setAuthCookie,
}))
import { loginCampaign, loginCampaignFormAction } from '@/app/(campaign)/campanha/actions/auth'
import {
loginCampaign,
loginCampaignFormAction,
logoutCampaign,
} from '@/app/(campaign)/campanha/actions/auth'
import { CAMPAIGN_SESSION_TTL_LONG, CAMPAIGN_SESSION_TTL_SHORT } from '@/lib/campaignSessionTtl'
const longToken =
'header.eyJpZCI6MSwiY29sbGVjdGlvbiI6ImNhbXBhaWduVXNlciIsInNpZCI6InNlc3Npb24taWQiLCJpYXQiOjEsImV4cCI6Mn0.signature'
describe('loginCampaign credential mapping', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.login.mockResolvedValue({ token: 'campaign-token' })
mocks.login.mockResolvedValue({ token: longToken })
})
it('maps an email identifier to email credentials', async () => {
@ -84,6 +92,34 @@ describe('loginCampaign credential mapping', () => {
})
})
it('uses an eight-hour session when remember me is not selected', async () => {
await loginCampaign({
identifier: 'staff@example.com',
password: 'secret',
})
expect(mocks.setAuthCookie).toHaveBeenCalledWith(
longToken,
expect.objectContaining({ login: mocks.login }),
CAMPAIGN_SESSION_TTL_SHORT,
)
})
it('uses a fourteen-day session when remember me is selected in FormData', async () => {
const formData = new FormData()
formData.set('identifier', 'staff@example.com')
formData.set('password', 'secret')
formData.set('rememberMe', 'true')
await loginCampaignFormAction({}, formData)
expect(mocks.setAuthCookie).toHaveBeenCalledWith(
longToken,
expect.objectContaining({ login: mocks.login }),
CAMPAIGN_SESSION_TTL_LONG,
)
})
it('preserves password whitespace at the FormData boundary', async () => {
const formData = new FormData()
formData.set('identifier', ' staff@example.com ')
@ -139,4 +175,28 @@ describe('loginCampaign credential mapping', () => {
'Conta temporariamente bloqueada após várias tentativas. Aguarde alguns minutos e tente de novo.',
})
})
describe('logoutCampaign', () => {
beforeEach(() => {
mocks.cookieGet.mockReturnValue({ value: longToken })
})
it('revokes the current server session before clearing the logout cookie', async () => {
await logoutCampaign()
expect(mocks.revokeSession).toHaveBeenCalledWith(
longToken,
expect.objectContaining({ login: mocks.login }),
)
expect(mocks.clearAuthCookie).toHaveBeenCalledOnce()
})
it('still clears the logout cookie when server revocation rejects the token', async () => {
mocks.revokeSession.mockRejectedValueOnce(new Error('revocation unavailable'))
await logoutCampaign()
expect(mocks.clearAuthCookie).toHaveBeenCalledOnce()
})
})
})