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:
parent
f15a2cf1c1
commit
c4866fabc0
16 changed files with 4977 additions and 15 deletions
19
AGENTS.md
19
AGENTS.md
|
|
@ -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/*)
|
||||
|
|
|
|||
36
src/app/(campanha)/campanha/(app)/layout.tsx
Normal file
36
src/app/(campanha)/campanha/(app)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
src/app/(campanha)/campanha/(app)/page.tsx
Normal file
10
src/app/(campanha)/campanha/(app)/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
src/app/(campanha)/campanha/actions/auth.ts
Normal file
70
src/app/(campanha)/campanha/actions/auth.ts
Normal 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')
|
||||
}
|
||||
97
src/app/(campanha)/campanha/login/LoginForm.tsx
Normal file
97
src/app/(campanha)/campanha/login/LoginForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/app/(campanha)/campanha/login/page.tsx
Normal file
36
src/app/(campanha)/campanha/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/app/(campanha)/layout.tsx
Normal file
28
src/app/(campanha)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
22
src/collections/CampaignUser.ts
Normal file
22
src/collections/CampaignUser.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
8
src/lib/schemas/campaign-login.ts
Normal file
8
src/lib/schemas/campaign-login.ts
Normal 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>
|
||||
4430
src/migrations/20260716_010420_add_campaign_user.json
Normal file
4430
src/migrations/20260716_010420_add_campaign_user.json
Normal file
File diff suppressed because it is too large
Load diff
55
src/migrations/20260716_010420_add_campaign_user.ts
Normal file
55
src/migrations/20260716_010420_add_campaign_user.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
24
src/utilities/campaignAuth.ts
Normal file
24
src/utilities/campaignAuth.ts
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue