Add remembered campaign sessions
Keep campaign JWT and cookie lifetimes aligned while revoking the active server session on logout.
This commit is contained in:
parent
126b1abae8
commit
3892c2e0a4
17 changed files with 472 additions and 57 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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?
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
5
src/lib/campaignSessionTtl.ts
Normal file
5
src/lib/campaignSessionTtl.ts
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
39
tests/e2e/campaignAuth.e2e.spec.ts
Normal file
39
tests/e2e/campaignAuth.e2e.spec.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
81
tests/unit/campaignAuthCookie.unit.spec.ts
Normal file
81
tests/unit/campaignAuthCookie.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue