diff --git a/src/app/admin/users/actions.ts b/src/app/admin/users/actions.ts new file mode 100644 index 0000000..70de4bb --- /dev/null +++ b/src/app/admin/users/actions.ts @@ -0,0 +1,54 @@ +"use server"; + +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import bcrypt from "bcryptjs"; +import { sendWelcomeEmail } from "@/lib/email"; + +export async function createUser(data: { + name: string; + email: string; + password: string; + role: string; + emailVerified: boolean; + sendWelcome: boolean; +}): Promise<{ success: true; userId: string } | { success: false; error: string }> { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || session.user.role !== "admin") { + return { success: false, error: "Нет доступа" }; + } + + const { name, email, password, role, emailVerified, sendWelcome } = data; + + if (!name.trim() || !email.trim() || !password.trim()) { + return { success: false, error: "Заполните все обязательные поля" }; + } + + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + return { success: false, error: "Пользователь с таким email уже существует" }; + } + + const hashedPassword = await bcrypt.hash(password, 10); + + const user = await prisma.user.create({ + data: { name: name.trim(), email: email.trim().toLowerCase(), role, emailVerified }, + }); + + // Create credential account (Better Auth's internal structure) + await prisma.account.create({ + data: { + userId: user.id, + accountId: user.id, + providerId: "credential", + password: hashedPassword, + }, + }); + + if (sendWelcome) { + await sendWelcomeEmail(user.email, user.name).catch(() => {}); + } + + return { success: true, userId: user.id }; +} diff --git a/src/app/admin/users/new/page.tsx b/src/app/admin/users/new/page.tsx new file mode 100644 index 0000000..8052405 --- /dev/null +++ b/src/app/admin/users/new/page.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; +import { CreateUserForm } from "@/components/admin/create-user-form"; + +export const metadata = { title: "Новый пользователь" }; + +export default function NewUserPage() { + return ( +
+ + +
+

+ Создание пользователя +

+
+ +
+ +
+
+ ); +} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 84e94f9..e1cf675 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -1,6 +1,7 @@ import { prisma } from "@/lib/prisma"; import { Badge } from "@/components/ui/badge"; import Link from "next/link"; +import { UserPlus } from "lucide-react"; const roleLabel: Record = { admin: "Администратор", @@ -22,9 +23,18 @@ export default async function UsersPage() { return (
-
-

Пользователи

-

{users.length} пользователей

+
+
+

Пользователи

+

{users.length} пользователей

+
+ + + Добавить пользователя +
diff --git a/src/components/admin/create-user-form.tsx b/src/components/admin/create-user-form.tsx new file mode 100644 index 0000000..e5156f4 --- /dev/null +++ b/src/components/admin/create-user-form.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Eye, EyeOff, RefreshCw } from "lucide-react"; +import { createUser } from "@/app/admin/users/actions"; + +const ROLES = [ + { value: "student", label: "Ученик" }, + { value: "curator", label: "Куратор" }, + { value: "admin", label: "Администратор" }, +]; + +function generatePassword() { + const chars = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789!@#$"; + return Array.from({ length: 12 }, () => chars[Math.floor(Math.random() * chars.length)]).join(""); +} + +const inputStyle: React.CSSProperties = { + border: "2px solid var(--border)", + background: "var(--background)", + outline: "none", + width: "100%", + padding: "0.5rem 0.75rem", + fontSize: "0.875rem", + fontFamily: "inherit", +}; + +const focusHandlers = { + onFocus: (e: React.FocusEvent) => + (e.currentTarget.style.borderColor = "var(--foreground)"), + onBlur: (e: React.FocusEvent) => + (e.currentTarget.style.borderColor = "var(--border)"), +}; + +function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function Toggle({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) { + return ( +
+ +
+

{label}

+ {hint &&

{hint}

} +
+
+ ); +} + +export function CreateUserForm() { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [showPassword, setShowPassword] = useState(false); + + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState("student"); + const [emailVerified, setEmailVerified] = useState(true); + const [sendWelcome, setSendWelcome] = useState(true); + + function handleGenerate() { + setPassword(generatePassword()); + setShowPassword(true); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + startTransition(async () => { + const result = await createUser({ name, email, password, role, emailVerified, sendWelcome }); + if (!result.success) { + setError(result.error); + return; + } + router.push(`/admin/users/${result.userId}`); + router.refresh(); + }); + } + + return ( +
+ {/* Name */} + + setName(e.target.value)} + placeholder="Иван Иванов" + required + style={inputStyle} + {...focusHandlers} + /> + + + {/* Email */} + + setEmail(e.target.value)} + placeholder="user@example.com" + required + style={{ ...inputStyle, fontFamily: "var(--font-mono)" }} + {...focusHandlers} + /> + + + {/* Password */} + +
+
+ setPassword(e.target.value)} + placeholder="Минимум 8 символов" + required + minLength={8} + style={{ ...inputStyle, paddingRight: "2.5rem", fontFamily: "var(--font-mono)" }} + {...focusHandlers} + /> + +
+ +
+ {password && showPassword && ( +

+ {password} +

+ )} +
+ + {/* Role */} + + + + + {/* Toggles */} +
+ + +
+ + {/* Error */} + {error && ( +

+ {error} +

+ )} + + {/* Actions */} +
+ + +
+
+ ); +}