From b2fa98051fc48da585a43dc7a68ac9291a210aec Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Tue, 19 May 2026 14:27:57 +0500 Subject: [PATCH] Add quick enroll button to admin users table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "+ Доступ" button to each user row in the admin users table. Clicking it opens a centered modal with a course dropdown, optional expiry date, and a Server Action that upserts CourseEnrollment, logs to AccessLog, and sends sendCourseAccessEmail on new enrollments. Co-Authored-By: Claude Sonnet 4.6 --- src/app/admin/users/enroll-action.ts | 75 ++++++ src/components/admin/quick-enroll-modal.tsx | 252 ++++++++++++++++++++ src/components/admin/users-table.tsx | 2 + 3 files changed, 329 insertions(+) create mode 100644 src/app/admin/users/enroll-action.ts create mode 100644 src/components/admin/quick-enroll-modal.tsx diff --git a/src/app/admin/users/enroll-action.ts b/src/app/admin/users/enroll-action.ts new file mode 100644 index 0000000..c504de8 --- /dev/null +++ b/src/app/admin/users/enroll-action.ts @@ -0,0 +1,75 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { sendCourseAccessEmail } from "@/lib/email"; +import { revalidatePath } from "next/cache"; + +async function requireAdmin() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || session.user.role !== "admin") throw new Error("Forbidden"); + return session; +} + +export async function grantCourseAccess( + userId: string, + courseId: string, + expiresAt: Date | null +): Promise<{ ok: true } | { error: string }> { + try { + const session = await requireAdmin(); + + const [user, course] = await Promise.all([ + prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }), + prisma.course.findUnique({ where: { id: courseId }, select: { title: true } }), + ]); + + if (!user) return { error: "Пользователь не найден" }; + if (!course) return { error: "Курс не найден" }; + + const existing = await prisma.courseEnrollment.findUnique({ + where: { userId_courseId: { userId, courseId } }, + }); + + await prisma.courseEnrollment.upsert({ + where: { userId_courseId: { userId, courseId } }, + update: { expiresAt }, + create: { userId, courseId, expiresAt }, + }); + + await prisma.accessLog.create({ + data: { + courseId, + userId, + action: "granted", + method: "quick", + grantedById: session.user.id, + }, + }); + + // Send email only on new enrollment (not on update) + if (!existing) { + await sendCourseAccessEmail(user.email, user.name ?? user.email, course.title).catch( + (e) => console.error("[enroll-action] sendCourseAccessEmail:", e) + ); + } + + revalidatePath("/admin/users"); + revalidatePath(`/admin/users/${userId}`); + + return { ok: true }; + } catch (e) { + console.error("[enroll-action] grantCourseAccess:", e); + return { error: "Произошла ошибка. Попробуйте ещё раз." }; + } +} + +export async function getPublishedCourses(): Promise<{ id: string; title: string }[]> { + await requireAdmin(); + return prisma.course.findMany({ + where: { published: true }, + select: { id: true, title: true }, + orderBy: { title: "asc" }, + }); +} diff --git a/src/components/admin/quick-enroll-modal.tsx b/src/components/admin/quick-enroll-modal.tsx new file mode 100644 index 0000000..356d170 --- /dev/null +++ b/src/components/admin/quick-enroll-modal.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useState, useTransition, useEffect, useRef } from "react"; +import { grantCourseAccess, getPublishedCourses } from "@/app/admin/users/enroll-action"; + +interface Course { + id: string; + title: string; +} + +interface Props { + userId: string; + userName: string; +} + +export function QuickEnrollButton({ userId, userName }: Props) { + const [open, setOpen] = useState(false); + const [courses, setCourses] = useState([]); + const [loadingCourses, setLoadingCourses] = useState(false); + const [courseId, setCourseId] = useState(""); + const [expiresAt, setExpiresAt] = useState(""); + const [status, setStatus] = useState<"idle" | "success" | "error">("idle"); + const [errorMsg, setErrorMsg] = useState(""); + const [pending, startTransition] = useTransition(); + const dialogRef = useRef(null); + + // Load courses when modal opens + useEffect(() => { + if (!open) return; + setLoadingCourses(true); + getPublishedCourses() + .then((data) => { + setCourses(data); + if (data.length > 0) setCourseId(data[0].id); + }) + .finally(() => setLoadingCourses(false)); + }, [open]); + + // Close on Escape + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") handleClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open]); + + function handleOpen() { + setStatus("idle"); + setErrorMsg(""); + setExpiresAt(""); + setCourseId(""); + setOpen(true); + } + + function handleClose() { + if (pending) return; + setOpen(false); + } + + function handleSubmit() { + if (!courseId) return; + const expiry = expiresAt ? new Date(expiresAt) : null; + startTransition(async () => { + const result = await grantCourseAccess(userId, courseId, expiry); + if ("error" in result) { + setStatus("error"); + setErrorMsg(result.error); + } else { + setStatus("success"); + setTimeout(() => setOpen(false), 1200); + } + }); + } + + return ( + <> + {/* Trigger button */} + + + {/* Modal overlay */} + {open && ( +
{ + if (e.target === e.currentTarget) handleClose(); + }} + > +
+ {/* Header */} +
+
+

+ Выдать доступ +

+

+ {userName} +

+
+ +
+ + {/* Course select */} +
+ + {loadingCourses ? ( +

+ Загрузка… +

+ ) : courses.length === 0 ? ( +

+ Нет опубликованных курсов +

+ ) : ( + + )} +
+ + {/* Expiry date (optional) */} +
+ + setExpiresAt(e.target.value)} + disabled={pending} + min={new Date().toISOString().slice(0, 10)} + style={{ + width: "100%", + border: "2px solid var(--border)", + background: "var(--background)", + color: expiresAt ? "var(--foreground)" : "var(--muted-foreground)", + padding: "0.4rem 0.5rem", + fontSize: "13px", + fontFamily: "inherit", + outline: "none", + }} + onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")} + onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")} + /> +

+ Оставьте пустым для бессрочного доступа +

+
+ + {/* Error message */} + {status === "error" && ( +

+ {errorMsg} +

+ )} + + {/* Success message */} + {status === "success" && ( +

+ Доступ выдан +

+ )} + + {/* Actions */} +
+ + +
+
+
+ )} + + ); +} diff --git a/src/components/admin/users-table.tsx b/src/components/admin/users-table.tsx index ee62aff..75917be 100644 --- a/src/components/admin/users-table.tsx +++ b/src/components/admin/users-table.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import Link from "next/link"; import { Badge } from "@/components/ui/badge"; +import { QuickEnrollButton } from "@/components/admin/quick-enroll-modal"; type Enrollment = { courseId: string; @@ -175,6 +176,7 @@ export function UsersTable({ users }: { users: UserRow[] }) {
{user.role !== "admin" && } +
setHoveredId(user.id)}