Implement platform settings (Stage 9)

- Wire settings to actual platform behavior: maintenance mode, registration toggle,
  notification emails, curator feedback emails, email verification flag
- Add logo (logoUrl, showLogo) and social network links (YouTube, VK, Telegram) settings
- Show logo + school name dynamically in student layout header
- Add footer to student layout with org requisites and social links
- Register page: read settings server-side, validate terms checkbox with legal links
- Login page: show notice when redirected from closed registration
- Settings form: add Logo and Social Networks sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 15:31:10 +05:00
parent ba0a630fd9
commit c64f393a7b
8 changed files with 223 additions and 24 deletions
+20 -2
View File
@@ -1,17 +1,35 @@
import { getSetting } from "@/lib/settings";
import { LoginForm } from "./login-form"; import { LoginForm } from "./login-form";
export default function LoginPage() { export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ notice?: string }>;
}) {
const [schoolName, { notice }] = await Promise.all([
getSetting("schoolName"),
searchParams,
]);
return ( return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}> <div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}> <h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
Second Brain {schoolName}
</h1> </h1>
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "0.65rem" }}> <p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "0.65rem" }}>
Образовательная платформа Образовательная платформа
</p> </p>
</div> </div>
{notice === "registration_closed" && (
<div
className="mb-4 px-4 py-3 text-sm text-center"
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
Регистрация временно закрыта. Обратитесь к администратору.
</div>
)}
<div className="card-aubade p-8"> <div className="card-aubade p-8">
<LoginForm /> <LoginForm />
</div> </div>
+16 -3
View File
@@ -1,19 +1,32 @@
import { redirect } from "next/navigation";
import { getSettings } from "@/lib/settings";
import { RegisterForm } from "./register-form"; import { RegisterForm } from "./register-form";
export default function RegisterPage() { export default async function RegisterPage() {
const settings = await getSettings();
if (settings.registrationEnabled !== "true") {
redirect("/login?notice=registration_closed");
}
return ( return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}> <div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}> <h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
Second Brain {settings.schoolName}
</h1> </h1>
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "0.65rem" }}> <p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "0.65rem" }}>
Образовательная платформа Образовательная платформа
</p> </p>
</div> </div>
<div className="card-aubade p-8"> <div className="card-aubade p-8">
<RegisterForm /> <RegisterForm
showTermsCheckbox={settings.showTermsCheckbox === "true"}
privacyPolicyUrl={settings.privacyPolicyUrl}
termsUrl={settings.termsUrl}
offerUrl={settings.offerUrl}
/>
</div> </div>
</div> </div>
</div> </div>
+51 -1
View File
@@ -4,10 +4,18 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { signUp } from "@/lib/auth-client"; import { signUp } from "@/lib/auth-client";
export function RegisterForm() { interface Props {
showTermsCheckbox: boolean;
privacyPolicyUrl: string;
termsUrl: string;
offerUrl: string;
}
export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl }: Props) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [termsAccepted, setTermsAccepted] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
@@ -22,8 +30,18 @@ export function RegisterForm() {
fontFamily: "inherit", fontFamily: "inherit",
} as React.CSSProperties; } as React.CSSProperties;
const legalLinks = [
{ url: privacyPolicyUrl, label: "Политику конфиденциальности" },
{ url: termsUrl, label: "Согласие на обработку данных" },
{ url: offerUrl, label: "Договор-оферту" },
].filter((l) => l.url);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (showTermsCheckbox && !termsAccepted) {
setError("Необходимо принять условия для продолжения");
return;
}
setError(""); setError("");
setLoading(true); setLoading(true);
@@ -102,6 +120,38 @@ export function RegisterForm() {
placeholder="Минимум 8 символов" placeholder="Минимум 8 символов"
/> />
</div> </div>
{showTermsCheckbox && (
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
className="mt-0.5 flex-shrink-0"
style={{ width: "16px", height: "16px", accentColor: "var(--foreground)" }}
/>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Я принимаю{" "}
{legalLinks.length > 0
? legalLinks.map((l, i) => (
<span key={l.url}>
<a
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="underline"
style={{ color: "var(--foreground)" }}
>
{l.label}
</a>
{i < legalLinks.length - 1 ? ", " : ""}
</span>
))
: "условия использования платформы"}
</span>
</label>
)}
{error && ( {error && (
<p className="text-xs py-2 px-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}> <p className="text-xs py-2 px-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
{error} {error}
+46 -2
View File
@@ -16,6 +16,17 @@ export default async function StudentLayout({ children }: { children: React.Reac
if (maintenance === "true") redirect("/maintenance"); if (maintenance === "true") redirect("/maintenance");
} }
const [schoolName, logoUrl, showLogo, socialYoutube, socialVk, socialTelegram, orgRequisites] =
await Promise.all([
getSetting("schoolName"),
getSetting("logoUrl"),
getSetting("showLogo"),
getSetting("socialYoutube"),
getSetting("socialVk"),
getSetting("socialTelegram"),
getSetting("orgRequisites"),
]);
const isImpersonating = !!(session.session as { impersonatedBy?: string }).impersonatedBy; const isImpersonating = !!(session.session as { impersonatedBy?: string }).impersonatedBy;
return ( return (
@@ -25,8 +36,12 @@ export default async function StudentLayout({ children }: { children: React.Reac
className="sticky top-0 z-10 flex items-center justify-between px-6 py-3" className="sticky top-0 z-10 flex items-center justify-between px-6 py-3"
style={{ borderBottom: "2px solid var(--border)", backgroundColor: "var(--background)" }} style={{ borderBottom: "2px solid var(--border)", backgroundColor: "var(--background)" }}
> >
<Link href="/dashboard" className="font-bold tracking-wide" style={{ color: "var(--foreground)" }}> <Link href="/dashboard" className="flex items-center gap-2 font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
Second Brain {logoUrl && showLogo === "true" && (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoUrl} alt={schoolName} className="h-6 w-auto object-contain" />
)}
{schoolName}
</Link> </Link>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm" style={{ color: "var(--muted-foreground)" }}>{session.user.name}</span> <span className="text-sm" style={{ color: "var(--muted-foreground)" }}>{session.user.name}</span>
@@ -34,6 +49,35 @@ export default async function StudentLayout({ children }: { children: React.Reac
</div> </div>
</header> </header>
<div className="flex-1 flex flex-col">{children}</div> <div className="flex-1 flex flex-col">{children}</div>
{(socialYoutube || socialVk || socialTelegram || orgRequisites) && (
<footer
className="px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs"
style={{ borderTop: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
{orgRequisites && (
<p className="whitespace-pre-line text-center sm:text-left">{orgRequisites}</p>
)}
{(socialYoutube || socialVk || socialTelegram) && (
<div className="flex items-center gap-4 flex-shrink-0">
{socialYoutube && (
<a href={socialYoutube} target="_blank" rel="noopener noreferrer" className="hover:underline">
YouTube
</a>
)}
{socialVk && (
<a href={socialVk} target="_blank" rel="noopener noreferrer" className="hover:underline">
VK
</a>
)}
{socialTelegram && (
<a href={socialTelegram} target="_blank" rel="noopener noreferrer" className="hover:underline">
Telegram
</a>
)}
</div>
)}
</footer>
)}
</div> </div>
); );
} }
@@ -5,6 +5,7 @@ import { auth } from "@/lib/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { sendFeedbackReceivedEmail } from "@/lib/email"; import { sendFeedbackReceivedEmail } from "@/lib/email";
import { getSetting, asBool } from "@/lib/settings";
async function requireCurator() { async function requireCurator() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
@@ -78,14 +79,17 @@ export async function submitFeedback(
}); });
const { lesson } = submission.homework; const { lesson } = submission.homework;
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${lesson.id}`; const notifySetting = await getSetting("notifyStudentOnFeedback");
await sendFeedbackReceivedEmail( if (asBool(notifySetting)) {
submission.user.email, const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${lesson.id}`;
submission.user.name, await sendFeedbackReceivedEmail(
lesson.title, submission.user.email,
data.text, submission.user.name,
lessonUrl lesson.title,
); data.text,
lessonUrl
);
}
revalidatePath("/curator/homework"); revalidatePath("/curator/homework");
revalidatePath(`/curator/homework/${submissionId}`); revalidatePath(`/curator/homework/${submissionId}`);
+56
View File
@@ -432,6 +432,62 @@ export function SettingsForm({ initial }: { initial: Settings }) {
</Field> </Field>
</Section> </Section>
{/* ── 7. Логотип ── */}
<Section
title="Логотип"
hint="URL изображения логотипа школы. Отображается рядом с названием в шапке личного кабинета ученика."
>
<Field label="URL логотипа">
<input
value={s.logoUrl}
onChange={(e) => set("logoUrl", e.target.value)}
placeholder="https://..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<Toggle
label="Показывать логотип в шапке"
hint="Если выключено — логотип скрыт, отображается только название школы."
checked={bool("showLogo")}
onChange={(v) => set("showLogo", v ? "true" : "false")}
/>
</Section>
{/* ── 8. Социальные сети ── */}
<Section
title="Социальные сети"
hint="Ссылки отображаются в подвале личного кабинета ученика."
>
<Field label="YouTube (URL канала)">
<input
value={s.socialYoutube}
onChange={(e) => set("socialYoutube", e.target.value)}
placeholder="https://youtube.com/@..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<Field label="VK (URL сообщества)">
<input
value={s.socialVk}
onChange={(e) => set("socialVk", e.target.value)}
placeholder="https://vk.com/..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<Field label="Telegram (ссылка на канал или группу)">
<input
value={s.socialTelegram}
onChange={(e) => set("socialTelegram", e.target.value)}
placeholder="https://t.me/..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
</Section>
{/* Bottom save button */} {/* Bottom save button */}
<div className="flex justify-end pb-4"> <div className="flex justify-end pb-4">
<button <button
+13 -8
View File
@@ -5,6 +5,7 @@ import { auth } from "@/lib/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { sendHomeworkSubmittedEmail } from "@/lib/email"; import { sendHomeworkSubmittedEmail } from "@/lib/email";
import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings";
// ── Lesson Progress ─────────────────────────────────────────────────────────── // ── Lesson Progress ───────────────────────────────────────────────────────────
@@ -72,20 +73,24 @@ export async function submitHomework(
}); });
submissionId = created.id; submissionId = created.id;
const [lesson, admins] = await Promise.all([ const [lesson, settings] = await Promise.all([
prisma.homework.findUnique({ prisma.homework.findUnique({
where: { id: homeworkId }, where: { id: homeworkId },
include: { lesson: { select: { title: true } } }, include: { lesson: { select: { title: true } } },
}), }),
prisma.user.findMany({ getSettings(),
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
}),
]); ]);
if (lesson) { if (lesson && asBool(settings.notifyOnHomework)) {
const configuredEmails = parseNotificationEmails(settings.notificationEmails);
const recipients = configuredEmails.length > 0
? configuredEmails.map((email) => ({ email, name: "" }))
: await prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
});
await Promise.all( await Promise.all(
admins.map((a) => recipients.map((r) =>
sendHomeworkSubmittedEmail(a.email, a.name, session.user.name, lesson.lesson.title, submissionId) sendHomeworkSubmittedEmail(r.email, r.name, session.user.name, lesson.lesson.title, submissionId)
) )
); );
} }
+9
View File
@@ -33,6 +33,15 @@ export const SETTINGS_DEFAULTS = {
curatorCanAnswerQuestions: "true", curatorCanAnswerQuestions: "true",
curatorCanSeeStudents: "true", curatorCanSeeStudents: "true",
// Logo
logoUrl: "",
showLogo: "true",
// Social networks
socialYoutube: "",
socialVk: "",
socialTelegram: "",
// Code injection // Code injection
headCode: "", headCode: "",
bodyCode: "", bodyCode: "",