Add platform settings (Stage 9)

- Settings key-value table in Prisma with migration
- getSettings() / getSetting() helpers in lib/settings.ts
- Admin UI at /admin/settings with 6 sections: General, Notifications,
  Student profile, Legal docs, Curator permissions, Code injection
- saveSettings() server action with admin-only guard
- Maintenance mode: non-admin users redirected to /maintenance page
- schoolName propagated to page metadata and all email templates
- headCode / bodyCode injected into root layout <head> and <body>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:18:37 +05:00
parent 093e403f5f
commit e77588deb8
14 changed files with 834 additions and 29 deletions
+7
View File
@@ -3,11 +3,18 @@ import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
import { LogoutButton } from "@/components/layout/logout-button";
import { getSetting } from "@/lib/settings";
export default async function StudentLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
// Maintenance mode: non-admin users see the maintenance page
if (session.user.role !== "admin") {
const maintenance = await getSetting("maintenanceMode");
if (maintenance === "true") redirect("/maintenance");
}
return (
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--background)" }}>
<header
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { SETTINGS_DEFAULTS, type SettingsKey } from "@/lib/settings";
export async function saveSettings(data: Record<string, string>) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
const validKeys = Object.keys(SETTINGS_DEFAULTS) as SettingsKey[];
const ops = validKeys
.filter((key) => key in data)
.map((key) =>
prisma.settings.upsert({
where: { key },
update: { value: data[key] },
create: { key, value: data[key] },
})
);
await Promise.all(ops);
revalidatePath("/admin/settings");
revalidatePath("/", "layout");
}
+22
View File
@@ -0,0 +1,22 @@
import { getSettings } from "@/lib/settings";
import { SettingsForm } from "@/components/admin/settings-form";
export const metadata = { title: "Настройки платформы" };
export default async function SettingsPage() {
const settings = await getSettings();
return (
<div className="p-8 max-w-2xl">
<div className="mb-6">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Настройки платформы
</h1>
</div>
<SettingsForm initial={settings} />
</div>
);
}
+7
View File
@@ -4,12 +4,19 @@ import { redirect } from "next/navigation";
import Link from "next/link";
import { LogoutButton } from "@/components/layout/logout-button";
import { AdminShell } from "@/components/admin/admin-shell";
import { getSetting } from "@/lib/settings";
export default async function CuratorLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
if (session.user.role !== "curator" && session.user.role !== "admin") redirect("/dashboard");
// Maintenance mode: curators (non-admin) see the maintenance page
if (session.user.role === "curator") {
const maintenance = await getSetting("maintenanceMode");
if (maintenance === "true") redirect("/maintenance");
}
// Admin uses the admin shell with sidebar
if (session.user.role === "admin") {
return <AdminShell userName={session.user.name}>{children}</AdminShell>;
+21 -6
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Fira_Mono } from "next/font/google";
import "./globals.css";
import { getSettings } from "@/lib/settings";
const firaMono = Fira_Mono({
weight: ["400", "500", "700"],
@@ -9,19 +10,33 @@ const firaMono = Fira_Mono({
display: "swap",
});
export const metadata: Metadata = {
title: "Second Brain — Обучение",
description: "Образовательная платформа Second Brain",
};
export async function generateMetadata(): Promise<Metadata> {
const settings = await getSettings();
return {
title: `${settings.schoolName} — Обучение`,
description: settings.schoolDescription || "Образовательная платформа",
keywords: settings.schoolKeywords || undefined,
};
}
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const settings = await getSettings();
return (
<html lang="ru" className={`${firaMono.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">{children}</body>
{settings.headCode ? (
<head dangerouslySetInnerHTML={{ __html: settings.headCode }} />
) : null}
<body className="min-h-full flex flex-col">
{children}
{settings.bodyCode ? (
<div dangerouslySetInnerHTML={{ __html: settings.bodyCode }} />
) : null}
</body>
</html>
);
}
+23
View File
@@ -0,0 +1,23 @@
export default function MaintenancePage() {
return (
<div
className="min-h-screen flex items-center justify-center p-8"
style={{ backgroundColor: "var(--background)" }}
>
<div className="card-aubade p-10 max-w-md w-full text-center space-y-4">
<p
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Технические работы
</p>
<h1 className="text-2xl font-bold" style={{ color: "var(--foreground)" }}>
Скоро вернёмся
</h1>
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Платформа временно недоступна. Мы проводим обновление пожалуйста, зайдите позже.
</p>
</div>
</div>
);
}