Add /campanha internal area with isolated Payload auth

Introduce the internal campaign-management area gated by its own
authentication, kept isolated from the Payload admin so a campaign
session and an admin session can coexist in one browser.

- Add separate `campaignUser` auth collection (not `users` + roles)
- Authenticate via an isolated httpOnly `campaign-token` cookie scoped
  to /campanha, distinct from the default `payload-token`
- Add loginCampaign/logoutCampaign server actions and getCampaignUser
  session verification helper
- Add public login route and a protected (app) route group whose
  layout enforces the auth barrier
- Add migration 20260716_010420_add_campaign_user and document the
  auth foundation in AGENTS.md
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-15 22:33:26 -03:00
parent f15a2cf1c1
commit c4866fabc0
16 changed files with 4977 additions and 15 deletions

View file

@ -8,7 +8,7 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si
**Locked decisions — do not relitigate without a new concrete reason:**
- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campanha)` internal campaign tool (roles: assessores). No separate Rust service, no separate frontend app for `/campanha` — model it as Payload collections (it already has native `point` fields + `near` queries for the political map).
- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campanha)` internal campaign tool (roles: assessores). No separate Rust service, no separate frontend app for `/campanha` — model it as Payload collections (it already has native `point` fields + `near` queries for the political map). The `(campanha)` route group now EXISTS with an auth barrier — it is NOT built on `users` + roles but on a **separate Payload auth collection** `campaignUser` (see "Campaign auth" below).
- Hosting stays on Vercel for now. No self-host/Coolify migration in progress.
- Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL.
@ -32,7 +32,7 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si
## Known Gaps (as of 2026-07-15 — resolve before relying on this file for onboarding new devs)
1. **`Users` has no `roles` field yet.** The RBAC examples below are generic Payload patterns, not yet implemented here — every admin user today has full access. Add `roles` (e.g. `admin`/`editor`/`assessor`) before opening the admin to more people or building `/campanha`.
1. **No `roles` field on either auth collection yet.** The RBAC examples below are generic Payload patterns, not yet implemented here — every `users` (admin) user today has full access, and the `/campanha` barrier is currently just "any authenticated `campaignUser` = full access" (see "Campaign auth" below). Roles are deferred until a `/campanha` screen actually needs to gate on them; add `roles` (e.g. `admin`/`editor`/`assessor`) to the relevant collection at that point (and before opening the admin to more people).
2. **`Consent` document IDs are hardcoded** in code (e.g. `consent: 2` in `submitWhatsapp.ts`). Fragile — breaks silently if that document is ever recreated with a different ID, and blocks having a reproducible local seed. Consider referencing by a stable slug/key instead. (The column-type drift is fixed; the hardcoded-ID fragility remains.)
3. **`Post`/`Tag` ship the news system, but there is still no `Pages` collection for institutional content.** Partly resolved: the `post`/`tag` collections back the news/publications system, and the home "Últimas notícias" list plus the `/[type]`, `/[type]/[category]`, and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (`HomePage` global still exposes only a single `image` field — the text lives in `src/app/(frontend)/(home)/page.tsx`), and there is no `Pages` collection yet for institutional content such as the bio and propostas.
@ -73,6 +73,17 @@ curl -X POST "https://<prod-domain>/api/revalidate" \
- **Responses:** `200 { revalidated: true, tag: 'posts' }` on success; `401` if the secret is missing/wrong; `500` if `REVALIDATE_SECRET` is not configured on the server.
- This POST is the required last step of the post-seed / direct-DB-change runbook.
## Campaign auth (`/campanha`)
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** (`auth: true`), labels "Usuário"/"Usuários", admin group "Campanha", with a single `name` field on top of the built-in auth fields. It is NOT `users`: `admin.user` in `payload.config.ts` stays `users`, so a `campaignUser` cannot log into `/admin` (it can still be managed there by an admin). This is why `/campanha` is not built on `users` + roles.
- **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.
- **Session verification — `getCampaignUser()`** (`src/utilities/campaignAuth.ts`). Reads the `campaign-token` cookie, calls `payload.auth({ headers })` with an `Authorization: JWT <token>` header, and returns the user only when `user.collection === 'campaignUser'` (otherwise `null`) — so an admin `payload-token` can never satisfy the campaign barrier.
- **Login/logout server actions** (`src/app/(campanha)/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`.
- **Route layout.** The route group's root layout `src/app/(campanha)/layout.tsx` renders the `<html>`/`<body>` with `data-theme="campaign"`. The public login lives at `src/app/(campanha)/campanha/login/` (`page.tsx` + client `LoginForm.tsx`). Everything gated sits in the `(app)` group, whose layout (`src/app/(campanha)/campanha/(app)/layout.tsx`) calls `getCampaignUser()` and `redirect('/campanha/login')` when there is no session — that layout is the barrier.
- **Migration.** Adding the collection required migration `20260716_010420_add_campaign_user` (creates `campaign_user` + `campaign_user_sessions` tables and the related `payload_locked_documents_rels` / `payload_preferences_rels` columns).
## Core Principles
1. **TypeScript-First**: Always use TypeScript with proper types from Payload
@ -97,8 +108,8 @@ src/
├── app/
│ ├── (frontend)/ # Frontend routes + server actions (src/app/(frontend)/actions/*.ts)
│ ├── (payload)/ # Payload admin routes
│ └── (campanha)/ # NOT YET CREATED — planned internal campaign-management area
├── collections/ # Collection configs (Users, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag)
│ └── (campanha)/ # Internal campaign-management area (auth barrier, see "Campaign auth")
├── collections/ # Collection configs (Users, CampaignUser, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag)
├── globals/ # Global configs (SiteSettings, HomePage, Metadata)
├── components/ # Custom React components (incl. components/ui from shadcn)
├── lib/ # cities.ts, zod schemas (lib/schemas/*)

View file

@ -0,0 +1,36 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import React from 'react'
import { logoutCampaign } from '@/app/(campanha)/campanha/actions/auth'
import { Button } from '@/components/ui/button'
import { getCampaignUser } from '@/utilities/campaignAuth'
export default async function CampaignAppLayout({ children }: { children: React.ReactNode }) {
const user = await getCampaignUser()
if (!user) {
redirect('/campanha/login')
}
return (
<div className="flex min-h-screen flex-col">
<header className="flex items-center justify-between border-b px-4 py-3">
<nav className="flex items-center gap-4">
<Link href="/campanha" className="font-bold">
Campanha
</Link>
</nav>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">{user.name}</span>
<form action={logoutCampaign}>
<Button type="submit" variant="outline" size="sm">
Sair
</Button>
</form>
</div>
</header>
<main className="flex-1 p-4">{children}</main>
</div>
)
}

View file

@ -0,0 +1,10 @@
export default function CampaignHomePage() {
return (
<div className="space-y-2">
<h1 className="text-2xl font-bold">Painel da campanha</h1>
<p className="text-muted-foreground">
Em breve: ferramentas de gerenciamento da campanha.
</p>
</div>
)
}

View file

@ -0,0 +1,70 @@
'use server'
import config from '@payload-config'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { campaignLoginSchema, type CampaignLoginInput } from '@/lib/schemas/campaign-login'
import { CAMPAIGN_TOKEN_COOKIE } from '@/utilities/campaignAuth'
const COOKIE_PATH = '/campanha'
const DEFAULT_TOKEN_EXPIRATION = 7200
export type LoginResult = { error: string }
export const loginCampaign = async (input: CampaignLoginInput): Promise<LoginResult> => {
const parsed = campaignLoginSchema.safeParse(input)
if (!parsed.success) {
return { error: 'Dados inválidos.' }
}
const payload = await getPayload({ config })
let token: string | undefined
try {
const result = await payload.login({
collection: 'campaignUser',
data: parsed.data,
})
token = result.token
} catch {
return { error: 'Email ou senha inválidos.' }
}
if (!token) {
return { error: 'Email ou senha inválidos.' }
}
const tokenExpiration =
payload.collections.campaignUser?.config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION
const cookieStore = await cookies()
cookieStore.set(CAMPAIGN_TOKEN_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: COOKIE_PATH,
maxAge: tokenExpiration,
})
redirect('/campanha')
}
export const logoutCampaign = async (): Promise<void> => {
const cookieStore = await cookies()
cookieStore.set(CAMPAIGN_TOKEN_COOKIE, '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: COOKIE_PATH,
maxAge: 0,
})
redirect('/campanha/login')
}

View file

@ -0,0 +1,97 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTransition } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { loginCampaign } from '@/app/(campanha)/campanha/actions/auth'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { campaignLoginSchema, type CampaignLoginInput } from '@/lib/schemas/campaign-login'
export const LoginForm = () => {
const methods = useForm<CampaignLoginInput>({
resolver: zodResolver(campaignLoginSchema),
defaultValues: {
email: '',
password: '',
},
})
const [isSubmitting, startTransition] = useTransition()
const handleSubmit: SubmitHandler<CampaignLoginInput> = (input) => {
startTransition(async () => {
const result = await loginCampaign(input)
if (result?.error) {
methods.setError('root', { message: result.error })
}
})
}
const {
register,
formState: { errors },
} = methods
return (
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Acessar painel</CardTitle>
<CardDescription>Entre com seu e-mail e senha para continuar.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={methods.handleSubmit(handleSubmit)} noValidate>
<FieldGroup>
<Field data-invalid={Boolean(errors.email)}>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="voce@exemplo.com"
aria-invalid={Boolean(errors.email)}
{...register('email')}
/>
{errors.email?.message ? (
<FieldDescription className="text-destructive">
{errors.email.message}
</FieldDescription>
) : null}
</Field>
<Field data-invalid={Boolean(errors.password)}>
<FieldLabel htmlFor="password">Senha</FieldLabel>
<Input
id="password"
type="password"
autoComplete="current-password"
aria-invalid={Boolean(errors.password)}
{...register('password')}
/>
{errors.password?.message ? (
<FieldDescription className="text-destructive">
{errors.password.message}
</FieldDescription>
) : null}
</Field>
<Field>
{errors.root?.message ? <FieldError>{errors.root.message}</FieldError> : null}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Entrando...' : 'Entrar'}
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,36 @@
import { Megaphone } from 'lucide-react'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { LoginForm } from './LoginForm'
export const metadata: Metadata = {
title: 'Entrar | Campanha',
robots: {
index: false,
follow: false,
},
}
export default async function CampaignLoginPage() {
const user = await getCampaignUser()
if (user) {
redirect('/campanha')
}
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex items-center gap-2 self-center font-semibold">
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Megaphone className="size-4" />
</div>
Campanha
</div>
<LoginForm />
</div>
</main>
)
}

View file

@ -0,0 +1,28 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import React from 'react'
import '../(frontend)/styles.css'
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' })
export const metadata: Metadata = {
title: 'Campanha',
robots: {
index: false,
follow: false,
},
}
export default function CampanhaRootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang="pt-BR"
data-theme="campaign"
className={inter.variable}
style={{ colorScheme: 'light' }}
>
<body className="bg-background bg-none text-foreground antialiased">{children}</body>
</html>
)
}

View file

@ -180,6 +180,39 @@ body,
--field-icon: rgb(44 37 34 / 58%);
}
/* Clean, neutral light palette for the internal /campanha area (login +
management panel). Mirrors the shadcn token contract with a brand-red
primary and defines the --field-* tokens so the custom Input renders with a
visible border on the white card. Applied via data-theme="campaign" on the
(campanha) layout, so it never affects the public (frontend) pages. */
[data-theme='campaign'] {
--background: #ffffff;
--foreground: #1c1917;
--card: #ffffff;
--card-foreground: #1c1917;
--popover: #ffffff;
--popover-foreground: #1c1917;
--primary: #c51414;
--primary-foreground: #ffffff;
--secondary: #f5f5f4;
--secondary-foreground: #292524;
--muted: #f4f4f5;
--muted-foreground: #6b7280;
--accent: #f4f4f5;
--accent-foreground: #1c1917;
--destructive: #b42318;
--border: rgb(28 25 23 / 12%);
--input: rgb(28 25 23 / 16%);
--ring: #c51414;
--field-background: #ffffff;
--field-foreground: #1c1917;
--field-placeholder: rgb(28 25 23 / 50%);
--field-border: #d6d3d1;
--field-disabled-background: #f5f5f4;
--field-disabled-foreground: rgb(28 25 23 / 55%);
--field-icon: rgb(28 25 23 / 55%);
}
.dark {
--background: oklch(0.148 0.004 228.8);
--foreground: oklch(0.987 0.002 197.1);

View file

@ -0,0 +1,22 @@
import type { CollectionConfig } from 'payload'
export const CampaignUser: CollectionConfig = {
slug: 'campaignUser',
labels: {
singular: 'Usuário',
plural: 'Usuários',
},
admin: {
group: 'Campanha',
useAsTitle: 'name',
},
auth: true,
fields: [
{
name: 'name',
type: 'text',
label: 'Nome',
required: true,
},
],
}

View file

@ -0,0 +1,8 @@
import { z } from 'zod'
export const campaignLoginSchema = z.object({
email: z.email('Email inválido'),
password: z.string().min(1, 'Informe a senha'),
})
export type CampaignLoginInput = z.infer<typeof campaignLoginSchema>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,55 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "campaign_user_sessions" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"created_at" timestamp(3) with time zone,
"expires_at" timestamp(3) with time zone NOT NULL
);
CREATE TABLE "campaign_user" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"email" varchar NOT NULL,
"reset_password_token" varchar,
"reset_password_expiration" timestamp(3) with time zone,
"salt" varchar,
"hash" varchar,
"login_attempts" numeric DEFAULT 0,
"lock_until" timestamp(3) with time zone
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "campaign_user_id" integer;
ALTER TABLE "payload_preferences_rels" ADD COLUMN "campaign_user_id" integer;
ALTER TABLE "campaign_user_sessions" ADD CONSTRAINT "campaign_user_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."campaign_user"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "campaign_user_sessions_order_idx" ON "campaign_user_sessions" USING btree ("_order");
CREATE INDEX "campaign_user_sessions_parent_id_idx" ON "campaign_user_sessions" USING btree ("_parent_id");
CREATE INDEX "campaign_user_updated_at_idx" ON "campaign_user" USING btree ("updated_at");
CREATE INDEX "campaign_user_created_at_idx" ON "campaign_user" USING btree ("created_at");
CREATE UNIQUE INDEX "campaign_user_email_idx" ON "campaign_user" USING btree ("email");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_campaign_user_fk" FOREIGN KEY ("campaign_user_id") REFERENCES "public"."campaign_user"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_campaign_user_fk" FOREIGN KEY ("campaign_user_id") REFERENCES "public"."campaign_user"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_campaign_user_id_idx" ON "payload_locked_documents_rels" USING btree ("campaign_user_id");
CREATE INDEX "payload_preferences_rels_campaign_user_id_idx" ON "payload_preferences_rels" USING btree ("campaign_user_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "campaign_user_sessions" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "campaign_user" DISABLE ROW LEVEL SECURITY;
DROP TABLE "campaign_user_sessions" CASCADE;
DROP TABLE "campaign_user" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_campaign_user_fk";
ALTER TABLE "payload_preferences_rels" DROP CONSTRAINT "payload_preferences_rels_campaign_user_fk";
DROP INDEX "payload_locked_documents_rels_campaign_user_id_idx";
DROP INDEX "payload_preferences_rels_campaign_user_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "campaign_user_id";
ALTER TABLE "payload_preferences_rels" DROP COLUMN "campaign_user_id";`)
}

View file

@ -2,6 +2,7 @@ import * as migration_20260715_163458_initial from './20260715_163458_initial';
import * as migration_20260715_163500_consent_text_to_jsonb from './20260715_163500_consent_text_to_jsonb';
import * as migration_20260715_181058_add_post_and_tag from './20260715_181058_add_post_and_tag';
import * as migration_20260715_215834_rename_tag_visible_to_hidden from './20260715_215834_rename_tag_visible_to_hidden';
import * as migration_20260716_010420_add_campaign_user from './20260716_010420_add_campaign_user';
export const migrations = [
{
@ -22,6 +23,11 @@ export const migrations = [
{
up: migration_20260715_215834_rename_tag_visible_to_hidden.up,
down: migration_20260715_215834_rename_tag_visible_to_hidden.down,
name: '20260715_215834_rename_tag_visible_to_hidden'
name: '20260715_215834_rename_tag_visible_to_hidden',
},
{
up: migration_20260716_010420_add_campaign_user.up,
down: migration_20260716_010420_add_campaign_user.down,
name: '20260716_010420_add_campaign_user'
},
];

View file

@ -64,10 +64,12 @@ export type SupportedTimezones =
export interface Config {
auth: {
users: UserAuthOperations;
campaignUser: CampaignUserAuthOperations;
};
blocks: {};
collections: {
users: User;
campaignUser: CampaignUser;
media: Media;
petition: Petition;
contact: Contact;
@ -85,6 +87,7 @@ export interface Config {
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
campaignUser: CampaignUserSelect<false> | CampaignUserSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
petition: PetitionSelect<false> | PetitionSelect<true>;
contact: ContactSelect<false> | ContactSelect<true>;
@ -117,7 +120,7 @@ export interface Config {
widgets: {
collections: CollectionsWidget;
};
user: User;
user: User | CampaignUser;
jobs: {
tasks: {
schedulePublish: TaskSchedulePublish;
@ -147,6 +150,24 @@ export interface UserAuthOperations {
password: string;
};
}
export interface CampaignUserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@ -172,6 +193,32 @@ export interface User {
password?: string | null;
collection: 'users';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignUser".
*/
export interface CampaignUser {
id: number;
name: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'campaignUser';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
@ -492,6 +539,10 @@ export interface PayloadLockedDocument {
relationTo: 'users';
value: number | User;
} | null)
| ({
relationTo: 'campaignUser';
value: number | CampaignUser;
} | null)
| ({
relationTo: 'media';
value: number | Media;
@ -525,10 +576,15 @@ export interface PayloadLockedDocument {
value: number | Tag;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
user:
| {
relationTo: 'users';
value: number | User;
}
| {
relationTo: 'campaignUser';
value: number | CampaignUser;
};
updatedAt: string;
createdAt: string;
}
@ -538,10 +594,15 @@ export interface PayloadLockedDocument {
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | User;
};
user:
| {
relationTo: 'users';
value: number | User;
}
| {
relationTo: 'campaignUser';
value: number | CampaignUser;
};
key?: string | null;
value?:
| {
@ -588,6 +649,29 @@ export interface UsersSelect<T extends boolean = true> {
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignUser_select".
*/
export interface CampaignUserSelect<T extends boolean = true> {
name?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".

View file

@ -7,6 +7,7 @@ import { fileURLToPath } from 'url'
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
import { pt } from 'payload/i18n/pt'
import { CampaignUser } from './collections/CampaignUser'
import { Consent } from './collections/Consent'
import { Contact } from './collections/Contact'
import { Media } from './collections/Media'
@ -34,7 +35,18 @@ export default buildConfig({
globals: [SiteSettings.slug, HomePage.slug],
},
},
collections: [Users, Media, Petition, Contact, Consent, Signature, Subscription, Post, Tag],
collections: [
Users,
CampaignUser,
Media,
Petition,
Contact,
Consent,
Signature,
Subscription,
Post,
Tag,
],
globals: [SiteSettings, HomePage, Metadata],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',

View file

@ -0,0 +1,24 @@
import type { CampaignUser } from '@/payload-types'
import configPromise from '@payload-config'
import { cookies } from 'next/headers'
import { getPayload } from 'payload'
export const CAMPAIGN_TOKEN_COOKIE = 'campaign-token'
export const getCampaignUser = async (): Promise<CampaignUser | null> => {
const cookieStore = await cookies()
const token = cookieStore.get(CAMPAIGN_TOKEN_COOKIE)?.value
if (!token) return null
const payload = await getPayload({ config: configPromise })
const headers = new Headers({ Authorization: `JWT ${token}` })
const { user } = await payload.auth({ headers })
if (user?.collection !== 'campaignUser') return null
return user
}