RS+ Fase 1+3: auth read leve, password fields e login state

- authenticateCampaignToken usa depth:0 + select mínimo (sem join media)
- getCampaignUserWithAvatar para layout/perfil; hasCampaignSession em rotas públicas
- CampaignPasswordFields reutilizado em reset, perfil e convite
- LoginForm migra para CampaignFormActionState + mapCampaignFormActionError
- invite login schema unifica refineMatchingPasswords

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-01 06:07:28 +00:00
parent 5c6ed8cf31
commit 7484aab28a
No known key found for this signature in database
19 changed files with 227 additions and 178 deletions

View file

@ -26,13 +26,13 @@ import {
import { Toaster } from '@/components/ui/Toaster'
import { TooltipProvider } from '@/components/ui/tooltip'
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getCampaignUserWithAvatar } from '@/utilities/campaignAuth'
import { campaignUserShellView } from '@/utilities/campaignUserProfile'
import { loadCampaignPasskeys } from '@/utilities/webauthn/campaignWebAuthnCeremony'
import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/webauthn/campaignWebAuthnConfig'
export default async function CampaignAppLayout({ children }: { children: React.ReactNode }) {
const user = await getCampaignUser()
const user = await getCampaignUserWithAvatar()
if (!user) {
redirect('/campanha/login')

View file

@ -1,6 +1,7 @@
import config from '@payload-config'
import type { Metadata } from 'next'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPasskeysCard } from '@/components/campaign/auth/CampaignPasskeysCard'
@ -8,7 +9,7 @@ import { CampaignProfileSettings } from '@/components/campaign/auth/CampaignProf
import { CampaignPushNotificationsCard } from '@/components/campaign/auth/CampaignPushNotificationsCard'
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
import { getCampaignPushConsent } from '@/utilities/campaignConsent'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { getCampaignUserWithAvatar } from '@/utilities/campaignAuth'
import { campaignUserShellView } from '@/utilities/campaignUserProfile'
import { getCampaignVapidPublicKey } from '@/utilities/notification/sendCampaignPush'
import { loadCampaignPasskeys } from '@/utilities/webauthn/campaignWebAuthnCeremony'
@ -27,7 +28,8 @@ type CampaignProfilePageProps = {
}
export default async function CampaignProfilePage({ searchParams }: CampaignProfilePageProps) {
const [user, params] = await Promise.all([requireCampaignPageActor(), searchParams])
const [user, params] = await Promise.all([getCampaignUserWithAvatar(), searchParams])
if (!user) redirect('/campanha/login')
const payload = await getPayload({ config })
const [passkeys, relyingParty, requestHeaders, pushConsent] = await Promise.all([

View file

@ -18,8 +18,10 @@ import {
revokeCampaignSession,
setCampaignAuthCookie,
} from '@/utilities/campaignAuth'
export type LoginResult = { error?: string }
import {
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
const isLockedAuthError = (error: unknown): boolean =>
typeof error === 'object' &&
@ -27,11 +29,14 @@ const isLockedAuthError = (error: unknown): boolean =>
'name' in error &&
(error as { name: unknown }).name === 'LockedAuth'
export const loginCampaign = async (input: CampaignLoginInput): Promise<LoginResult> => {
export const loginCampaign = async (input: CampaignLoginInput): Promise<CampaignFormActionState> => {
const parsed = campaignLoginSchema.safeParse(input)
if (!parsed.success) {
return { error: 'Dados inválidos.' }
return mapCampaignFormActionError({
error: parsed.error,
genericMessage: 'Dados inválidos.',
})
}
const payload = await getPayload({ config })
@ -52,13 +57,13 @@ export const loginCampaign = async (input: CampaignLoginInput): Promise<LoginRes
token = result.token
} catch (error) {
if (isLockedAuthError(error)) {
return { error: CAMPAIGN_ACCOUNT_LOCKED_MESSAGE }
return { message: CAMPAIGN_ACCOUNT_LOCKED_MESSAGE }
}
return { error: CAMPAIGN_LOGIN_INVALID_CREDENTIALS_MESSAGE }
return { message: CAMPAIGN_LOGIN_INVALID_CREDENTIALS_MESSAGE }
}
if (!token) {
return { error: CAMPAIGN_LOGIN_INVALID_CREDENTIALS_MESSAGE }
return { message: CAMPAIGN_LOGIN_INVALID_CREDENTIALS_MESSAGE }
}
await setCampaignAuthCookie(
@ -71,17 +76,22 @@ export const loginCampaign = async (input: CampaignLoginInput): Promise<LoginRes
}
export const loginCampaignFormAction = async (
_previousState: LoginResult,
_previousState: CampaignFormActionState,
formData: FormData,
): Promise<LoginResult> => {
): Promise<CampaignFormActionState> => {
try {
return loginCampaign({
const result = await loginCampaign({
identifier: requiredFormText(formData, 'identifier'),
password: requiredFormSecret(formData, 'password'),
rememberMe: checkboxFormValue(formData, 'rememberMe'),
})
} catch {
return { error: 'Dados inválidos.' }
return result
} catch (error) {
if (error instanceof Error && error.message === 'NEXT_REDIRECT') throw error
return mapCampaignFormActionError({
error,
genericMessage: 'Dados inválidos.',
})
}
}

View file

@ -67,24 +67,12 @@ export const redeemCampaignInviteLoginFormAction = async (
_state: CampaignInviteFormState,
formData: FormData,
): Promise<CampaignInviteFormState> => {
let password: string
try {
password = requiredFormSecret(formData, 'password')
if (password !== requiredFormSecret(formData, 'passwordConfirmation')) {
return {
fieldErrors: {
passwordConfirmation: ['As senhas não coincidem.'],
},
}
}
} catch (error) {
return inviteFormError(error)
}
try {
const input = campaignInviteLoginSchema.parse({
token,
...profileFromForm(formData),
password,
password: requiredFormSecret(formData, 'password'),
passwordConfirmation: requiredFormSecret(formData, 'passwordConfirmation'),
consentAccepted: checkboxFormValue(formData, 'consentAccepted'),
})
await redeemCampaignInviteLogin(input)

View file

@ -3,7 +3,7 @@ import { redirect } from 'next/navigation'
import { ForgotPasswordForm } from '@/app/(campaign)/campanha/esqueci-senha/ForgotPasswordForm'
import { CampaignAuthPageShell } from '@/components/campaign/auth/CampaignAuthPageShell'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { hasCampaignSession } from '@/utilities/campaignAuth'
export const metadata: Metadata = {
title: 'Esqueci a senha | Campanha',
@ -14,9 +14,9 @@ export const metadata: Metadata = {
}
export default async function CampaignForgotPasswordPage() {
const user = await getCampaignUser()
const hasSession = await hasCampaignSession()
if (user) {
if (hasSession) {
redirect('/campanha/perfil')
}

View file

@ -3,7 +3,7 @@
import Link from 'next/link'
import { useActionState, useState } from 'react'
import { loginCampaignFormAction, type LoginResult } from '@/app/(campaign)/campanha/actions/auth'
import { loginCampaignFormAction } from '@/app/(campaign)/campanha/actions/auth'
import { CampaignAuthCardHeader } from '@/components/campaign/auth/CampaignAuthCardHeader'
import { CampaignBiometricLoginButton } from '@/components/campaign/auth/CampaignBiometricLoginButton'
import { Button } from '@/components/ui/button'
@ -28,6 +28,8 @@ import {
campaignAuthTextLinkClassName,
} from '@/lib/campaignAuthCopy'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
const LOGIN_ERROR_ID = 'login-credentials-error'
@ -48,10 +50,11 @@ type LoginFormProps = {
export const LoginForm = ({ biometricsConfigured }: LoginFormProps) => {
const [state, formAction, pending] = useActionState(
loginCampaignFormAction,
{} satisfies LoginResult,
{} satisfies CampaignFormActionState,
)
const [identifier, setIdentifier] = useState('')
const hasAuthError = Boolean(state.error)
const authError = firstFormActionMessage(state)
const hasAuthError = Boolean(authError)
return (
<Card>
@ -117,7 +120,7 @@ export const LoginForm = ({ biometricsConfigured }: LoginFormProps) => {
) : null}
</div>
<Field>
{state.error ? <FieldError id={LOGIN_ERROR_ID}>{state.error}</FieldError> : null}
{authError ? <FieldError id={LOGIN_ERROR_ID}>{authError}</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 ? 'Entrando...' : 'Entrar'}</span>

View file

@ -2,7 +2,7 @@ import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { CampaignAuthPageShell } from '@/components/campaign/auth/CampaignAuthPageShell'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { hasCampaignSession } from '@/utilities/campaignAuth'
import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/webauthn/campaignWebAuthnConfig'
import { LoginForm } from './LoginForm'
@ -15,9 +15,9 @@ export const metadata: Metadata = {
}
export default async function CampaignLoginPage() {
const user = await getCampaignUser()
const hasSession = await hasCampaignSession()
if (user) {
if (hasSession) {
redirect('/campanha')
}

View file

@ -5,21 +5,18 @@ import { useActionState } from 'react'
import { resetCampaignPasswordFormAction } from '@/app/(campaign)/campanha/actions/password'
import { CampaignAuthBackToLoginLink } from '@/components/campaign/auth/CampaignAuthBackToLoginLink'
import { CampaignAuthCardHeader } from '@/components/campaign/auth/CampaignAuthCardHeader'
import { CampaignPasswordFields } from '@/components/campaign/auth/CampaignPasswordFields'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Field, FieldError, FieldGroup } from '@/components/ui/field'
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,
)
const passwordError = fieldError(state.fieldErrors, 'password')
const passwordConfirmationError = fieldError(state.fieldErrors, 'passwordConfirmation')
return (
<Card>
@ -30,35 +27,8 @@ export const ResetPasswordForm = ({ token }: { token: string }) => {
<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>
<CampaignPasswordFields fieldErrors={state.fieldErrors} idPrefix="reset-password" />
{state.message ? <FieldError>{state.message}</FieldError> : null}
{passwordError ? <FieldError>{passwordError}</FieldError> : null}
{passwordConfirmationError ? (
<FieldError>{passwordConfirmationError}</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>

View file

@ -8,7 +8,7 @@ import { CampaignAuthPageShell } from '@/components/campaign/auth/CampaignAuthPa
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { CAMPAIGN_PASSWORD_RESET_TOKEN_MIN_LENGTH } from '@/lib/schemas/campaignPassword'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { hasCampaignSession } from '@/utilities/campaignAuth'
import { CAMPAIGN_PASSWORD_RESET_INVALID_TOKEN_MESSAGE } from '@/utilities/campaignPasswordReset'
export const metadata: Metadata = {
@ -26,8 +26,8 @@ type CampaignResetPasswordPageProps = {
export default async function CampaignResetPasswordPage({
searchParams,
}: CampaignResetPasswordPageProps) {
const user = await getCampaignUser()
if (user) {
const hasSession = await hasCampaignSession()
if (hasSession) {
redirect('/campanha/perfil')
}

View file

@ -0,0 +1,69 @@
'use client'
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { errorProps } from '@/utilities/campaignFormFields'
type CampaignPasswordFieldsProps = {
fieldErrors?: Record<string, string[]>
idPrefix?: string
passwordLabel?: string
confirmationLabel?: string
showPasswordHint?: boolean
required?: boolean
}
export const CampaignPasswordFields = ({
fieldErrors,
idPrefix = 'campaign-password',
passwordLabel = 'Nova senha',
confirmationLabel = 'Confirmar nova senha',
showPasswordHint = false,
required = true,
}: CampaignPasswordFieldsProps) => {
const password = errorProps(fieldErrors, 'password', idPrefix)
const passwordConfirmation = errorProps(fieldErrors, 'passwordConfirmation', idPrefix)
const passwordHelpId = `${idPrefix}-password-help`
return (
<FieldGroup>
<Field data-invalid={password.invalid}>
<FieldLabel htmlFor={`${idPrefix}-password`}>{passwordLabel}</FieldLabel>
<Input
id={`${idPrefix}-password`}
name="password"
type="password"
autoComplete="new-password"
minLength={8}
maxLength={128}
required={required}
className="min-h-11"
aria-invalid={password.invalid}
aria-describedby={password.error ? password.describedBy : passwordHelpId}
/>
{showPasswordHint ? (
<FieldDescription id={passwordHelpId}>Use pelo menos 8 caracteres.</FieldDescription>
) : null}
{password.error ? <FieldError id={password.describedBy}>{password.error}</FieldError> : null}
</Field>
<Field data-invalid={passwordConfirmation.invalid}>
<FieldLabel htmlFor={`${idPrefix}-password-confirmation`}>{confirmationLabel}</FieldLabel>
<Input
id={`${idPrefix}-password-confirmation`}
name="passwordConfirmation"
type="password"
autoComplete="new-password"
minLength={8}
maxLength={128}
required={required}
className="min-h-11"
aria-invalid={passwordConfirmation.invalid}
aria-describedby={passwordConfirmation.describedBy}
/>
{passwordConfirmation.error ? (
<FieldError id={passwordConfirmation.describedBy}>{passwordConfirmation.error}</FieldError>
) : null}
</Field>
</FieldGroup>
)
}

View file

@ -7,6 +7,7 @@ import {
removeCampaignAvatarFormAction,
updateCampaignAvatarFormAction,
} from '@/app/(campaign)/campanha/actions/profile'
import { CampaignPasswordFields } from '@/components/campaign/auth/CampaignPasswordFields'
import { CampaignUserAvatar } from '@/components/campaign/shared/CampaignUserAvatar'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@ -177,42 +178,11 @@ export const CampaignProfileSettings = ({
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>
<CampaignPasswordFields fieldErrors={passwordState.fieldErrors} idPrefix="profile" />
<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>

View file

@ -8,6 +8,7 @@ import type {
CampaignInviteFormState,
} from '@/app/(campaign)/campanha/convite/[token]/formActions'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { CampaignPasswordFields } from '@/components/campaign/auth/CampaignPasswordFields'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@ -184,8 +185,6 @@ export const CampaignInviteForm = ({
}) => {
const [state, formAction, pending] = useActionState(action, {})
const consentError = fieldError(state.fieldErrors, 'consentAccepted')
const passwordError = fieldError(state.fieldErrors, 'password')
const confirmationError = fieldError(state.fieldErrors, 'passwordConfirmation')
if (state.status === 'success') {
return (
@ -234,55 +233,13 @@ export const CampaignInviteForm = ({
)}
{kind === 'login' ? (
<FieldGroup>
<Field data-invalid={Boolean(passwordError)}>
<FieldLabel htmlFor="campaign-invite-password">Nova senha *</FieldLabel>
<Input
id="campaign-invite-password"
name="password"
type="password"
autoComplete="new-password"
minLength={8}
maxLength={128}
required
aria-invalid={Boolean(passwordError)}
aria-describedby={
passwordError
? 'campaign-invite-password-error'
: 'campaign-invite-password-help'
}
/>
<FieldDescription id="campaign-invite-password-help">
Use pelo menos 8 caracteres.
</FieldDescription>
{passwordError ? (
<FieldError id="campaign-invite-password-error">{passwordError}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(confirmationError)}>
<FieldLabel htmlFor="campaign-invite-password-confirmation">
Confirme a nova senha *
</FieldLabel>
<Input
id="campaign-invite-password-confirmation"
name="passwordConfirmation"
type="password"
autoComplete="new-password"
minLength={8}
maxLength={128}
required
aria-invalid={Boolean(confirmationError)}
aria-describedby={
confirmationError ? 'campaign-invite-password-confirmation-error' : undefined
}
/>
{confirmationError ? (
<FieldError id="campaign-invite-password-confirmation-error">
{confirmationError}
</FieldError>
) : null}
</Field>
</FieldGroup>
<CampaignPasswordFields
fieldErrors={state.fieldErrors}
idPrefix="campaign-invite"
passwordLabel="Nova senha *"
confirmationLabel="Confirme a nova senha *"
showPasswordHint
/>
) : null}
<Button type="submit" className="min-h-11 w-full" disabled={pending}>

View file

@ -9,7 +9,7 @@ export const campaignPasswordSchema = z
.min(8, 'A senha deve ter pelo menos 8 caracteres.')
.max(128, 'A senha deve ter no máximo 128 caracteres.')
const refineMatchingPasswords = (
export const refineMatchingPasswords = (
data: { password: string; passwordConfirmation: string },
context: RefinementCtx,
) => {

View file

@ -1,6 +1,9 @@
import { z } from 'zod'
import { campaignPasswordSchema } from '@/lib/schemas/campaignPassword'
import {
campaignPasswordSchema,
refineMatchingPasswords,
} from '@/lib/schemas/campaignPassword'
import { leadershipGenders } from '@/lib/schemas/leadership'
import {
brazilianMobile,
@ -33,10 +36,13 @@ export const campaignInviteAutofillSchema = campaignInviteProfileSchema.extend({
consentAccepted: z.boolean().optional(),
})
export const campaignInviteLoginSchema = campaignInviteProfileSchema.extend({
password: campaignPasswordSchema,
consentAccepted: z.boolean().optional(),
})
export const campaignInviteLoginSchema = campaignInviteProfileSchema
.extend({
password: campaignPasswordSchema,
passwordConfirmation: campaignPasswordSchema,
consentAccepted: z.boolean().optional(),
})
.superRefine(refineMatchingPasswords)
export type CampaignInviteCreateInput = z.input<typeof campaignInviteCreateSchema>
export type CampaignInviteAutofillInput = z.input<typeof campaignInviteAutofillSchema>

View file

@ -16,6 +16,16 @@ type CampaignAuthPayload = Pick<Payload, 'auth' | 'findByID'>
type CampaignCookiePayload = Pick<Payload, 'secret'>
export type AuthenticatedCampaignUser = CampaignUser & { email: string }
/** Gate reload — no `media` join; avatar stays a bare relationship id. */
export const CAMPAIGN_AUTH_GATE_SELECT = {
id: true,
name: true,
role: true,
email: true,
username: true,
avatar: true,
} as const
type CampaignTokenClaims = Record<string, unknown> & {
collection: 'campaignUser'
id: number | string
@ -97,7 +107,8 @@ export const authenticateCampaignToken = async (
const currentUser = await payload.findByID({
collection: 'campaignUser',
id: user.id,
depth: 1,
depth: 0,
select: CAMPAIGN_AUTH_GATE_SELECT,
})
return {
@ -109,19 +120,59 @@ export const authenticateCampaignToken = async (
}
}
export const getCampaignUserRaw = async (): Promise<AuthenticatedCampaignUser | null> => {
const readAuthenticatedCampaignUser = async (
depth: 0 | 1,
): Promise<AuthenticatedCampaignUser | null> => {
const cookieStore = await cookies()
const token = cookieStore.get(CAMPAIGN_TOKEN_COOKIE)?.value
if (!token) return null
const payload = await getPayload({ config: configPromise })
const user = await authenticateCampaignToken(token, payload)
if (!user) return null
return authenticateCampaignToken(token, payload)
if (depth === 0) return user
const withAvatar = await payload.findByID({
collection: 'campaignUser',
id: user.id,
depth: 1,
select: CAMPAIGN_AUTH_GATE_SELECT,
})
return {
...withAvatar,
email: withAvatar.email ?? '',
}
}
export const getCampaignUserRaw = (): Promise<AuthenticatedCampaignUser | null> =>
readAuthenticatedCampaignUser(0)
export const getCampaignUser = cache(getCampaignUserRaw)
export const getCampaignUserWithAvatarRaw = (): Promise<AuthenticatedCampaignUser | null> =>
readAuthenticatedCampaignUser(1)
export const getCampaignUserWithAvatar = cache(getCampaignUserWithAvatarRaw)
/** Public auth routes — session probe without reloading the user document. */
export const hasCampaignSession = cache(async (): Promise<boolean> => {
const cookieStore = await cookies()
const token = cookieStore.get(CAMPAIGN_TOKEN_COOKIE)?.value
if (!token) return false
try {
const payload = await getPayload({ config: configPromise })
const headers = new Headers({ Authorization: `JWT ${token}` })
const { user } = await payload.auth({ headers })
return user?.collection === 'campaignUser'
} catch {
return false
}
})
const campaignCookieOptions = (maxAge: number) => ({
httpOnly: true,
maxAge,

View file

@ -180,7 +180,15 @@ describe('campaign authentication foundation', () => {
expect(findByID).toHaveBeenCalledWith({
collection: 'campaignUser',
id: staleJwtUser.id,
depth: 1,
depth: 0,
select: expect.objectContaining({
id: true,
name: true,
role: true,
email: true,
username: true,
avatar: true,
}),
})
expect(result?.email).toBe('')
expect(result?.role).toBe('advisor')

View file

@ -40,7 +40,11 @@ vi.mock('payload', () => ({
})),
}))
import { getCampaignUser, getCampaignUserRaw } from '@/utilities/campaignAuth'
import {
getCampaignUser,
getCampaignUserRaw,
getCampaignUserWithAvatar,
} from '@/utilities/campaignAuth'
describe('campaign auth request memoization', () => {
beforeEach(() => {
@ -76,6 +80,17 @@ describe('campaign auth request memoization', () => {
expect(state.findByID).toHaveBeenCalledTimes(2)
})
it('shares avatar reload within one request', async () => {
const [fromLayout, fromSidebar] = await Promise.all([
getCampaignUserWithAvatar(),
getCampaignUserWithAvatar(),
])
expect(fromLayout).toBe(fromSidebar)
expect(state.auth).toHaveBeenCalledTimes(1)
expect(state.findByID).toHaveBeenCalledTimes(2)
})
it('keeps an uncached raw function for tests and action boundaries', async () => {
await Promise.all([getCampaignUserRaw(), getCampaignUserRaw()])

View file

@ -169,7 +169,7 @@ describe('campaign login composition', () => {
})
it('links auth errors to both credential fields and shows leadership recovery copy', async () => {
mocks.loginAction.mockResolvedValue({ error: 'E-mail, celular ou senha inválidos.' })
mocks.loginAction.mockResolvedValue({ message: 'E-mail, celular ou senha inválidos.' })
render(createElement(LoginForm))
fireEvent.change(screen.getByLabelText('E-mail ou celular'), {
@ -197,10 +197,10 @@ describe('campaign login composition', () => {
})
it('shows the pending spinner and announces an action error', async () => {
let resolveAction: ((value: { error: string }) => void) | undefined
let resolveAction: ((value: { message: string }) => void) | undefined
mocks.loginAction.mockImplementation(
() =>
new Promise<{ error: string }>((resolve) => {
new Promise<{ message: string }>((resolve) => {
resolveAction = resolve
}),
)
@ -223,7 +223,7 @@ describe('campaign login composition', () => {
expect((pendingButton as HTMLButtonElement).disabled).toBe(true)
await act(async () => {
resolveAction?.({ error: 'E-mail, celular ou senha inválidos.' })
resolveAction?.({ message: 'E-mail, celular ou senha inválidos.' })
})
await waitFor(() =>

View file

@ -141,8 +141,8 @@ describe('loginCampaign credential mapping', () => {
formData.set('identifier', 'not-an-identifier')
formData.set('password', '')
await expect(loginCampaignFormAction({}, formData)).resolves.toEqual({
error: 'Dados inválidos.',
await expect(loginCampaignFormAction({}, formData)).resolves.toMatchObject({
fieldErrors: expect.any(Object),
})
expect(mocks.login).not.toHaveBeenCalled()
})
@ -156,7 +156,7 @@ describe('loginCampaign credential mapping', () => {
password: 'wrong-password',
}),
).resolves.toEqual({
error: 'E-mail, celular ou senha inválidos.',
message: 'E-mail, celular ou senha inválidos.',
})
})
@ -171,7 +171,7 @@ describe('loginCampaign credential mapping', () => {
password: 'any-password',
}),
).resolves.toEqual({
error:
message:
'Conta temporariamente bloqueada após várias tentativas. Aguarde alguns minutos e tente de novo.',
})
})