graph + mail dispatcher, test-email endpoint, notifications global
This commit is contained in:
+3
-1
@@ -109,10 +109,12 @@ export default buildConfig({
|
||||
| blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) |
|
||||
| consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) |
|
||||
| turnstile | Cloudflare Turnstile (widget + verify) | [turnstile.md](./turnstile.md) |
|
||||
| email | SMTP z panelu: adapter Payloada + sendEmail | [email.md](./email.md) |
|
||||
| email | Wysyłka: SMTP z panelu lub Microsoft Graph (M365) | [email.md](./email.md) |
|
||||
| forms | Form-builder + submitForm (Turnstile + zapis) | [forms.md](./forms.md) |
|
||||
| analytics | GA4 / GTM spięte z Consent Mode | [analytics.md](./analytics.md) |
|
||||
| slug | Auto-slug z tytułu, per locale | [slug.md](./slug.md) |
|
||||
| notifications | Teksty wyników akcji (formularz) per język | [notifications.md](./notifications.md) |
|
||||
| security | Nagłówki bezpieczeństwa HTTP (HSTS, X-Frame...) | [security.md](./security.md) |
|
||||
| content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
|
||||
|
||||
Nowy projekt krok po kroku: [getting-started.md](./getting-started.md)
|
||||
|
||||
+80
-4
@@ -1,8 +1,10 @@
|
||||
# email
|
||||
|
||||
Wysyłka maili przez SMTP z SiteIntegrations, w runtime (bez Payload email
|
||||
adaptera). Edytor zmienia SMTP w panelu — następny mail idzie z nowymi
|
||||
ustawieniami, bez restartu.
|
||||
Wysyłka maili z dwoma transportami do wyboru: **SMTP z panelu**
|
||||
(`panelSmtpAdapter`, uniwersalny) albo **Microsoft Graph** (`graphAdapter`,
|
||||
przez Exchange/M365). Oba implementują ten sam interfejs `PayloadEmailAdapter`,
|
||||
więc `payload.sendEmail` i maile z formularzy działają niezależnie od wyboru.
|
||||
Klient/projekt wybiera transport w configu.
|
||||
|
||||
## Zależność
|
||||
|
||||
@@ -73,4 +75,78 @@ resetu hasła i weryfikacji konta. Adapter czyta konfigurację przy każdym
|
||||
wysłaniu, więc zmiana skrzynki w panelu działa bez restartu.
|
||||
|
||||
Bez adaptera Payload używa mocka, który tylko loguje do konsoli — maile
|
||||
form-buildera nie wyjdą.
|
||||
form-buildera nie wyjdą.
|
||||
|
||||
## Maskowanie sekretów w panelu (MaskedField)
|
||||
|
||||
Wrażliwe pola w Site Integrations (smtpPassword, r2SecretAccessKey,
|
||||
turnstileSecretKey) są maskowane w UI — pokazują `••••` zamiast plaintextu, z
|
||||
przyciskiem Reveal/Hide. To maskowanie UI, NIE hashowanie ani szyfrowanie:
|
||||
wartość w bazie jest plaintext (musi być odzyskiwalna do autentykacji SMTP/R2).
|
||||
Chroni przed patrzeniem przez ramię i przypadkowym pokazaniem panelu.
|
||||
|
||||
Podpięte przez `admin.components.Field: '@intecion/ipal-kit/client#MaskedField'`.
|
||||
Działa na dowolnym polu `text`. Po wpięciu w projekcie może być konieczne
|
||||
`payload generate:importmap`, żeby panel rozpoznał komponent.
|
||||
|
||||
> Główną ochroną sekretów pozostaje `read: isAdmin` na globalu SiteIntegrations
|
||||
> (anonim nie dostaje). Maskowanie to warstwa dodatkowa (shoulder-surfing), nie
|
||||
> ochrona bazy — przy wycieku DB sekrety są czytelne.
|
||||
|
||||
|
||||
## Adapter — Microsoft Graph (Exchange / M365)
|
||||
|
||||
Alternatywa dla SMTP: wysyłka przez Microsoft Graph API, przez skrzynkę w
|
||||
Waszym (agencyjnym) tenancie M365. Wszystkie maile z formularzy wszystkich
|
||||
projektów idą przez JEDNĄ skrzynkę nadawczą (np. `[email protected]`).
|
||||
|
||||
### Podział konfiguracji (celowy)
|
||||
|
||||
**Sekrety w `.env`** (agencyjne — Wasz Exchange, klient nie widzi):
|
||||
|
||||
```bash
|
||||
GRAPH_TENANT_ID=...
|
||||
GRAPH_CLIENT_ID=...
|
||||
GRAPH_CLIENT_SECRET=...
|
||||
GRAPH_SENDER=[email protected] # jedna skrzynka dla wszystkich projektów
|
||||
```
|
||||
|
||||
**From-display w panelu** (per projekt): czyta istniejące `smtpFromAddress` /
|
||||
`smtpFromName` z SiteIntegrations — bo „from" to ten sam koncept niezależnie od
|
||||
transportu. Nie trzeba nowego pola.
|
||||
|
||||
### Wpięcie — wybór transportu
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
import { panelSmtpAdapter, graphAdapter } from '@intecion/ipal-kit'
|
||||
|
||||
email: process.env.GRAPH_CLIENT_ID
|
||||
? graphAdapter() // Graph, gdy sekrety w .env
|
||||
: panelSmtpAdapter(), // SMTP z panelu (fallback)
|
||||
```
|
||||
|
||||
### Setup Azure / Exchange (jednorazowo, Wasza strona, POZA kodem)
|
||||
|
||||
1. **App registration** w Azure AD → `tenantId`, `clientId`
|
||||
2. **Client secret** → `clientSecret`
|
||||
3. **API Permissions** → Microsoft Graph → **Application** → `Mail.Send` →
|
||||
**Grant admin consent** (bez tego: `Insufficient privileges`)
|
||||
4. **Exchange Admin Center** → skrzynka `[email protected]` → Mailbox
|
||||
Delegation → aplikacja do **"Send As"** (bez tego: `ErrorAccessDenied`)
|
||||
|
||||
### Szczegóły techniczne
|
||||
|
||||
- Auth: client credentials flow, scope `https://graph.microsoft.com/.default`
|
||||
(NIE `Mail.Send` — Azure odrzuca, AADSTS1002012)
|
||||
- Wysyłka: `POST /users/{sender}/sendMail` (NIE `/me` — app-only nie ma „me")
|
||||
- Sukces: HTTP 202 (pusty body)
|
||||
- Czysty REST (fetch), zero bibliotek Microsoft, zero nowych zależności
|
||||
|
||||
### PUŁAPKA — from vs Send-As
|
||||
|
||||
Jeśli `from` w panelu = cudza domena (np. `[email protected]`), a sender =
|
||||
`[email protected]` — Exchange zablokuje, chyba że aplikacja ma Send-As na tę
|
||||
domenę. Najbezpieczniej: `from` = `GRAPH_SENDER` (Wasza skrzynka), a adres
|
||||
klienta w `replyTo` (odpowiedzi trafią do klienta). Wtedy Send-As na cudze
|
||||
domeny nie jest potrzebny.
|
||||
+56
-1
@@ -146,6 +146,7 @@ type SubmitFormResult =
|
||||
| { success: false; reason: 'turnstile' }
|
||||
| { success: false; reason: 'validation'; field?: string; kind?: 'required' | 'too_long' | 'unknown_fields' }
|
||||
| { success: false; reason: 'not_found' }
|
||||
| { success: false; reason: 'consent'; field?: string }
|
||||
| { success: false; reason: 'error' }
|
||||
```
|
||||
|
||||
@@ -162,4 +163,58 @@ function errorMessage(r) {
|
||||
}
|
||||
```
|
||||
|
||||
Ten sam wzorzec co consent: plugin nie zaszywa języka, oddaje dane.
|
||||
Ten sam wzorzec co consent: plugin nie zaszywa języka, oddaje dane.
|
||||
|
||||
|
||||
## Zgoda RODO (consent field)
|
||||
|
||||
Pole zgody RODO to checkbox o nazwie `consent` (konfigurowalna przez
|
||||
`FormsOption.consentFieldName`). Plugin WYMUSZA jego zaznaczenie SERVER-SIDE —
|
||||
niezależnie od tego, jak redaktor ustawił pole w panelu.
|
||||
|
||||
### Dlaczego server-side
|
||||
|
||||
Zgoda egzekwowana jest w `validateSubmission`, nie flagą w panelu. To jedyne
|
||||
miejsce, którego redaktor nie osłabi (zapominając `required`) ani nie naruszy
|
||||
(ustawiając `defaultValue: true` — pre-zaznaczenie, którego RODO zabrania), a
|
||||
front nie obejdzie. Jeśli formularz ma pole `consent`, MUSI być zaznaczone,
|
||||
inaczej `submitForm` zwraca `reason: 'consent'`.
|
||||
|
||||
### Jak użyć
|
||||
|
||||
1. Redaktor dodaje w panelu checkbox o nazwie `consent`, label „Akceptuję
|
||||
politykę prywatności [link]" (link do polityki wpisuje w label — treść zgody
|
||||
należy do panelu).
|
||||
2. Plugin wymusza zaznaczenie. Niezaznaczony → `reason: 'consent'`.
|
||||
3. Komunikat z modułu notifications (`notifications.form.consent`, per język).
|
||||
|
||||
Zmiana nazwy pola:
|
||||
|
||||
```ts
|
||||
ipalKit({ forms: { consentFieldName: 'zgoda' } })
|
||||
```
|
||||
|
||||
## Komunikaty — resolveFormMessage (zalecane)
|
||||
|
||||
Zamiast ręcznego switcha po `reason`, użyj `resolveFormMessage` z modułu
|
||||
notifications — mapuje kod na tekst z panelu, per język, z interpolacją `{field}`:
|
||||
|
||||
```tsx
|
||||
import { resolveFormMessage, getNotificationTexts } from '@intecion/ipal-kit'
|
||||
|
||||
const notifications = await getNotificationTexts({ payload, locale })
|
||||
// w FormRenderer:
|
||||
if (!result.success) {
|
||||
setError(resolveFormMessage(result, notifications.form))
|
||||
}
|
||||
```
|
||||
|
||||
To obsługuje WSZYSTKIE kody (w tym `consent`, `rate_limited`, `validation` z
|
||||
`{field}`) tekstami z panelu. Ręczny switch (wyżej) zostaw tylko, jeśli nie
|
||||
używasz modułu notifications. Szczegóły: [notifications.md](./notifications.md).
|
||||
|
||||
## PUŁAPKA — pola captchy
|
||||
|
||||
Turnstile wstrzykuje ukryte pole `cf-turnstile-response`. Plugin je toleruje
|
||||
(nie odrzuca jako `unknown_fields`) — bo sam obsługuje Turnstile. Nie musisz go
|
||||
filtrować w kliencie.
|
||||
@@ -0,0 +1,69 @@
|
||||
# notifications
|
||||
|
||||
Teksty powiadomień (wyniki akcji) konfigurowane w panelu, per język, z
|
||||
fallbackiem. Na dziś obsługuje komunikaty wyników formularza (`submitForm`),
|
||||
z miejscem na przyszłe konteksty. Global **Notifications**, budowany zawsze.
|
||||
|
||||
## Zasada
|
||||
|
||||
Plugin daje KOD wyniku (`submitForm` zwraca `reason`), nie tekst. Ten moduł
|
||||
mapuje kod → tekst z panelu (localized), z fallbackiem angielskim per pole.
|
||||
Front dostaje gotowy string i styluje go jak chce (toast, inline, banner).
|
||||
Dzięki temu żaden komunikat nie jest zaszyty w kodzie — wszystko przez panel.
|
||||
|
||||
## Config
|
||||
|
||||
Brak opcji — global **Notifications** jest zawsze budowany. Edytor zarządza
|
||||
tekstami w panelu (karta Notifications), grupowane per kontekst. Grupa `form`:
|
||||
`success`, `error`, `rateLimited`, `turnstile`, `validation`, `consent`,
|
||||
`notFound`. Każde pole puste → fallback (NOTIFICATION_FALLBACK).
|
||||
|
||||
## Helper — getNotificationTexts
|
||||
|
||||
Pobiera teksty z globala per język, fallback per pole. Analog `getConsentTexts`:
|
||||
|
||||
```ts
|
||||
import { getNotificationTexts } from '@intecion/ipal-kit'
|
||||
|
||||
const notifications = await getNotificationTexts({ payload, locale })
|
||||
// notifications.form.error, notifications.form.success, ...
|
||||
```
|
||||
|
||||
## Mapowanie wyniku — resolveFormMessage
|
||||
|
||||
Most między `submitForm` a UI: bierze wynik i teksty, zwraca jeden komunikat.
|
||||
Interpoluje `{field}` w walidacji. NIGDY nie pokazuje surowego wyjątku
|
||||
(`error` → generyczny tekst, nie treść błędu backendu).
|
||||
|
||||
```ts
|
||||
import { resolveFormMessage } from '@intecion/ipal-kit'
|
||||
|
||||
const result = await submitFormAction(...)
|
||||
if (!result.success) {
|
||||
setError(resolveFormMessage(result, notifications.form))
|
||||
}
|
||||
```
|
||||
|
||||
To zastępuje sztywne `Błąd: ${result.reason}` — teraz przyjazny tekst z panelu,
|
||||
per język.
|
||||
|
||||
## Interpolacja {field}
|
||||
|
||||
Tekst `validation` może zawierać `{field}` — podstawia się nazwa pola z błędem:
|
||||
|
||||
```
|
||||
Panel: "Sprawdź pole {field} i spróbuj ponownie."
|
||||
Wynik: "Sprawdź pole email i spróbuj ponownie."
|
||||
```
|
||||
|
||||
## Rozszerzanie o nowe konteksty
|
||||
|
||||
Grupa `form` to pierwszy kontekst. Kolejne (`newsletter`, `system`) dodaje się
|
||||
tak samo — nowa grupa w `globals/Notifications/fields.ts` + pole w typach +
|
||||
fallback. `getNotificationTexts` resolwuje, co istnieje.
|
||||
|
||||
## Dostęp
|
||||
|
||||
Global ma `read: () => true` — teksty są publiczne (pokazywane użytkownikom
|
||||
końcowym), więc front czyta je bez sesji. Inaczej niż SiteIntegrations
|
||||
(`read: isAdmin` — tam sekrety).
|
||||
@@ -0,0 +1,64 @@
|
||||
# security
|
||||
|
||||
Generyczne nagłówki bezpieczeństwa HTTP (HSTS, X-Frame-Options, nosniff,
|
||||
Referrer-Policy, Permissions-Policy) jako funkcja do `next.config`. CSP CELOWO
|
||||
pominięte — zależy od domen projektu, zostaje w projekcie.
|
||||
|
||||
## Zasada
|
||||
|
||||
Nagłówki, które są IDENTYCZNE między projektami, plugin dostarcza raz. CSP
|
||||
(Content-Security-Policy) wymaga znajomości domen konkretnego projektu (skąd
|
||||
ładują się skrypty, obrazy, fonty, analytics), więc nie może być generyczne —
|
||||
zostaje w projekcie, dodawane przez `additional`.
|
||||
|
||||
## Użycie — next.config.ts
|
||||
|
||||
Nagłówki wpina się w `next.config`, NIE w proxy — bo muszą pokryć CAŁĄ
|
||||
aplikację (też `/admin`, statyki), a proxy pomija te trasy.
|
||||
|
||||
```ts
|
||||
// next.config.ts
|
||||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
import type { NextConfig } from 'next'
|
||||
import { buildSecurityHeaders } from '@intecion/ipal-kit'
|
||||
|
||||
const securityHeaders = buildSecurityHeaders({
|
||||
hsts: process.env.NODE_ENV === 'production', // WAŻNE: off w dev (http)
|
||||
additional: [
|
||||
// CSP projektu — zna swoje domeny:
|
||||
// { key: 'Content-Security-Policy', value: "default-src 'self'; ..." },
|
||||
],
|
||||
})
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
async headers() {
|
||||
return [{ source: '/:path*', headers: securityHeaders }]
|
||||
},
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig)
|
||||
```
|
||||
|
||||
## Opcje
|
||||
|
||||
| Opcja | Domyślnie | Rola |
|
||||
|---|---|---|
|
||||
| `hsts` | `true` | Strict-Transport-Security (wymuś HTTPS) |
|
||||
| `hstsMaxAge` | `63072000` (2 lata) | max-age HSTS w sekundach |
|
||||
| `hstsIncludeSubDomains` | `true` | HSTS na subdomeny |
|
||||
| `hstsPreload` | `false` | preload (tylko jeśli zgłaszasz do listy) |
|
||||
| `frameOptions` | `'DENY'` | X-Frame-Options (anty-clickjacking) |
|
||||
| `referrerPolicy` | `'strict-origin-when-cross-origin'` | Referrer-Policy |
|
||||
| `permissionsPolicy` | blokuje camera/mic/geolocation | Permissions-Policy |
|
||||
| `additional` | `[]` | dodatkowe nagłówki (np. CSP); same-key nadpisuje |
|
||||
|
||||
## PUŁAPKA — HSTS w dev
|
||||
|
||||
HSTS nad HTTP na localhost może zablokować przeglądarkę na HTTPS dla localhost.
|
||||
ZAWSZE wyłączaj w dev: `hsts: process.env.NODE_ENV === 'production'`.
|
||||
|
||||
## Nadpisywanie i CSP
|
||||
|
||||
`additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options
|
||||
na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` —
|
||||
plugin go nie generuje, bo zależy od projektu.
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
Pole slug generowane automatycznie z tytułu, per locale.
|
||||
|
||||
> **Plugin JUŻ to ma — nie pisz własnego auto-sluga.** Częsty błąd: projekt
|
||||
> dodaje ręczne pole `{ name: 'slug', type: 'text' }` i każe redaktorowi wpisywać
|
||||
> slug, albo pisze własny hook normalizujący. Nie trzeba — `buildSlugField`
|
||||
> robi to lepiej: auto-generacja gdy puste, nie nadpisuje ręcznego, per-locale,
|
||||
> diakrytyki PL→ASCII. Jeśli w kolekcji masz ręczny slug, ZAMIEŃ go na
|
||||
> `buildSlugField({ from: 'title' })`.
|
||||
|
||||
## Użycie (kolekcja klienta)
|
||||
|
||||
```ts
|
||||
|
||||
Reference in New Issue
Block a user