feat(admin): Sprint 5 — Audit log + Settings (gouvernance)

This commit is contained in:
Claude Integration 2026-06-01 00:13:49 +00:00
parent 2ad4cbed80
commit 79ddcd23f5
14 changed files with 773 additions and 24 deletions

View file

@ -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")
);

View file

@ -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?
}

View file

@ -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 (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Audit log</h1>
<p className="mt-1 text-sm text-zinc-500">
{rows.length} entrée{rows.length > 1 ? "s" : ""}
{rows.length === 300 ? " (limite atteinte — affinez les filtres)" : ""}
</p>
</div>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche événement, cible, acteur…"
className="flex-1 min-w-[200px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="scope"
defaultValue={filters.scope ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous scopes</option>
{scopes.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<input
type="text"
name="actor"
defaultValue={filters.actor ?? ""}
placeholder="Acteur (email)"
className="rounded-md border border-zinc-300 px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<label className="flex items-center gap-1 text-xs text-zinc-500">
Du
<input type="date" name="from" defaultValue={sp.from ?? ""} className="rounded-md border border-zinc-300 px-2 py-1 text-sm" />
</label>
<label className="flex items-center gap-1 text-xs text-zinc-500">
au
<input type="date" name="to" defaultValue={sp.to ?? ""} className="rounded-md border border-zinc-300 px-2 py-1 text-sm" />
</label>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.scope || filters.actor || filters.from || filters.to) ? (
<Link href="/admin/audit" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-3 py-2 text-left font-semibold">Quand</th>
<th className="px-3 py-2 text-left font-semibold">Scope</th>
<th className="px-3 py-2 text-left font-semibold">Événement</th>
<th className="px-3 py-2 text-left font-semibold">Cible</th>
<th className="px-3 py-2 text-left font-semibold">Acteur</th>
<th className="px-3 py-2 text-left font-semibold">Détails</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-sm text-zinc-500">
Aucune entrée d&apos;audit ne correspond aux filtres.
</td>
</tr>
) : null}
{rows.map((r) => (
<tr key={r.id} className="hover:bg-zinc-50 align-top">
<td className="px-3 py-2 text-[11px] font-mono text-zinc-500 whitespace-nowrap">
{dateTimeFmt.format(r.createdAt)}
</td>
<td className="px-3 py-2 text-xs text-zinc-700 whitespace-nowrap">{r.scope}</td>
<td className="px-3 py-2 font-mono text-xs text-zinc-900 whitespace-nowrap">{r.event}</td>
<td className="px-3 py-2 font-mono text-[11px] text-zinc-600">
{r.target ? r.target.slice(0, 24) + (r.target.length > 24 ? "…" : "") : "—"}
</td>
<td className="px-3 py-2 text-xs text-zinc-700">{r.actorEmail ?? "—"}</td>
<td className="px-3 py-2 font-mono text-[11px] text-zinc-600">
{r.details && typeof r.details === "object" && Object.keys(r.details as object).length > 0
? JSON.stringify(r.details)
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -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<string, unknown>) {
await recordAudit({ scope: "admin.bookings", event, target, actorEmail: actor, details });
}
const ALLOWED_STATUS = new Set<string>([

View file

@ -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<string, unknown>,
) {
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,
});
}

View file

@ -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<string, unknown>) {
await recordAudit({ scope: "admin.organizations", event, target, actorEmail: actor, details });
}
const slugRe = /^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/;

View file

@ -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<string, unknown>) {
await recordAudit({ scope: "admin.pirogue", event, target, actorEmail: actor, details });
}
const providerSchema = z.object({

View file

@ -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<string, unknown>) {
await recordAudit({ scope: "admin.reviews", event, target, actorEmail: actor, details });
}
const updateSchema = z.object({

View file

@ -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<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
{children}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end">
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}
export function PlatformForm({
initial,
}: {
initial: { name: string; defaultLang: string; activeLangs: string[]; currency: string; commissionPercent: number };
}) {
return (
<FormWrapper action={savePlatformSettingsAction}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Nom de la plateforme" required>
<input name="name" defaultValue={initial.name} required maxLength={80} className={inputCls} />
</FormField>
<FormField label="Devise (ISO 4217)" required hint="EUR, USD, BRL…">
<input
name="currency"
defaultValue={initial.currency}
required
pattern="^[A-Z]{3}$"
maxLength={3}
className={inputCls + " uppercase"}
/>
</FormField>
<FormField label="Langue par défaut" required hint="Code ISO 639-1 (fr, en, pt…)">
<input
name="defaultLang"
defaultValue={initial.defaultLang}
required
pattern="^[a-zA-Z]{2}$"
maxLength={2}
className={inputCls + " lowercase"}
/>
</FormField>
<FormField label="Langues actives" required hint="Séparées par virgule (fr, en, pt).">
<input
name="activeLangs"
defaultValue={initial.activeLangs.join(", ")}
required
className={inputCls + " lowercase"}
placeholder="fr, en"
/>
</FormField>
<FormField label="Commission plateforme (%)" hint="Affiché dans les CGV. 0 = pas de commission.">
<input
name="commissionPercent"
type="number"
min={0}
max={100}
step="0.01"
defaultValue={initial.commissionPercent.toString()}
className={inputCls}
/>
</FormField>
</div>
</FormWrapper>
);
}
export function ThemeForm({ initial }: { initial: { active: string } }) {
return (
<FormWrapper action={saveThemeSettingsAction}>
<FormField label="Thème actif" hint="Détermine la skin du site public.">
<select name="active" defaultValue={initial.active} className={selectCls}>
<option value="default">default sobre (admin-like)</option>
<option value="theme-aquarelle">theme-aquarelle carnet naturaliste XIXᵉ</option>
<option value="theme-guyane">theme-guyane palette tropicale</option>
</select>
</FormField>
</FormWrapper>
);
}
export function StripeForm({
initial,
}: {
initial: { currency: string; commissionMode: string; perBookingFeePercent: number };
}) {
return (
<FormWrapper action={saveStripeSettingsAction}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Devise Stripe" required hint="Doit correspondre à la devise plateforme.">
<input
name="currency"
defaultValue={initial.currency}
required
pattern="^[A-Z]{3}$"
maxLength={3}
className={inputCls + " uppercase"}
/>
</FormField>
<FormField label="Modèle économique" required>
<select name="commissionMode" defaultValue={initial.commissionMode} className={selectCls}>
<option value="none">Aucune monétisation (preview)</option>
<option value="owner-subscription">Abonnement loueur (revenu plateforme)</option>
<option value="per-booking">Commission par réservation</option>
</select>
</FormField>
<FormField
label="Commission par réservation (%)"
hint="Utilisé uniquement si modèle = par réservation."
>
<input
name="perBookingFeePercent"
type="number"
min={0}
max={100}
step="0.01"
defaultValue={initial.perBookingFeePercent.toString()}
className={inputCls}
/>
</FormField>
</div>
</FormWrapper>
);
}

View file

@ -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 };
}

View file

@ -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 (
<span
className={
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ring-1 ring-inset " +
(ok ? "bg-emerald-100 text-emerald-800 ring-emerald-300" : "bg-amber-100 text-amber-800 ring-amber-300")
}
>
{ok ? labelOk : labelKo}
</span>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3 border-b border-zinc-100 py-1.5 last:border-b-0">
<dt className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</dt>
<dd className="text-sm text-zinc-900">{value}</dd>
</div>
);
}
export default async function SettingsAdminPage() {
const [settings, env] = await Promise.all([getAllSettings(), Promise.resolve(readEnvSnapshot())]);
return (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Paramètres</h1>
<p className="mt-1 text-sm text-zinc-500">
Configuration plateforme persistée en base + snapshot des variables d&apos;environnement (lecture seule).
</p>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Plateforme</h2>
<PlatformForm initial={settings.platform} />
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Thème site public</h2>
<ThemeForm initial={settings.theme} />
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Monétisation Stripe</h2>
<StripeForm initial={settings.stripe} />
<div className="mt-5 rounded border border-zinc-200 bg-zinc-50 p-3">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Variables d&apos;environnement Stripe (lecture seule)
</h3>
<dl className="space-y-1.5">
<Row label="STRIPE_SECRET_KEY" value={<Badge ok={env.stripe.secretKeyConfigured} />} />
<Row label="STRIPE_PUBLISHABLE_KEY" value={<Badge ok={env.stripe.publishableKeyConfigured} />} />
<Row label="STRIPE_WEBHOOK_SECRET" value={<Badge ok={env.stripe.webhookSecretConfigured} />} />
<Row label="STRIPE_OWNER_SUBSCRIPTION_PRICE_ID" value={<Badge ok={env.stripe.ownerPriceIdConfigured} labelKo="Manquant ou placeholder" />} />
</dl>
<p className="mt-2 text-[11px] text-zinc-500">
Les clés et secrets restent dans les variables d&apos;environnement du container. Modifications via le déploiement.
</p>
</div>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Stockage médias (S3 / MinIO)</h2>
<dl className="space-y-1.5">
<Row label="Endpoint" value={<code className="text-xs">{env.s3.endpoint ?? "—"}</code>} />
<Row label="Région" value={<code className="text-xs">{env.s3.region ?? "—"}</code>} />
<Row label="Bucket" value={<code className="text-xs">{env.s3.bucket ?? "—"}</code>} />
<Row
label="URL publique"
value={
env.s3.publicUrl ? (
<a href={env.s3.publicUrl} target="_blank" rel="noreferrer" className="text-xs text-zinc-900 hover:underline">
{env.s3.publicUrl}
</a>
) : "—"
}
/>
<Row label="Path-style URL" value={<Badge ok={env.s3.pathStyle} labelOk="Activé" labelKo="Désactivé" />} />
<Row label="MINIO_ROOT_USER" value={<Badge ok={env.s3.rootUserConfigured} />} />
</dl>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Déploiement</h2>
<dl className="space-y-1.5">
<Row label="URL publique" value={<code className="text-xs">{env.app.publicUrl ?? "—"}</code>} />
<Row label="URL auth" value={<code className="text-xs">{env.app.authUrl ?? "—"}</code>} />
<Row label="Version" value={<code className="text-xs">{env.app.deploymentVersion ?? "—"}</code>} />
</dl>
</section>
</div>
);
}

View file

@ -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<string>([
UserRole.OWNER,
@ -14,8 +15,8 @@ const ROLE_VALUES = new Set<string>([
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<string, unknown>) {
await recordAudit({ scope: "admin.users", event, target, actorEmail: actor, details });
}
export async function updateUserRoleAction(id: string, role: string) {

91
src/lib/admin/audit.ts Normal file
View file

@ -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<string, unknown> | null;
};
export async function recordAudit(entry: AuditEntry): Promise<void> {
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<AuditListItem[]> {
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<string[]> {
const rows = await prisma.auditLog.findMany({
distinct: ["scope"],
select: { scope: true },
orderBy: { scope: "asc" },
});
return rows.map((r) => r.scope);
}

120
src/lib/admin/settings.ts Normal file
View file

@ -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<AllSettings> {
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<PlatformSettings>) ?? {}) },
theme: { ...DEFAULTS.theme, ...((map.get("theme") as Partial<ThemeSettings>) ?? {}) },
stripe: { ...DEFAULTS.stripe, ...((map.get("stripe") as Partial<StripeSettings>) ?? {}) },
};
}
export async function setSetting(
key: SettingKey,
value: Record<string, unknown>,
updatedBy: string | null,
): Promise<void> {
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,
},
};
}