mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:35:18 +00:00
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:
parent
5c6ed8cf31
commit
7484aab28a
19 changed files with 227 additions and 178 deletions
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
|||
69
src/components/campaign/auth/CampaignPasswordFields.tsx
Normal file
69
src/components/campaign/auth/CampaignPasswordFields.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()])
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue