Add quick enroll button to admin users table
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Course[]>([]);
|
||||||
|
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<HTMLDivElement>(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 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpen}
|
||||||
|
className="text-xs px-2 py-1 transition-colors"
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
background: "transparent",
|
||||||
|
fontWeight: 600,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
title={`Выдать доступ к курсу — ${userName}`}
|
||||||
|
>
|
||||||
|
+ Доступ
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Modal overlay */}
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
style={{ background: "rgba(50,50,50,0.45)" }}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
className="w-full max-w-sm p-6 space-y-4"
|
||||||
|
style={{
|
||||||
|
background: "var(--background)",
|
||||||
|
border: "2px solid var(--foreground)",
|
||||||
|
boxShadow: "6px 6px 0 0 var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Выдать доступ
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold mt-0.5" style={{ color: "var(--foreground)" }}>
|
||||||
|
{userName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-xs leading-none"
|
||||||
|
style={{ color: "var(--muted-foreground)", fontSize: "18px", lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course select */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-xs font-bold uppercase tracking-widest mb-1.5"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Курс
|
||||||
|
</label>
|
||||||
|
{loadingCourses ? (
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Загрузка…
|
||||||
|
</p>
|
||||||
|
) : courses.length === 0 ? (
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Нет опубликованных курсов
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={courseId}
|
||||||
|
onChange={(e) => setCourseId(e.target.value)}
|
||||||
|
disabled={pending}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
background: "var(--background)",
|
||||||
|
color: "var(--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)")}
|
||||||
|
>
|
||||||
|
{courses.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expiry date (optional) */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-xs font-bold uppercase tracking-widest mb-1.5"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Срок доступа (необязательно)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={expiresAt}
|
||||||
|
onChange={(e) => 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)")}
|
||||||
|
/>
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Оставьте пустым для бессрочного доступа
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{status === "error" && (
|
||||||
|
<p className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
||||||
|
{errorMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success message */}
|
||||||
|
{status === "success" && (
|
||||||
|
<p className="text-xs font-semibold" style={{ color: "#3A6A3A" }}>
|
||||||
|
Доступ выдан
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={pending || loadingCourses || !courseId || status === "success"}
|
||||||
|
className="btn-aubade btn-aubade-accent text-xs px-4 py-2"
|
||||||
|
>
|
||||||
|
{pending ? "Выдаю…" : "Выдать"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={pending}
|
||||||
|
className="btn-aubade text-xs px-4 py-2"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { QuickEnrollButton } from "@/components/admin/quick-enroll-modal";
|
||||||
|
|
||||||
type Enrollment = {
|
type Enrollment = {
|
||||||
courseId: string;
|
courseId: string;
|
||||||
@@ -175,6 +176,7 @@ export function UsersTable({ users }: { users: UserRow[] }) {
|
|||||||
<td className="px-3 py-3 relative">
|
<td className="px-3 py-3 relative">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{user.role !== "admin" && <ImpersonateButton userId={user.id} />}
|
{user.role !== "admin" && <ImpersonateButton userId={user.id} />}
|
||||||
|
<QuickEnrollButton userId={user.id} userName={user.name ?? user.email} />
|
||||||
<div
|
<div
|
||||||
className="relative inline-block"
|
className="relative inline-block"
|
||||||
onMouseEnter={() => setHoveredId(user.id)}
|
onMouseEnter={() => setHoveredId(user.id)}
|
||||||
|
|||||||
Reference in New Issue
Block a user