diff --git a/src/app/(campaign)/campanha/(app)/perfil/page.tsx b/src/app/(campaign)/campanha/(app)/perfil/page.tsx index 12542224..6dbdfa48 100644 --- a/src/app/(campaign)/campanha/(app)/perfil/page.tsx +++ b/src/app/(campaign)/campanha/(app)/perfil/page.tsx @@ -1,6 +1,5 @@ import config from '@payload-config' import { headers } from 'next/headers' -import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { CampaignPasskeysCard } from '@/components/campaign/auth/CampaignPasskeysCard' @@ -8,8 +7,8 @@ import { CampaignProfileSettings } from '@/components/campaign/auth/CampaignProf import { CampaignPushNotificationsCard } from '@/components/campaign/auth/CampaignPushNotificationsCard' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { deviceLabelFromUserAgent } from '@/lib/deviceLabel' -import { getCampaignUserWithAvatar } from '@/utilities/campaignAuth' import { getCampaignPushConsent } from '@/utilities/campaignConsent' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { campaignUserShellView } from '@/utilities/campaignUserProfile' import { getCampaignVapidPublicKey } from '@/utilities/notification/sendCampaignPush' import { loadCampaignPasskeys } from '@/utilities/webauthn/campaignWebAuthnCeremony' @@ -22,8 +21,10 @@ type CampaignProfilePageProps = { } export default async function CampaignProfilePage({ searchParams }: CampaignProfilePageProps) { - const [user, params] = await Promise.all([getCampaignUserWithAvatar(), searchParams]) - if (!user) redirect('/campanha/login') + const [user, params] = await Promise.all([ + requireCampaignPageActor({ withAvatar: true }), + searchParams, + ]) const payload = await getPayload({ config }) const [passkeys, relyingParty, requestHeaders, pushConsent] = await Promise.all([ diff --git a/src/utilities/campaignPageActor.ts b/src/utilities/campaignPageActor.ts index bd2a002b..5473d442 100644 --- a/src/utilities/campaignPageActor.ts +++ b/src/utilities/campaignPageActor.ts @@ -5,11 +5,23 @@ import { redirect } from 'next/navigation' import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles' import { advisorEditingAccess } from '@/utilities/access/shared' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { getCampaignUser, getCampaignUserWithAvatar } from '@/utilities/campaignAuth' /** Exactly the non-null actor `getCampaignUser` proves (keeps its `email` refinement). */ export type CampaignPageActor = NonNullable>> +export type CampaignPageActorOptions = { + gate?: CampaignPageGate + redirectTo?: string + /** + * Load the actor with `avatar` populated (depth 1). The perfil shell renders + * `avatarUrl`, which requires the `avatar` relationship to be a populated + * media object — depth 0 would resolve it to a number id that the shell + * would treat as `null`. + */ + withAvatar?: boolean +} + export type CampaignPageGate = 'staff' | 'unrestricted' | 'noLeader' | 'writable' export { LEADER_CONTACTS_HOME } @@ -30,14 +42,19 @@ export const CAMPAIGN_STAFF_QUADRO_PATH = '/campanha/quadro' * (C142 — write destinations must not be offered to a * read-only advisor; the server already rejects the write). * - * A custom `redirectTo` overrides the gate's default target. The convention - * guard in `codebaseConventions.unit.spec.ts` fails the build on a - * `getCampaignUser()` call in a `(app)` route page outside this helper. + * A custom `redirectTo` overrides the gate's default target. `withAvatar` + * loads the actor through `getCampaignUserWithAvatar` (avatar populated at + * depth 1) instead of `getCampaignUser` — both return + * `AuthenticatedCampaignUser | null`; the avatar depth is runtime-only and + * does not change the gates. The convention guard in + * `codebaseConventions.unit.spec.ts` fails the build on a `getCampaignUser*()` + * call in a `(app)` route page outside this helper. */ export const requireCampaignPageActor = async ( - options: { gate?: CampaignPageGate; redirectTo?: string } = {}, + options: CampaignPageActorOptions = {}, ): Promise => { - const user = await getCampaignUser() + const getter = options.withAvatar ? getCampaignUserWithAvatar : getCampaignUser + const user = await getter() if (!user) redirect('/campanha/login') const { gate, redirectTo } = options diff --git a/tests/unit/campaignPageActorGate.unit.spec.ts b/tests/unit/campaignPageActorGate.unit.spec.ts index 8f151eb6..edfd2b1d 100644 --- a/tests/unit/campaignPageActorGate.unit.spec.ts +++ b/tests/unit/campaignPageActorGate.unit.spec.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ redirect: vi.fn(), getCampaignUser: vi.fn(), + getCampaignUserWithAvatar: vi.fn(), })) vi.mock('next/navigation', () => ({ @@ -13,6 +14,7 @@ vi.mock('next/navigation', () => ({ vi.mock('@/utilities/campaignAuth', () => ({ getCampaignUser: mocks.getCampaignUser, + getCampaignUserWithAvatar: mocks.getCampaignUserWithAvatar, })) vi.mock('@/utilities/access/shared', () => ({ @@ -79,3 +81,30 @@ describe('requireCampaignPageActor — gate: writable (C142)', () => { expect(mocks.redirect).toHaveBeenCalled() }) }) + +describe('requireCampaignPageActor — withAvatar option', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('loads the actor through the avatar getter (perfil shell needs a populated avatarUrl)', async () => { + mocks.getCampaignUserWithAvatar.mockResolvedValue(actor('coordinator')) + const user = await requireCampaignPageActor({ withAvatar: true }) + expect(user.role).toBe('coordinator') + expect(mocks.getCampaignUserWithAvatar).toHaveBeenCalledTimes(1) + expect(mocks.getCampaignUser).not.toHaveBeenCalled() + expect(mocks.redirect).not.toHaveBeenCalled() + }) + + it('redirects to login when the avatar getter finds no session', async () => { + mocks.getCampaignUserWithAvatar.mockResolvedValue(null) + // Real `next/navigation` redirect never returns (throws NEXT_REDIRECT); + // the helper reads `user.role` right after, so mimic that here. + mocks.redirect.mockImplementationOnce(() => { + throw new Error('NEXT_REDIRECT') + }) + await expect(requireCampaignPageActor({ withAvatar: true })).rejects.toThrow('NEXT_REDIRECT') + expect(mocks.redirect).toHaveBeenCalledWith('/campanha/login') + expect(mocks.getCampaignUser).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 6af5072d..1040281c 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -528,11 +528,12 @@ describe('src/utilities top-level is pinned', () => { describe('campaign route-page actor comes from requireCampaignPageActor', () => { // P3-I: the staff page prologue was ~110 hand-spelled lines across 30 pages // with a real divergence (`return null` ×5 — a blank screen for a missing - // session). A page that re-spells `getCampaignUser()` breaks the build here; - // genuinely bespoke prologues are allowlisted with their reason: + // session). A page that re-spells any `getCampaignUser*()` getter variant + // (e.g. `getCampaignUserWithAvatar`) breaks the build here; genuinely + // bespoke prologues are allowlisted with their reason: const allowlist = new Set([]) - it('keeps getCampaignUser() out of (app) route pages', () => { + it('keeps getCampaignUser*() getters out of (app) route pages', () => { const offenders: string[] = [] for (const file of walkSourceFiles(resolve(repoRoot, 'src/app/(campaign)/campanha/(app)'), [ @@ -540,7 +541,7 @@ describe('campaign route-page actor comes from requireCampaignPageActor', () => ])) { const path = repoPath(file) if (basename(file) !== 'page.tsx' || allowlist.has(path)) continue - if (/getCampaignUser\(\)/.test(readFileSync(file, 'utf8'))) offenders.push(path) + if (/getCampaignUser[A-Za-z0-9]*\(/.test(readFileSync(file, 'utf8'))) offenders.push(path) } expect(offenders, 'use requireCampaignPageActor from @/utilities/campaignPageActor').toEqual([])