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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
Reference in New Issue
Block a user