Files
lms-sb/src/components/admin/users-table.tsx
T
admins 0e4f6c4b01 Fix impersonation: use direct fetch to /api/auth/admin/impersonate-user
authClient.admin.impersonateUser is not registered in pathMethods
in better-auth v1.6 client plugin — call the endpoint directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:18:49 +05:00

201 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
type Enrollment = {
courseId: string;
courseTitle: string;
expiresAt: Date | null;
};
type UserRow = {
id: string;
name: string;
email: string;
role: string;
emailVerified: boolean;
createdAt: Date;
enrollmentCount: number;
enrollments: Enrollment[];
};
const roleLabel: Record<string, string> = {
admin: "Администратор",
curator: "Куратор",
student: "Ученик",
};
const roleVariant: Record<string, "default" | "secondary" | "outline"> = {
admin: "default",
curator: "secondary",
student: "outline",
};
function UserPopup({ user }: { user: UserRow }) {
const now = new Date();
return (
<div
className="absolute z-50 right-0 top-full mt-1 w-72 p-4 space-y-3 text-sm"
style={{
background: "var(--background)",
border: "2px solid var(--foreground)",
boxShadow: "4px 4px 0 0 var(--foreground)",
}}
>
{/* Contact */}
<div className="space-y-0.5">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Контакты</p>
<p className="font-mono text-xs">{user.email}</p>
</div>
{/* Courses */}
{user.enrollments.length > 0 ? (
<div className="space-y-1.5">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Курсы ({user.enrollments.length})
</p>
{user.enrollments.map((e) => {
const expired = e.expiresAt && new Date(e.expiresAt) < now;
return (
<div key={e.courseId} className="flex items-start justify-between gap-2">
<p className="text-xs flex-1 truncate">{e.courseTitle}</p>
<span
className="text-xs shrink-0"
style={{ color: expired ? "oklch(0.577 0.245 27.325)" : "var(--muted-foreground)" }}
>
{e.expiresAt
? expired
? "просрочен"
: `до ${new Date(e.expiresAt).toLocaleDateString("ru-RU")}`
: "бессрочно"}
</span>
</div>
);
})}
</div>
) : (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>Курсов нет</p>
)}
<Link
href={`/admin/users/${user.id}`}
className="block text-xs underline"
style={{ color: "var(--muted-foreground)" }}
>
Открыть профиль
</Link>
</div>
);
}
function ImpersonateButton({ userId }: { userId: string }) {
const [loading, setLoading] = useState(false);
async function handleImpersonate() {
setLoading(true);
try {
const res = await fetch("/api/auth/admin/impersonate-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
credentials: "include",
});
if (!res.ok) throw new Error(await res.text());
window.location.href = "/dashboard";
} catch (e) {
console.error("Impersonation failed:", e);
setLoading(false);
}
}
return (
<button
type="button"
onClick={handleImpersonate}
disabled={loading}
className="text-xs px-2 py-1 transition-colors"
style={{
border: "1px solid var(--border)",
color: loading ? "var(--muted-foreground)" : "var(--foreground)",
background: "transparent",
}}
>
{loading ? "..." : "Войти как"}
</button>
);
}
export function UsersTable({ users }: { users: UserRow[] }) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
return (
<div className="bg-white border border-slate-200 rounded-2xl overflow-visible">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
{["Пользователь", "Роль", "Курсов", "Email подтверждён", "Зарегистрирован", ""].map((h) => (
<th key={h} className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">{h}</th>
))}
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
className="border-b last:border-0"
style={{ borderColor: "var(--border)" }}
>
<td className="px-5 py-3">
<Link
href={`/admin/users/${user.id}`}
className="font-medium hover:underline"
style={{ color: "var(--foreground)" }}
>
{user.name}
</Link>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{user.email}</p>
</td>
<td className="px-5 py-3">
<Badge variant={roleVariant[user.role] ?? "outline"}>
{roleLabel[user.role] ?? user.role}
</Badge>
</td>
<td className="px-5 py-3 text-sm text-slate-600">{user.enrollmentCount}</td>
<td className="px-5 py-3">
<span className={`text-xs font-medium ${user.emailVerified ? "text-green-600" : "text-slate-400"}`}>
{user.emailVerified ? "Да" : "Нет"}
</span>
</td>
<td className="px-5 py-3 text-sm text-slate-400">
{new Date(user.createdAt).toLocaleDateString("ru-RU")}
</td>
{/* Actions */}
<td className="px-3 py-3 relative">
<div className="flex items-center gap-2">
{user.role !== "admin" && <ImpersonateButton userId={user.id} />}
<div
className="relative inline-block"
onMouseEnter={() => setHoveredId(user.id)}
onMouseLeave={() => setHoveredId(null)}
>
<button
type="button"
className="text-xs px-2 py-1"
style={{ border: "1px solid var(--border)", color: "var(--muted-foreground)" }}
>
···
</button>
{hoveredId === user.id && <UserPopup user={user} />}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}