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, + }, + }; +}