From 7484aab28a977c0f69dd6e3f26ddfa3aa0e733c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 06:07:28 +0000 Subject: [PATCH] RS+ Fase 1+3: auth read leve, password fields e login state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/(campaign)/campanha/(app)/layout.tsx | 4 +- .../(campaign)/campanha/(app)/perfil/page.tsx | 6 +- src/app/(campaign)/campanha/actions/auth.ts | 34 +++++---- .../campanha/convite/[token]/formActions.ts | 16 +---- .../campanha/esqueci-senha/page.tsx | 6 +- .../(campaign)/campanha/login/LoginForm.tsx | 11 +-- src/app/(campaign)/campanha/login/page.tsx | 6 +- .../redefinir-senha/ResetPasswordForm.tsx | 36 +--------- .../campanha/redefinir-senha/page.tsx | 6 +- .../campaign/auth/CampaignPasswordFields.tsx | 69 +++++++++++++++++++ .../campaign/auth/CampaignProfileSettings.tsx | 34 +-------- .../campaign/invite/CampaignInviteForm.tsx | 59 +++------------- src/lib/schemas/campaignPassword.ts | 2 +- src/lib/schemas/invite.ts | 16 +++-- src/utilities/campaignAuth.ts | 57 ++++++++++++++- tests/int/campaignAuth.int.spec.ts | 10 ++- .../unit/campaignAuthMemoization.unit.spec.ts | 17 ++++- .../campaignCompositionCleanup.unit.spec.ts | 8 +-- tests/unit/campaignLoginAction.unit.spec.ts | 8 +-- 19 files changed, 227 insertions(+), 178 deletions(-) create mode 100644 src/components/campaign/auth/CampaignPasswordFields.tsx diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx index 640f0501..f0dbcd7e 100644 --- a/src/app/(campaign)/campanha/(app)/layout.tsx +++ b/src/app/(campaign)/campanha/(app)/layout.tsx @@ -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') diff --git a/src/app/(campaign)/campanha/(app)/perfil/page.tsx b/src/app/(campaign)/campanha/(app)/perfil/page.tsx index 0c731b03..b48c096c 100644 --- a/src/app/(campaign)/campanha/(app)/perfil/page.tsx +++ b/src/app/(campaign)/campanha/(app)/perfil/page.tsx @@ -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([ diff --git a/src/app/(campaign)/campanha/actions/auth.ts b/src/app/(campaign)/campanha/actions/auth.ts index fa5ebea8..ddf2048d 100644 --- a/src/app/(campaign)/campanha/actions/auth.ts +++ b/src/app/(campaign)/campanha/actions/auth.ts @@ -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 => { +export const loginCampaign = async (input: CampaignLoginInput): Promise => { 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 => { +): Promise => { 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.', + }) } } diff --git a/src/app/(campaign)/campanha/convite/[token]/formActions.ts b/src/app/(campaign)/campanha/convite/[token]/formActions.ts index f415c348..ccbaf84d 100644 --- a/src/app/(campaign)/campanha/convite/[token]/formActions.ts +++ b/src/app/(campaign)/campanha/convite/[token]/formActions.ts @@ -67,24 +67,12 @@ export const redeemCampaignInviteLoginFormAction = async ( _state: CampaignInviteFormState, formData: FormData, ): Promise => { - 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) diff --git a/src/app/(campaign)/campanha/esqueci-senha/page.tsx b/src/app/(campaign)/campanha/esqueci-senha/page.tsx index 995cf5c8..86ddb21e 100644 --- a/src/app/(campaign)/campanha/esqueci-senha/page.tsx +++ b/src/app/(campaign)/campanha/esqueci-senha/page.tsx @@ -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') } diff --git a/src/app/(campaign)/campanha/login/LoginForm.tsx b/src/app/(campaign)/campanha/login/LoginForm.tsx index aef5fbb6..69c29920 100644 --- a/src/app/(campaign)/campanha/login/LoginForm.tsx +++ b/src/app/(campaign)/campanha/login/LoginForm.tsx @@ -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 ( @@ -117,7 +120,7 @@ export const LoginForm = ({ biometricsConfigured }: LoginFormProps) => { ) : null} - {state.error ? {state.error} : null} + {authError ? {authError} : null}