Initialize Stage 0: Next.js 16 scaffold with auth and role-based routing
- Next.js 16.2.2 + React 19 + TypeScript + Tailwind v4 - Better Auth with email/password and role system (student/curator/admin) - Prisma 7 schema: User, Session, Account, Verification + full LMS model - Role-based dashboards: student /dashboard, curator /curator/dashboard, admin /admin/dashboard - Auth pages: login, register, verify-email - Better Auth API route handler - Middleware for route protection - Docker Compose with PostgreSQL 16 - Seed script with test users (admin/curator/student) - CLAUDE.md and ROADMAP.md project documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { signIn } from "@/lib/auth-client";
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
const result = await signIn.email({ email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError("Неверный email или пароль");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Вход..." : "Войти"}
|
||||
</button>
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Нет аккаунта?{" "}
|
||||
<Link href="/register" className="text-amber-600 hover:underline">
|
||||
Зарегистрироваться
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { LoginForm } from "./login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-amber-50">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-amber-900">Second Brain</h1>
|
||||
<p className="text-amber-700 mt-2">Войдите в свой аккаунт</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-amber-100 p-8">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { RegisterForm } from "./register-form";
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-amber-50">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-amber-900">Second Brain</h1>
|
||||
<p className="text-amber-700 mt-2">Создайте аккаунт</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-amber-100 p-8">
|
||||
<RegisterForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { signUp } from "@/lib/auth-client";
|
||||
|
||||
export function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
const result = await signUp.email({ name, email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? "Ошибка регистрации");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-4xl">✉️</div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
Проверьте почту
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
Мы отправили письмо на <strong>{email}</strong> для подтверждения
|
||||
аккаунта.
|
||||
</p>
|
||||
<Link href="/login" className="text-amber-600 hover:underline text-sm">
|
||||
Вернуться к входу
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Имя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
placeholder="Иван Иванов"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
placeholder="Минимум 8 символов"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Регистрация..." : "Зарегистрироваться"}
|
||||
</button>
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Уже есть аккаунт?{" "}
|
||||
<Link href="/login" className="text-amber-600 hover:underline">
|
||||
Войти
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-amber-50">
|
||||
<div className="text-center max-w-md space-y-4">
|
||||
<div className="text-5xl">✅</div>
|
||||
<h1 className="text-2xl font-bold text-amber-900">Email подтверждён</h1>
|
||||
<p className="text-gray-600">
|
||||
Ваш аккаунт активирован. Теперь вы можете войти в систему.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-6 rounded-lg transition-colors"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { LogoutButton } from "@/components/layout/logout-button";
|
||||
|
||||
export default async function StudentDashboard() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session) redirect("/login");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-amber-50">
|
||||
<header className="bg-white border-b border-amber-100 px-6 py-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-amber-900">Second Brain</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{session.user.name}</span>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-4xl mx-auto px-6 py-10">
|
||||
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
|
||||
Добро пожаловать, {session.user.name}!
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-8">Ваши курсы появятся здесь.</p>
|
||||
<div className="bg-white rounded-2xl border border-amber-100 p-8 text-center text-gray-400">
|
||||
<p className="text-4xl mb-3">📚</p>
|
||||
<p>Доступных курсов пока нет.</p>
|
||||
<p className="text-sm mt-1">Обратитесь к администратору за доступом.</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { LogoutButton } from "@/components/layout/logout-button";
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session) redirect("/login");
|
||||
if (session.user.role !== "admin") redirect("/dashboard");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<header className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-slate-900">Second Brain — Админ</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{session.user.name}</span>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-5xl mx-auto px-6 py-10">
|
||||
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
|
||||
Панель администратора
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-8">Управление платформой Second Brain.</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-6">
|
||||
<p className="text-3xl mb-2">📚</p>
|
||||
<p className="font-medium text-gray-800">Курсы</p>
|
||||
<p className="text-sm text-gray-400 mt-1">CRUD — Этап 1</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-6">
|
||||
<p className="text-3xl mb-2">👥</p>
|
||||
<p className="font-medium text-gray-800">Пользователи</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Управление — Этап 1</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-6">
|
||||
<p className="text-3xl mb-2">📊</p>
|
||||
<p className="font-medium text-gray-800">Аналитика</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Этап 10</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { LogoutButton } from "@/components/layout/logout-button";
|
||||
|
||||
export default async function CuratorDashboard() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session) redirect("/login");
|
||||
if (session.user.role !== "curator" && session.user.role !== "admin") {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-green-50">
|
||||
<header className="bg-white border-b border-green-100 px-6 py-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-green-900">Second Brain — Куратор</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{session.user.name}</span>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-4xl mx-auto px-6 py-10">
|
||||
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
|
||||
Панель куратора
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-8">Здесь будут домашние задания на проверку.</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-2xl border border-green-100 p-6">
|
||||
<p className="text-3xl mb-2">📝</p>
|
||||
<p className="font-medium text-gray-800">Домашние задания</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Новых заданий нет</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-green-100 p-6">
|
||||
<p className="text-3xl mb-2">👥</p>
|
||||
<p className="font-medium text-gray-800">Мои ученики</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Откроется в Этапе 3</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const role = session.user.role;
|
||||
|
||||
if (role === "admin") redirect("/admin/dashboard");
|
||||
if (role === "curator") redirect("/curator/dashboard");
|
||||
redirect("/dashboard");
|
||||
}
|
||||
Reference in New Issue
Block a user