Files
lms-sb/src/components/admin/create-user-form.tsx
T
admins 99c143d670 Add manual user creation in admin panel
- Server action createUser() with bcrypt password hash + Account record
- Form with name, email, password (show/hide + generate), role, emailVerified toggle
- Optional welcome email toggle (bypasses auto-hook for admin-created users)
- /admin/users/new page with breadcrumb navigation
- After creation, redirects to the new user's profile page
- "Добавить пользователя" button on the users list page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:36:52 +05:00

227 lines
7.4 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, 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<HTMLInputElement | HTMLSelectElement>) =>
(e.currentTarget.style.borderColor = "var(--foreground)"),
onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) =>
(e.currentTarget.style.borderColor = "var(--border)"),
};
function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
{label}
{required && <span style={{ color: "oklch(0.577 0.245 27.325)" }}> *</span>}
</label>
{children}
</div>
);
}
function Toggle({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<div className="flex items-start gap-3">
<button type="button" onClick={() => onChange(!checked)} className="mt-0.5 flex-shrink-0">
<span
className="relative inline-block w-10 h-6 transition-colors"
style={{ background: checked ? "var(--accent)" : "var(--border)", border: "2px solid var(--foreground)" }}
>
<span
className="absolute top-0.5 w-4 h-4 transition-transform"
style={{ background: "var(--foreground)", left: "2px", transform: checked ? "translateX(16px)" : "translateX(0)" }}
/>
</span>
</button>
<div>
<p className="text-sm font-medium" style={{ color: "var(--foreground)" }}>{label}</p>
{hint && <p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>{hint}</p>}
</div>
</div>
);
}
export function CreateUserForm() {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(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 (
<form onSubmit={handleSubmit} className="space-y-5 max-w-lg">
{/* Name */}
<Field label="Имя" required>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Иван Иванов"
required
style={inputStyle}
{...focusHandlers}
/>
</Field>
{/* Email */}
<Field label="Email (логин)" required>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
{/* Password */}
<Field label="Пароль" required>
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Минимум 8 символов"
required
minLength={8}
style={{ ...inputStyle, paddingRight: "2.5rem", fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2"
style={{ color: "var(--muted-foreground)" }}
>
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
<button
type="button"
onClick={handleGenerate}
title="Сгенерировать пароль"
className="btn-aubade px-3 flex items-center gap-1.5 text-xs whitespace-nowrap"
>
<RefreshCw size={13} />
Сгенерировать
</button>
</div>
{password && showPassword && (
<p className="text-xs mt-1 font-mono" style={{ color: "var(--muted-foreground)" }}>
{password}
</p>
)}
</Field>
{/* Role */}
<Field label="Роль">
<select
value={role}
onChange={(e) => setRole(e.target.value)}
style={{ ...inputStyle, appearance: "none", cursor: "pointer" }}
{...focusHandlers}
>
{ROLES.map((r) => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
</Field>
{/* Toggles */}
<div className="space-y-3 pt-1">
<Toggle
label="Email подтверждён"
hint="Пользователь сможет войти сразу, без подтверждения почты."
checked={emailVerified}
onChange={setEmailVerified}
/>
<Toggle
label="Отправить приветственное письмо"
hint="Письмо будет отправлено на указанный email."
checked={sendWelcome}
onChange={setSendWelcome}
/>
</div>
{/* Error */}
{error && (
<p className="text-sm px-3 py-2" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
{error}
</p>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={pending}
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm"
style={{ opacity: pending ? 0.6 : 1 }}
>
{pending ? "Создание..." : "Создать пользователя"}
</button>
<button
type="button"
onClick={() => router.back()}
className="btn-aubade px-4 py-2 text-sm"
>
Отмена
</button>
</div>
</form>
);
}