feat(admin): Sprint 5 — Audit log + Settings (gouvernance)
This commit is contained in:
parent
2ad4cbed80
commit
79ddcd23f5
14 changed files with 773 additions and 24 deletions
|
|
@ -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")
|
||||
);
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
134
src/app/admin/audit/page.tsx
Normal file
134
src/app/admin/audit/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])?$/;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
171
src/app/admin/settings/_components/SettingsForms.tsx
Normal file
171
src/app/admin/settings/_components/SettingsForms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/app/admin/settings/actions.ts
Normal file
89
src/app/admin/settings/actions.ts
Normal 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 };
|
||||
}
|
||||
100
src/app/admin/settings/page.tsx
Normal file
100
src/app/admin/settings/page.tsx
Normal 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'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'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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
91
src/lib/admin/audit.ts
Normal 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
120
src/lib/admin/settings.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue