From 79ddcd23f590fde70908147b58b9a71cdb1a1f1e Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 00:13:49 +0000 Subject: [PATCH 01/47] =?UTF-8?q?feat(admin):=20Sprint=205=20=E2=80=94=20A?= =?UTF-8?q?udit=20log=20+=20Settings=20(gouvernance)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 22 +++ prisma/schema.prisma | 22 +++ src/app/admin/audit/page.tsx | 134 ++++++++++++++ src/app/admin/bookings/actions.ts | 5 +- src/app/admin/carbets/actions.ts | 23 +-- src/app/admin/organizations/actions.ts | 5 +- src/app/admin/pirogue-providers/actions.ts | 5 +- src/app/admin/reviews/actions.ts | 5 +- .../settings/_components/SettingsForms.tsx | 171 ++++++++++++++++++ src/app/admin/settings/actions.ts | 89 +++++++++ src/app/admin/settings/page.tsx | 100 ++++++++++ src/app/admin/users/actions.ts | 5 +- src/lib/admin/audit.ts | 91 ++++++++++ src/lib/admin/settings.ts | 120 ++++++++++++ 14 files changed, 773 insertions(+), 24 deletions(-) create mode 100644 prisma/migrations/20260601000000_audit_and_settings/migration.sql create mode 100644 src/app/admin/audit/page.tsx create mode 100644 src/app/admin/settings/_components/SettingsForms.tsx create mode 100644 src/app/admin/settings/actions.ts create mode 100644 src/app/admin/settings/page.tsx create mode 100644 src/lib/admin/audit.ts create mode 100644 src/lib/admin/settings.ts diff --git a/prisma/migrations/20260601000000_audit_and_settings/migration.sql b/prisma/migrations/20260601000000_audit_and_settings/migration.sql new file mode 100644 index 0000000..15779de --- /dev/null +++ b/prisma/migrations/20260601000000_audit_and_settings/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "event" TEXT NOT NULL, + "target" TEXT, + "actorEmail" TEXT, + "details" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "AuditLog_scope_idx" ON "AuditLog"("scope"); +CREATE INDEX "AuditLog_event_idx" ON "AuditLog"("event"); +CREATE INDEX "AuditLog_actorEmail_idx" ON "AuditLog"("actorEmail"); +CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt"); + +CREATE TABLE "Setting" ( + "key" TEXT NOT NULL, + "value" JSONB NOT NULL DEFAULT '{}', + "updatedAt" TIMESTAMP(3) NOT NULL, + "updatedBy" TEXT, + CONSTRAINT "Setting_pkey" PRIMARY KEY ("key") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 63a3b1c..d0cc722 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -326,3 +326,25 @@ model ContentPage { @@index([category]) @@index([published]) } + +model AuditLog { + id String @id @default(cuid()) + scope String + event String + target String? + actorEmail String? + details Json @default("{}") + createdAt DateTime @default(now()) + + @@index([scope]) + @@index([event]) + @@index([actorEmail]) + @@index([createdAt]) +} + +model Setting { + key String @id + value Json @default("{}") + updatedAt DateTime @updatedAt + updatedBy String? +} diff --git a/src/app/admin/audit/page.tsx b/src/app/admin/audit/page.tsx new file mode 100644 index 0000000..52cc4fd --- /dev/null +++ b/src/app/admin/audit/page.tsx @@ -0,0 +1,134 @@ +import Link from "next/link"; +import { listAuditAdmin, listAuditScopes } from "@/lib/admin/audit"; + +export const dynamic = "force-dynamic"; + +type PageProps = { + searchParams: Promise<{ + q?: string; + scope?: string; + actor?: string; + from?: string; + to?: string; + }>; +}; + +function parseDate(v?: string): Date | undefined { + if (!v) return undefined; + const d = new Date(v); + return isNaN(d.getTime()) ? undefined : d; +} + +export default async function AuditAdminPage({ searchParams }: PageProps) { + const sp = await searchParams; + const filters = { + q: sp.q?.trim() || undefined, + scope: sp.scope?.trim() || undefined, + actor: sp.actor?.trim() || undefined, + from: parseDate(sp.from), + to: parseDate(sp.to), + }; + const [rows, scopes] = await Promise.all([listAuditAdmin(filters), listAuditScopes()]); + const dateTimeFmt = new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", month: "short", year: "2-digit", hour: "2-digit", minute: "2-digit", + }); + + return ( +
+
+
+

Audit log

+

+ {rows.length} entrée{rows.length > 1 ? "s" : ""} + {rows.length === 300 ? " (limite atteinte — affinez les filtres)" : ""} +

+
+
+ +
+ + + + + + + {(filters.q || filters.scope || filters.actor || filters.from || filters.to) ? ( + + Réinit. + + ) : null} +
+ +
+ + + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : null} + {rows.map((r) => ( + + + + + + + + + ))} + +
QuandScopeÉvénementCibleActeurDétails
+ Aucune entrée d'audit ne correspond aux filtres. +
+ {dateTimeFmt.format(r.createdAt)} + {r.scope}{r.event} + {r.target ? r.target.slice(0, 24) + (r.target.length > 24 ? "…" : "") : "—"} + {r.actorEmail ?? "—"} + {r.details && typeof r.details === "object" && Object.keys(r.details as object).length > 0 + ? JSON.stringify(r.details) + : "—"} +
+
+
+ ); +} diff --git a/src/app/admin/bookings/actions.ts b/src/app/admin/bookings/actions.ts index 79c4a4a..a8726d4 100644 --- a/src/app/admin/bookings/actions.ts +++ b/src/app/admin/bookings/actions.ts @@ -5,9 +5,10 @@ import { auth } from "@/auth"; import { BookingStatus, PaymentStatus, UserRole } from "@/generated/prisma/enums"; import { requireRole } from "@/lib/authorization"; import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; -async function audit(event: string, target: string, actor: string | null, details: unknown) { - console.log(JSON.stringify({ scope: "admin.bookings", event, target, actor, details, at: new Date().toISOString() })); +async function audit(event: string, target: string, actor: string | null, details: Record) { + await recordAudit({ scope: "admin.bookings", event, target, actorEmail: actor, details }); } const ALLOWED_STATUS = new Set([ diff --git a/src/app/admin/carbets/actions.ts b/src/app/admin/carbets/actions.ts index eb1d84c..131fd02 100644 --- a/src/app/admin/carbets/actions.ts +++ b/src/app/admin/carbets/actions.ts @@ -5,6 +5,7 @@ import { redirect } from "next/navigation"; import { z } from "zod"; import { auth } from "@/auth"; import { requireRole } from "@/lib/authorization"; +import { recordAudit } from "@/lib/admin/audit"; import { prisma } from "@/lib/prisma"; import { AccessType, @@ -197,23 +198,17 @@ export async function reorderMediaAction(carbetId: string, mediaId: string, dire return { ok: true as const }; } -/** - * Audit léger : log dans la console (Sprint 5 ajoutera une table AuditLog). - * Pour l'instant on a au moins une trace dans les logs du container. - */ async function audit( - action: string, + event: string, entityId: string, actor: string | null, payload: Record, ) { - console.log( - JSON.stringify({ - audit: action, - actor, - entityId, - payload, - at: new Date().toISOString(), - }), - ); + await recordAudit({ + scope: "admin.carbets", + event, + target: entityId, + actorEmail: actor, + details: payload, + }); } diff --git a/src/app/admin/organizations/actions.ts b/src/app/admin/organizations/actions.ts index b7e2daf..5f8bcf6 100644 --- a/src/app/admin/organizations/actions.ts +++ b/src/app/admin/organizations/actions.ts @@ -7,9 +7,10 @@ import { auth } from "@/auth"; import { UserRole } from "@/generated/prisma/enums"; import { requireRole } from "@/lib/authorization"; import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; -async function audit(event: string, target: string, actor: string | null, details: unknown) { - console.log(JSON.stringify({ scope: "admin.organizations", event, target, actor, details, at: new Date().toISOString() })); +async function audit(event: string, target: string, actor: string | null, details: Record) { + await recordAudit({ scope: "admin.organizations", event, target, actorEmail: actor, details }); } const slugRe = /^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/; diff --git a/src/app/admin/pirogue-providers/actions.ts b/src/app/admin/pirogue-providers/actions.ts index 2b74779..5111fd8 100644 --- a/src/app/admin/pirogue-providers/actions.ts +++ b/src/app/admin/pirogue-providers/actions.ts @@ -7,9 +7,10 @@ import { auth } from "@/auth"; import { UserRole } from "@/generated/prisma/enums"; import { requireRole } from "@/lib/authorization"; import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; -async function audit(event: string, target: string, actor: string | null, details: unknown) { - console.log(JSON.stringify({ scope: "admin.pirogue", event, target, actor, details, at: new Date().toISOString() })); +async function audit(event: string, target: string, actor: string | null, details: Record) { + await recordAudit({ scope: "admin.pirogue", event, target, actorEmail: actor, details }); } const providerSchema = z.object({ diff --git a/src/app/admin/reviews/actions.ts b/src/app/admin/reviews/actions.ts index 6182104..6cb5eec 100644 --- a/src/app/admin/reviews/actions.ts +++ b/src/app/admin/reviews/actions.ts @@ -6,9 +6,10 @@ import { auth } from "@/auth"; import { UserRole } from "@/generated/prisma/enums"; import { requireRole } from "@/lib/authorization"; import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; -async function audit(event: string, target: string, actor: string | null, details: unknown) { - console.log(JSON.stringify({ scope: "admin.reviews", event, target, actor, details, at: new Date().toISOString() })); +async function audit(event: string, target: string, actor: string | null, details: Record) { + await recordAudit({ scope: "admin.reviews", event, target, actorEmail: actor, details }); } const updateSchema = z.object({ diff --git a/src/app/admin/settings/_components/SettingsForms.tsx b/src/app/admin/settings/_components/SettingsForms.tsx new file mode 100644 index 0000000..ca13106 --- /dev/null +++ b/src/app/admin/settings/_components/SettingsForms.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { FormField, inputCls, selectCls } from "@/components/admin/FormField"; +import { + savePlatformSettingsAction, + saveStripeSettingsAction, + saveThemeSettingsAction, +} from "../actions"; + +type Action = (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>; + +function FormWrapper({ + action, + children, + submitLabel = "Enregistrer", +}: { + action: Action; + children: React.ReactNode; + submitLabel?: string; +}) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + function onSubmit(fd: FormData) { + setError(null); + setSuccess(null); + startTransition(async () => { + const res = await action(fd); + if (res && res.ok === false) setError(res.error); + else if (res && res.ok === true) setSuccess("Enregistré."); + }); + } + + return ( +
+
+ {children} + {error ? ( +
{error}
+ ) : null} + {success ? ( +
{success}
+ ) : null} +
+ +
+
+
+ ); +} + +export function PlatformForm({ + initial, +}: { + initial: { name: string; defaultLang: string; activeLangs: string[]; currency: string; commissionPercent: number }; +}) { + return ( + +
+ + + + + + + + + + + + + + + +
+
+ ); +} + +export function ThemeForm({ initial }: { initial: { active: string } }) { + return ( + + + + + + ); +} + +export function StripeForm({ + initial, +}: { + initial: { currency: string; commissionMode: string; perBookingFeePercent: number }; +}) { + return ( + +
+ + + + + + + + + +
+
+ ); +} diff --git a/src/app/admin/settings/actions.ts b/src/app/admin/settings/actions.ts new file mode 100644 index 0000000..c112f4a --- /dev/null +++ b/src/app/admin/settings/actions.ts @@ -0,0 +1,89 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; +import { requireRole } from "@/lib/authorization"; +import { recordAudit } from "@/lib/admin/audit"; +import { setSetting } from "@/lib/admin/settings"; + +const platformSchema = z.object({ + name: z.string().trim().min(2).max(80), + defaultLang: z.string().trim().length(2), + activeLangs: z.array(z.string().trim().length(2)).min(1).max(10), + currency: z.string().trim().length(3), + commissionPercent: z.coerce.number().min(0).max(100), +}); + +const themeSchema = z.object({ + active: z.enum(["default", "theme-aquarelle", "theme-guyane"]), +}); + +const stripeSchema = z.object({ + currency: z.string().trim().length(3), + commissionMode: z.enum(["none", "owner-subscription", "per-booking"]), + perBookingFeePercent: z.coerce.number().min(0).max(100), +}); + +async function actor() { + const session = await auth(); + return session?.user?.email ?? null; +} + +export async function savePlatformSettingsAction(fd: FormData) { + await requireRole([UserRole.ADMIN]); + const langsRaw = (fd.get("activeLangs") as string | null) ?? ""; + const activeLangs = langsRaw + .split(/[,;\s]+/) + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length === 2); + const parsed = platformSchema.safeParse({ + name: fd.get("name"), + defaultLang: ((fd.get("defaultLang") as string | null) ?? "").toLowerCase(), + activeLangs, + currency: ((fd.get("currency") as string | null) ?? "").toUpperCase(), + commissionPercent: fd.get("commissionPercent"), + }); + if (!parsed.success) { + return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") }; + } + if (!parsed.data.activeLangs.includes(parsed.data.defaultLang)) { + return { ok: false as const, error: "La langue par défaut doit faire partie des langues actives." }; + } + const who = await actor(); + await setSetting("platform", parsed.data, who); + await recordAudit({ scope: "admin.settings", event: "platform.update", actorEmail: who, details: parsed.data }); + revalidatePath("/admin/settings"); + return { ok: true as const }; +} + +export async function saveThemeSettingsAction(fd: FormData) { + await requireRole([UserRole.ADMIN]); + const parsed = themeSchema.safeParse({ active: fd.get("active") }); + if (!parsed.success) { + return { ok: false as const, error: "Thème invalide." }; + } + const who = await actor(); + await setSetting("theme", parsed.data, who); + await recordAudit({ scope: "admin.settings", event: "theme.update", actorEmail: who, details: parsed.data }); + revalidatePath("/admin/settings"); + return { ok: true as const }; +} + +export async function saveStripeSettingsAction(fd: FormData) { + await requireRole([UserRole.ADMIN]); + const parsed = stripeSchema.safeParse({ + currency: ((fd.get("currency") as string | null) ?? "").toUpperCase(), + commissionMode: fd.get("commissionMode"), + perBookingFeePercent: fd.get("perBookingFeePercent"), + }); + if (!parsed.success) { + return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") }; + } + const who = await actor(); + await setSetting("stripe", parsed.data, who); + await recordAudit({ scope: "admin.settings", event: "stripe.update", actorEmail: who, details: parsed.data }); + revalidatePath("/admin/settings"); + return { ok: true as const }; +} diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx new file mode 100644 index 0000000..5c2ad14 --- /dev/null +++ b/src/app/admin/settings/page.tsx @@ -0,0 +1,100 @@ +import { getAllSettings, readEnvSnapshot } from "@/lib/admin/settings"; +import { PlatformForm, StripeForm, ThemeForm } from "./_components/SettingsForms"; + +export const dynamic = "force-dynamic"; + +function Badge({ ok, labelOk = "Configuré", labelKo = "Non configuré" }: { ok: boolean; labelOk?: string; labelKo?: string }) { + return ( + + {ok ? labelOk : labelKo} + + ); +} + +function Row({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export default async function SettingsAdminPage() { + const [settings, env] = await Promise.all([getAllSettings(), Promise.resolve(readEnvSnapshot())]); + + return ( +
+
+

Paramètres

+

+ Configuration plateforme persistée en base + snapshot des variables d'environnement (lecture seule). +

+
+ +
+

Plateforme

+ +
+ +
+

Thème site public

+ +
+ +
+

Monétisation Stripe

+ +
+

+ Variables d'environnement Stripe (lecture seule) +

+
+ } /> + } /> + } /> + } /> +
+

+ Les clés et secrets restent dans les variables d'environnement du container. Modifications via le déploiement. +

+
+
+ +
+

Stockage médias (S3 / MinIO)

+
+ {env.s3.endpoint ?? "—"}} /> + {env.s3.region ?? "—"}} /> + {env.s3.bucket ?? "—"}} /> + + {env.s3.publicUrl} + + ) : "—" + } + /> + } /> + } /> +
+
+ +
+

Déploiement

+
+ {env.app.publicUrl ?? "—"}} /> + {env.app.authUrl ?? "—"}} /> + {env.app.deploymentVersion ?? "—"}} /> +
+
+
+ ); +} diff --git a/src/app/admin/users/actions.ts b/src/app/admin/users/actions.ts index a4138e3..f44718b 100644 --- a/src/app/admin/users/actions.ts +++ b/src/app/admin/users/actions.ts @@ -5,6 +5,7 @@ import { auth } from "@/auth"; import { UserRole } from "@/generated/prisma/enums"; import { requireRole } from "@/lib/authorization"; import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; const ROLE_VALUES = new Set([ UserRole.OWNER, @@ -14,8 +15,8 @@ const ROLE_VALUES = new Set([ UserRole.ADMIN, ]); -async function audit(event: string, target: string, actor: string | null, details: unknown) { - console.log(JSON.stringify({ scope: "admin.users", event, target, actor, details, at: new Date().toISOString() })); +async function audit(event: string, target: string, actor: string | null, details: Record) { + await recordAudit({ scope: "admin.users", event, target, actorEmail: actor, details }); } export async function updateUserRoleAction(id: string, role: string) { diff --git a/src/lib/admin/audit.ts b/src/lib/admin/audit.ts new file mode 100644 index 0000000..c39f46e --- /dev/null +++ b/src/lib/admin/audit.ts @@ -0,0 +1,91 @@ +import "server-only"; + +import { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/prisma"; + +export type AuditEntry = { + scope: string; + event: string; + target?: string | null; + actorEmail?: string | null; + details?: Record | null; +}; + +export async function recordAudit(entry: AuditEntry): Promise { + const safeDetails = (entry.details ?? {}) as Prisma.InputJsonValue; + try { + await prisma.auditLog.create({ + data: { + scope: entry.scope, + event: entry.event, + target: entry.target ?? null, + actorEmail: entry.actorEmail ?? null, + details: safeDetails, + }, + }); + } catch (e) { + console.error( + JSON.stringify({ + warn: "audit.persist.failed", + scope: entry.scope, + event: entry.event, + target: entry.target ?? null, + actorEmail: entry.actorEmail ?? null, + details: entry.details ?? {}, + error: e instanceof Error ? e.message : String(e), + }), + ); + } +} + +export type AuditFilters = { + q?: string; + scope?: string; + event?: string; + actor?: string; + from?: Date; + to?: Date; +}; + +export type AuditListItem = { + id: string; + scope: string; + event: string; + target: string | null; + actorEmail: string | null; + details: unknown; + createdAt: Date; +}; + +export async function listAuditAdmin(filters: AuditFilters = {}): Promise { + const where: Prisma.AuditLogWhereInput = {}; + if (filters.q) { + where.OR = [ + { event: { contains: filters.q, mode: "insensitive" } }, + { target: { contains: filters.q, mode: "insensitive" } }, + { actorEmail: { contains: filters.q, mode: "insensitive" } }, + ]; + } + if (filters.scope) where.scope = filters.scope; + if (filters.event) where.event = filters.event; + if (filters.actor) where.actorEmail = filters.actor; + if (filters.from || filters.to) { + where.createdAt = {}; + if (filters.from) where.createdAt.gte = filters.from; + if (filters.to) where.createdAt.lte = filters.to; + } + return prisma.auditLog.findMany({ + where, + orderBy: { createdAt: "desc" }, + take: 300, + }); +} + +export async function listAuditScopes(): Promise { + const rows = await prisma.auditLog.findMany({ + distinct: ["scope"], + select: { scope: true }, + orderBy: { scope: "asc" }, + }); + return rows.map((r) => r.scope); +} diff --git a/src/lib/admin/settings.ts b/src/lib/admin/settings.ts new file mode 100644 index 0000000..e4339cf --- /dev/null +++ b/src/lib/admin/settings.ts @@ -0,0 +1,120 @@ +import "server-only"; + +import { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/prisma"; + +export type PlatformSettings = { + name: string; + defaultLang: string; + activeLangs: string[]; + currency: string; + commissionPercent: number; +}; + +export type ThemeSettings = { + active: "default" | "theme-aquarelle" | "theme-guyane"; +}; + +export type StripeSettings = { + currency: string; + commissionMode: "none" | "owner-subscription" | "per-booking"; + perBookingFeePercent: number; +}; + +export type AllSettings = { + platform: PlatformSettings; + theme: ThemeSettings; + stripe: StripeSettings; +}; + +export const DEFAULTS: AllSettings = { + platform: { + name: "Karbé", + defaultLang: "fr", + activeLangs: ["fr"], + currency: "EUR", + commissionPercent: 0, + }, + theme: { + active: "default", + }, + stripe: { + currency: "EUR", + commissionMode: "owner-subscription", + perBookingFeePercent: 0, + }, +}; + +const KEYS = ["platform", "theme", "stripe"] as const; +type SettingKey = (typeof KEYS)[number]; + +export async function getAllSettings(): Promise { + const rows = await prisma.setting.findMany({ where: { key: { in: [...KEYS] } } }); + const map = new Map(rows.map((r) => [r.key as SettingKey, r.value])); + return { + platform: { ...DEFAULTS.platform, ...((map.get("platform") as Partial) ?? {}) }, + theme: { ...DEFAULTS.theme, ...((map.get("theme") as Partial) ?? {}) }, + stripe: { ...DEFAULTS.stripe, ...((map.get("stripe") as Partial) ?? {}) }, + }; +} + +export async function setSetting( + key: SettingKey, + value: Record, + updatedBy: string | null, +): Promise { + await prisma.setting.upsert({ + where: { key }, + create: { key, value: value as Prisma.InputJsonValue, updatedBy: updatedBy ?? null }, + update: { value: value as Prisma.InputJsonValue, updatedBy: updatedBy ?? null }, + }); +} + +export type EnvSnapshot = { + stripe: { + secretKeyConfigured: boolean; + publishableKeyConfigured: boolean; + webhookSecretConfigured: boolean; + ownerPriceIdConfigured: boolean; + }; + s3: { + endpoint: string | null; + region: string | null; + bucket: string | null; + publicUrl: string | null; + pathStyle: boolean; + rootUserConfigured: boolean; + }; + app: { + publicUrl: string | null; + deploymentVersion: string | null; + authUrl: string | null; + }; +}; + +export function readEnvSnapshot(): EnvSnapshot { + const has = (k: string) => Boolean((process.env[k] ?? "").trim()); + return { + stripe: { + secretKeyConfigured: has("STRIPE_SECRET_KEY"), + publishableKeyConfigured: has("STRIPE_PUBLISHABLE_KEY") || has("NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY"), + webhookSecretConfigured: has("STRIPE_WEBHOOK_SECRET"), + ownerPriceIdConfigured: + has("STRIPE_OWNER_SUBSCRIPTION_PRICE_ID") && + !(process.env.STRIPE_OWNER_SUBSCRIPTION_PRICE_ID ?? "").includes("REPLACE_ME"), + }, + s3: { + endpoint: process.env.S3_ENDPOINT ?? null, + region: process.env.S3_REGION ?? null, + bucket: process.env.S3_BUCKET ?? null, + publicUrl: process.env.S3_PUBLIC_URL ?? null, + pathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true", + rootUserConfigured: has("MINIO_ROOT_USER"), + }, + app: { + publicUrl: process.env.NEXT_PUBLIC_SITE_URL ?? process.env.APP_URL ?? null, + deploymentVersion: process.env.DEPLOYMENT_VERSION ?? null, + authUrl: process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? null, + }, + }; +} From 4e6867b3654e2c10457036aa4104b2d8abbfc3a2 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 00:44:39 +0000 Subject: [PATCH 02/47] =?UTF-8?q?feat(admin):=20Sprint=206=20=E2=80=94=20/?= =?UTF-8?q?admin/media=20gallery=20+=20theme=20write-through?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/media/page.tsx | 137 ++++++++++++++++++++++++++++++ src/app/admin/settings/actions.ts | 11 +++ src/lib/admin/media.ts | 60 +++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 src/app/admin/media/page.tsx create mode 100644 src/lib/admin/media.ts diff --git a/src/app/admin/media/page.tsx b/src/app/admin/media/page.tsx new file mode 100644 index 0000000..36dc856 --- /dev/null +++ b/src/app/admin/media/page.tsx @@ -0,0 +1,137 @@ +import Link from "next/link"; +import { MediaType } from "@/generated/prisma/enums"; +import { getMediaStats, listMediaAdmin } from "@/lib/admin/media"; +import { StatusBadge } from "@/components/admin/StatusBadge"; + +export const dynamic = "force-dynamic"; + +type PageProps = { + searchParams: Promise<{ q?: string; type?: string; carbetId?: string }>; +}; + +const TYPE_VALUES = new Set([MediaType.PHOTO, MediaType.VIDEO]); + +export default async function MediaAdminPage({ searchParams }: PageProps) { + const sp = await searchParams; + const filters = { + q: sp.q?.trim() || undefined, + type: TYPE_VALUES.has(sp.type ?? "") ? (sp.type as MediaType) : undefined, + carbetId: sp.carbetId || undefined, + }; + const [items, stats] = await Promise.all([listMediaAdmin(filters), getMediaStats()]); + const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" }); + + return ( +
+
+

Médias

+

+ {items.length} affiché{items.length > 1 ? "s" : ""} + {items.length === 200 ? " (limite atteinte — affinez les filtres)" : ""} +

+
+ +
+ + + + + +
+ +
+ + + + {(filters.q || filters.type || filters.carbetId) ? ( + + Réinit. + + ) : null} +
+ + {items.length === 0 ? ( +
+ Aucun média ne correspond aux filtres. +
+ ) : ( +
+ {items.map((m) => ( + +
+ {m.type === MediaType.PHOTO ? ( + // eslint-disable-next-line @next/next/no-img-element + {m.s3Key} + ) : ( +
+ )} + + {m.type} + +
+
+
+ {m.carbet.title} + +
+
+ {m.s3Key} + {dateFmt.format(m.createdAt)} +
+
+ + ))} +
+ )} +
+ ); +} + +function Stat({ + label, + value, + tone = "neutral", +}: { + label: string; + value: number; + tone?: "neutral" | "warn"; +}) { + return ( +
0 ? "border-amber-300" : "border-zinc-200") + } + > +
{label}
+
0 ? "text-amber-700" : "text-zinc-900")}> + {value} +
+
+ ); +} diff --git a/src/app/admin/settings/actions.ts b/src/app/admin/settings/actions.ts index c112f4a..7da4291 100644 --- a/src/app/admin/settings/actions.ts +++ b/src/app/admin/settings/actions.ts @@ -7,6 +7,7 @@ import { UserRole } from "@/generated/prisma/enums"; import { requireRole } from "@/lib/authorization"; import { recordAudit } from "@/lib/admin/audit"; import { setSetting } from "@/lib/admin/settings"; +import { togglePlugin } from "@/lib/plugins/server"; const platformSchema = z.object({ name: z.string().trim().min(2).max(80), @@ -66,8 +67,18 @@ export async function saveThemeSettingsAction(fd: FormData) { } const who = await actor(); await setSetting("theme", parsed.data, who); + + // Le rendu du site public est piloté par l'état des plugins thème. + // On synchronise : un seul plugin actif (ou aucun pour "default"). + const wantAquarelle = parsed.data.active === "theme-aquarelle"; + const wantGuyane = parsed.data.active === "theme-guyane"; + await togglePlugin("theme-aquarelle", wantAquarelle); + await togglePlugin("theme-guyane", wantGuyane); + await recordAudit({ scope: "admin.settings", event: "theme.update", actorEmail: who, details: parsed.data }); revalidatePath("/admin/settings"); + revalidatePath("/admin/plugins"); + revalidatePath("/"); return { ok: true as const }; } diff --git a/src/lib/admin/media.ts b/src/lib/admin/media.ts new file mode 100644 index 0000000..f96654e --- /dev/null +++ b/src/lib/admin/media.ts @@ -0,0 +1,60 @@ +import "server-only"; + +import { Prisma } from "@/generated/prisma/client"; +import { MediaType } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; + +export type AdminMediaFilters = { + q?: string; + type?: MediaType; + carbetId?: string; +}; + +export type AdminMediaListItem = { + id: string; + type: MediaType; + s3Key: string; + s3Url: string; + sortOrder: number; + createdAt: Date; + carbet: { id: string; title: string; slug: string; status: string }; +}; + +export async function listMediaAdmin(filters: AdminMediaFilters = {}): Promise { + const where: Prisma.MediaWhereInput = {}; + if (filters.q) { + where.OR = [ + { s3Key: { contains: filters.q, mode: "insensitive" } }, + { carbet: { title: { contains: filters.q, mode: "insensitive" } } }, + { carbet: { slug: { contains: filters.q, mode: "insensitive" } } }, + ]; + } + if (filters.type) where.type = filters.type; + if (filters.carbetId) where.carbetId = filters.carbetId; + + return prisma.media.findMany({ + where, + orderBy: [{ createdAt: "desc" }], + take: 200, + select: { + id: true, + type: true, + s3Key: true, + s3Url: true, + sortOrder: true, + createdAt: true, + carbet: { select: { id: true, title: true, slug: true, status: true } }, + }, + }); +} + +export async function getMediaStats() { + const [total, photo, video, carbetsWithMedia, carbetsWithoutMedia] = await Promise.all([ + prisma.media.count(), + prisma.media.count({ where: { type: MediaType.PHOTO } }), + prisma.media.count({ where: { type: MediaType.VIDEO } }), + prisma.carbet.count({ where: { media: { some: {} } } }), + prisma.carbet.count({ where: { media: { none: {} } } }), + ]); + return { total, photo, video, carbetsWithMedia, carbetsWithoutMedia }; +} From a5ae692cf46ba7e0304c1086c34381f4e54b56c3 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 00:49:31 +0000 Subject: [PATCH 03/47] =?UTF-8?q?fix(admin):=20content-pages=20=C3=A9ditai?= =?UTF-8?q?t=20FR=20quel=20que=20soit=20le=20lien=20cliqu=C3=A9=20?= =?UTF-8?q?=E2=80=94=20support=20multilang=20complet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[slug]/_components/EditorForm.tsx | 14 +- src/app/admin/content-pages/[slug]/page.tsx | 90 +++++++--- src/app/admin/content-pages/page.tsx | 160 ++++++++++++++---- 3 files changed, 204 insertions(+), 60 deletions(-) diff --git a/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx b/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx index 64e3818..0f2d54a 100644 --- a/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx +++ b/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; type Page = { slug: string; + lang: string; title: string; body: string; category: string; @@ -25,11 +26,14 @@ export default function EditorForm({ page }: { page: Page }) { setMsg(null); setErr(null); try { - const res = await fetch(`/api/admin/content-pages/${encodeURIComponent(page.slug)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title, body, published }), - }); + const res = await fetch( + `/api/admin/content-pages/${encodeURIComponent(page.slug)}?lang=${encodeURIComponent(page.lang)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, body, published }), + }, + ); if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j?.error || `HTTP ${res.status}`); diff --git a/src/app/admin/content-pages/[slug]/page.tsx b/src/app/admin/content-pages/[slug]/page.tsx index d9e6b25..db82c4b 100644 --- a/src/app/admin/content-pages/[slug]/page.tsx +++ b/src/app/admin/content-pages/[slug]/page.tsx @@ -2,46 +2,90 @@ import { notFound } from "next/navigation"; import Link from "next/link"; import { requireRole } from "@/lib/authorization"; import { UserRole } from "@/generated/prisma/enums"; -import { getContentPage } from "@/lib/content-pages"; import { prisma } from "@/lib/prisma"; import EditorForm from "./_components/EditorForm"; export const dynamic = "force-dynamic"; -type PageProps = { params: Promise<{ slug: string }> }; +type PageProps = { + params: Promise<{ slug: string }>; + searchParams: Promise<{ lang?: string }>; +}; -export default async function EditContentPage({ params }: PageProps) { +function normalizeLang(v: string | undefined): string { + if (!v) return "fr"; + const l = v.toLowerCase().trim(); + return /^[a-z]{2}$/.test(l) ? l : "fr"; +} + +export default async function EditContentPage({ params, searchParams }: PageProps) { await requireRole([UserRole.ADMIN]); const { slug } = await params; - // Pas getContentPage : il filtre published=true. Ici on veut tout voir. - // Admin édite la version FR par défaut. (Édition EN = future feature.) - const row = await prisma.contentPage.findUnique({ - where: { slug_lang: { slug, lang: "fr" } }, - }); + const sp = await searchParams; + const lang = normalizeLang(sp.lang); + + const [row, siblings] = await Promise.all([ + prisma.contentPage.findUnique({ where: { slug_lang: { slug, lang } } }), + prisma.contentPage.findMany({ + where: { slug }, + select: { lang: true, title: true, published: true, updatedAt: true }, + orderBy: { lang: "asc" }, + }), + ]); if (!row) notFound(); - // Re-construction du type minimal attendu par le formulaire. + const page = { slug: row.slug, + lang: row.lang, title: row.title, body: row.body, category: row.category, published: row.published, - updatedAt: row.updatedAt, }; - // Mute eslint sur le _ = getContentPage (gardé importé pour la cohérence future). - void getContentPage; + return ( -
- - ← Toutes les pages - -

Éditer · {page.title}

-

- URL publique : /{page.slug} -

+
+
+ + ← Toutes les pages + +

+ {page.title} + + {page.lang} + +

+

+ URL publique : /{page.slug} + {page.lang !== "fr" ? ` · variante ${page.lang}` : ""} +

+ + {siblings.length > 1 ? ( + + ) : null} +
+
diff --git a/src/app/admin/content-pages/page.tsx b/src/app/admin/content-pages/page.tsx index 263e290..0f9f2ab 100644 --- a/src/app/admin/content-pages/page.tsx +++ b/src/app/admin/content-pages/page.tsx @@ -10,50 +10,146 @@ const CATEGORY_LABEL: Record = { legal: "Légales", }; +type Translation = { + lang: string; + title: string; + published: boolean; + updatedAt: Date; +}; + +type GroupedPage = { + slug: string; + category: string; + translations: Translation[]; +}; + export default async function ContentPagesAdminPage() { await requireRole([UserRole.ADMIN]); - const pages = await listContentPages(); + const rows = await listContentPages(); - const byCategory = pages.reduce>((acc, p) => { + // Regrouper par slug — chaque slug peut avoir plusieurs traductions. + const bySlug = new Map(); + for (const r of rows) { + const existing = bySlug.get(r.slug); + const t: Translation = { + lang: r.lang, + title: r.title, + published: r.published, + updatedAt: r.updatedAt, + }; + if (existing) { + existing.translations.push(t); + } else { + bySlug.set(r.slug, { slug: r.slug, category: r.category, translations: [t] }); + } + } + const pages = Array.from(bySlug.values()).sort((a, b) => a.slug.localeCompare(b.slug)); + + const byCategory = pages.reduce>((acc, p) => { (acc[p.category] ??= []).push(p); return acc; }, {}); - return ( -
-

Pages éditoriales

-

- Pages markdown affichées dans le site public. La catégorie « Général » - est gérée par le plugin content-pages, la catégorie « Légales » - par legal-pages. Désactiver le plugin dépublie ses pages - sans les supprimer. -

+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" }); -
+ return ( +
+
+

Pages éditoriales

+

+ Pages markdown servies par le site public. Chaque page existe en une ou + plusieurs langues — utilisez le bouton de la langue voulue pour éditer + la bonne version. +

+
+ +
{Object.entries(byCategory).map(([cat, list]) => (
-

+

{CATEGORY_LABEL[cat] ?? cat}

-
    - {list.map((p) => ( -
  • -
    -
    {p.title}
    -
    - /{p.slug} · {p.published ? "publié" : "dépublié"} · - mis à jour le {new Date(p.updatedAt).toLocaleDateString("fr-FR")} -
    -
    - - Éditer - -
  • - ))} -
+
+ + + + + + + + + + + + {list.map((p) => { + const fr = p.translations.find((t) => t.lang === "fr"); + const others = p.translations.filter((t) => t.lang !== "fr").sort((a, b) => a.lang.localeCompare(b.lang)); + const lastUpdated = p.translations + .map((t) => t.updatedAt.getTime()) + .reduce((a, b) => Math.max(a, b), 0); + return ( + + + + + + + + ); + })} + +
SlugTitre (FR)TraductionsMAJÉditer
/{p.slug} + {fr ? ( + <> + {fr.title} + {!fr.published ? ( + + dépublié + + ) : null} + + ) : ( + — (pas de version FR) + )} + + {others.length === 0 ? ( + + ) : ( + + {others.map((t) => ( + + {t.lang} + + ))} + + )} + + {lastUpdated ? dateFmt.format(new Date(lastUpdated)) : "—"} + + + {p.translations + .sort((a, b) => (a.lang === "fr" ? -1 : b.lang === "fr" ? 1 : a.lang.localeCompare(b.lang))) + .map((t) => ( + + {t.lang} + + ))} + +
+
))}
From 1f8dd90979ab629d675da60d4e7f3b01b65ee1fa Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 00:51:19 +0000 Subject: [PATCH 04/47] =?UTF-8?q?fix(admin):=20PATCH=20content-pages=20res?= =?UTF-8?q?pecte=20=3Flang=3D=20(sinon=20=C3=A9crasait=20FR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/admin/content-pages/[slug]/route.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/app/api/admin/content-pages/[slug]/route.ts b/src/app/api/admin/content-pages/[slug]/route.ts index c2db7dd..df9ee1d 100644 --- a/src/app/api/admin/content-pages/[slug]/route.ts +++ b/src/app/api/admin/content-pages/[slug]/route.ts @@ -11,21 +11,28 @@ const patchSchema = z.object({ published: z.boolean().optional(), }); +function normalizeLang(v: string | null): string { + if (!v) return "fr"; + const l = v.toLowerCase().trim(); + return /^[a-z]{2}$/.test(l) ? l : "fr"; +} + export async function PATCH(req: Request, ctx: { params: Promise<{ slug: string }> }) { await requireRole([UserRole.ADMIN]); const { slug } = await ctx.params; + const url = new URL(req.url); + const lang = normalizeLang(url.searchParams.get("lang")); const session = await auth(); const parsed = patchSchema.safeParse(await req.json().catch(() => ({}))); if (!parsed.success) { return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); } - // L'admin édite la version FR par défaut (édition multi-langues à venir). const existing = await prisma.contentPage.findUnique({ - where: { slug_lang: { slug, lang: "fr" } }, + where: { slug_lang: { slug, lang } }, }); if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); const updated = await prisma.contentPage.update({ - where: { slug_lang: { slug, lang: "fr" } }, + where: { slug_lang: { slug, lang } }, data: { ...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}), ...(parsed.data.body !== undefined ? { body: parsed.data.body } : {}), @@ -35,6 +42,7 @@ export async function PATCH(req: Request, ctx: { params: Promise<{ slug: string }); return NextResponse.json({ slug: updated.slug, + lang: updated.lang, title: updated.title, published: updated.published, updatedAt: updated.updatedAt, From a9fcd18022ac0271bc6af1e6b306ec37cf03c0ac Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 01:10:49 +0000 Subject: [PATCH 05/47] =?UTF-8?q?feat(admin):=20/admin/home=20=E2=80=94=20?= =?UTF-8?q?=C3=A9diteur=20des=20textes=20de=20la=20page=20d'accueil=20(FR+?= =?UTF-8?q?EN,=20override=20DB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 9 + prisma/schema.prisma | 11 ++ .../home/_components/HomeTranslationsForm.tsx | 169 ++++++++++++++++++ src/app/admin/home/actions.ts | 67 +++++++ src/app/admin/home/page.tsx | 39 ++++ src/components/admin/Sidebar.tsx | 5 +- src/lib/admin/home-keys.ts | 51 ++++++ src/lib/admin/translations.ts | 72 ++++++++ src/lib/i18n/overrides.ts | 59 ++++++ src/lib/i18n/server.ts | 11 +- 10 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260601120000_translation_overrides/migration.sql create mode 100644 src/app/admin/home/_components/HomeTranslationsForm.tsx create mode 100644 src/app/admin/home/actions.ts create mode 100644 src/app/admin/home/page.tsx create mode 100644 src/lib/admin/home-keys.ts create mode 100644 src/lib/admin/translations.ts create mode 100644 src/lib/i18n/overrides.ts diff --git a/prisma/migrations/20260601120000_translation_overrides/migration.sql b/prisma/migrations/20260601120000_translation_overrides/migration.sql new file mode 100644 index 0000000..5f3bfb5 --- /dev/null +++ b/prisma/migrations/20260601120000_translation_overrides/migration.sql @@ -0,0 +1,9 @@ +CREATE TABLE "Translation" ( + "key" TEXT NOT NULL, + "lang" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "updatedBy" TEXT, + CONSTRAINT "Translation_pkey" PRIMARY KEY ("key", "lang") +); +CREATE INDEX "Translation_lang_idx" ON "Translation"("lang"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d0cc722..caa7314 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -348,3 +348,14 @@ model Setting { updatedAt DateTime @updatedAt updatedBy String? } + +model Translation { + key String + lang String + value String + updatedAt DateTime @updatedAt + updatedBy String? + + @@id([key, lang]) + @@index([lang]) +} diff --git a/src/app/admin/home/_components/HomeTranslationsForm.tsx b/src/app/admin/home/_components/HomeTranslationsForm.tsx new file mode 100644 index 0000000..e0bc7c0 --- /dev/null +++ b/src/app/admin/home/_components/HomeTranslationsForm.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { saveHomeTranslationsAction } from "../actions"; + +type Row = { + key: string; + baseFr: string; + baseEn: string; + overrideFr: string | null; + overrideEn: string | null; +}; + +type Section = { + id: string; + label: string; + description: string; + rows: Row[]; +}; + +type Props = { + sections: Section[]; +}; + +function autoRows(text: string): number { + const lines = text.split("\n").length; + return Math.min(8, Math.max(1, lines)); +} + +export function HomeTranslationsForm({ sections }: Props) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // État local : on garde uniquement la valeur courante (initialisée avec override ?? base). + // Le baseValue est posé en input caché et sert au backend pour décider override vs reset. + const initial = useMemo(() => { + const m = new Map(); + for (const s of sections) { + for (const r of s.rows) { + m.set(r.key, { fr: r.overrideFr ?? r.baseFr, en: r.overrideEn ?? r.baseEn }); + } + } + return m; + }, [sections]); + + function onSubmit(formData: FormData) { + setError(null); + setSuccess(null); + startTransition(async () => { + const res = await saveHomeTranslationsAction(formData); + if (res.ok === false) { + setError(res.error); + } else { + const parts: string[] = []; + if (res.saved) parts.push(`${res.saved} sauvegardé${res.saved > 1 ? "s" : ""}`); + if (res.reset) parts.push(`${res.reset} réinitialisé${res.reset > 1 ? "s" : ""} (valeur de base)`); + setSuccess(parts.length > 0 ? parts.join(" · ") : "Aucun changement."); + } + }); + } + + // On crée un seul formulaire global qui contient toutes les sections. + let counter = 0; + + return ( +
+
+ {sections.map((section) => ( +
+
+

+ {section.label} +

+

{section.description}

+
+ +
+ {section.rows.map((r) => { + const idxFr = counter++; + const idxEn = counter++; + const init = initial.get(r.key)!; + const hasOverrideFr = r.overrideFr !== null; + const hasOverrideEn = r.overrideEn !== null; + return ( +
+
+ {r.key} + + {hasOverrideFr ? ( + + FR modifié + + ) : null} + {hasOverrideEn ? ( + + EN modifié + + ) : null} + +
+ +
+