feat(panel): implement user management administration panel with sessions and minecraft link support
This commit is contained in:
28 files changed
+4263
-68
No files matched your search
@@ -4,6 +4,7 @@ import { TierProvider } from "@/lib/tier";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { RequireAdmin } from "@/components/RequireAdmin";
|
||||
import { RequireAuth } from "@/components/RequireAuth";
|
||||
import { RequireOwner } from "@/components/RequireOwner";
|
||||
import { Login } from "@/pages/Login";
|
||||
import { ChangePassword } from "@/pages/ChangePassword";
|
||||
import { Dashboard } from "@/pages/Dashboard";
|
||||
@@ -16,6 +17,8 @@ import { Account } from "@/pages/Account";
|
||||
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
||||
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
|
||||
import { SubmissionsPage } from "@/pages/admin/SubmissionsPage";
|
||||
import { UsersPage } from "@/pages/admin/UsersPage";
|
||||
import { UserDetailPage } from "@/pages/admin/UserDetailPage";
|
||||
import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
|
||||
import { UpdatesPage } from "@/pages/admin/UpdatesPage";
|
||||
|
||||
@@ -61,6 +64,11 @@ export default function App() {
|
||||
<Route path="builds" element={<ImageBuildPage />} />
|
||||
<Route path="submissions" element={<SubmissionsPage />} />
|
||||
<Route path="updates" element={<UpdatesPage />} />
|
||||
{/* Owner-gated: user management (one level above admin). */}
|
||||
<Route element={<RequireOwner />}>
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="users/:id" element={<UserDetailPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -137,12 +137,13 @@ function ThemeToggle() {
|
||||
}
|
||||
|
||||
export function AppShell() {
|
||||
const { isAdmin } = useTier();
|
||||
const { isAdmin, isOwner } = useTier();
|
||||
const { t, i18n } = useTranslation("navigation");
|
||||
// Sections are derived purely from is_admin: User-Side always, Admin/SysAdmin
|
||||
// only for admins. isAdmin is fail-closed (false while /me loads or on failure),
|
||||
// so admin sections appear only once identity is confirmed.
|
||||
const sections = visibleSections(isAdmin);
|
||||
// Sections are derived purely from is_admin and is_owner: User-Side always,
|
||||
// Admin-Side only for admins, Owner-Side only for the platform owner. Both
|
||||
// flags are fail-closed (false while /me loads or on failure), so admin and
|
||||
// owner sections appear only once identity is confirmed.
|
||||
const sections = visibleSections(isAdmin, isOwner);
|
||||
|
||||
// Sync document metadata with the active language.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
onCreated: (id: string) => void;
|
||||
}
|
||||
|
||||
export function CreateUserDialog({ onCreated }: Props) {
|
||||
const { t } = useTranslation("admin");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<"user" | "admin">("user");
|
||||
const [password, setPassword] = useState("");
|
||||
const [mustChange, setMustChange] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
function reset() {
|
||||
setUsername("");
|
||||
setEmail("");
|
||||
setRole("user");
|
||||
setPassword("");
|
||||
setMustChange(true);
|
||||
setErr(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (submitting) return;
|
||||
setErr(null);
|
||||
|
||||
if (!username.trim()) {
|
||||
setErr(t("users_create_validation_username"));
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setErr(t("users_create_validation_password"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const u = await api.createUser({
|
||||
username: username.trim(),
|
||||
email: email.trim() || undefined,
|
||||
role,
|
||||
password,
|
||||
must_change_password: mustChange,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
onCreated(u.id);
|
||||
} catch (e) {
|
||||
setErr(humanizeError(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("users_create_btn")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("users_create_title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("users_create_desc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Username */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">
|
||||
{t("users_field_username")} *
|
||||
</Label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("users_create_username_placeholder")}
|
||||
className="h-9 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">
|
||||
{t("users_field_email")}
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">
|
||||
{t("users_field_role")}
|
||||
</Label>
|
||||
<Select value={role} onValueChange={(v: "user" | "admin") => setRole(v)}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">{t("users_role_user")}</SelectItem>
|
||||
<SelectItem value="admin">{t("users_role_admin")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">
|
||||
{t("users_field_password")} *
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="min. 8 characters"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Must change password toggle */}
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mustChange}
|
||||
onChange={(e) => setMustChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm text-foreground">
|
||||
{t("users_create_must_change")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{err && (
|
||||
<p className="rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={submitting} className="gap-1.5">
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
{t("users_create_btn")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { NotAuthorized } from "@/components/States";
|
||||
import { Loading } from "@/components/States";
|
||||
|
||||
export function RequireOwner() {
|
||||
const { loading, isOwner } = useTier();
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
if (!isOwner) {
|
||||
return <NotAuthorized />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -100,5 +100,94 @@
|
||||
"build_import_submission_none": "No matching submissions found or not loaded",
|
||||
"build_import_submission_hint": "Selecting a submission automatically populates the Image Reference, Context Reference, and the corresponding Dockerfile audit header.",
|
||||
"build_import_submission_warning_title": "Warning: This submission is currently \"{{status}}\"",
|
||||
"build_import_submission_warning_desc": "Manually triggering a build will not automatically mark this submission as approved, nor will it update its associated build status in the database. Use for emergency debugging or testing only."
|
||||
"build_import_submission_warning_desc": "Manually triggering a build will not automatically mark this submission as approved, nor will it update its associated build status in the database. Use for emergency debugging or testing only.",
|
||||
|
||||
"_users_comment": "User administration (admin-tier only).",
|
||||
"users_title": "Users",
|
||||
"users_subtitle": "Manage platform user accounts, quotas, and sessions.",
|
||||
"users_create_btn": "Create User",
|
||||
"users_create_title": "Create New User",
|
||||
"users_create_desc": "Create a new platform account. The user will receive the initial password and will be prompted to change it on first login if the toggle is enabled.",
|
||||
"users_create_username_placeholder": "e.g. alice",
|
||||
"users_create_validation_username": "Username is required.",
|
||||
"users_create_validation_password": "Password must be at least 8 characters.",
|
||||
"users_create_must_change": "Require password change on first login",
|
||||
"users_search_placeholder": "Search username or email...",
|
||||
"users_search_btn": "Search",
|
||||
"users_filter_role_all": "All Roles",
|
||||
"users_filter_status_all": "All Status",
|
||||
"users_status_active": "Active",
|
||||
"users_status_disabled": "Disabled",
|
||||
"users_role_admin": "Admin",
|
||||
"users_role_owner": "Owner",
|
||||
"users_role_user": "User",
|
||||
"users_col_role": "Role",
|
||||
"users_col_servers": "Servers",
|
||||
"users_col_status": "Status",
|
||||
"users_col_created": "Created",
|
||||
"users_view_detail": "Details",
|
||||
"users_total_count": "{{count}} total users",
|
||||
"users_empty_title": "No Users Found",
|
||||
"users_empty_hint": "No users match the current filters.",
|
||||
"users_back_to_list": "Back to user list",
|
||||
"users_edit_profile": "Edit Profile",
|
||||
"users_field_username": "Username",
|
||||
"users_field_email": "Email",
|
||||
"users_field_role": "Role",
|
||||
"users_field_password": "Initial Password",
|
||||
"users_save_btn": "Save Changes",
|
||||
"users_save_ok": "Changes saved successfully.",
|
||||
"users_linked_accounts": "Linked Minecraft Accounts",
|
||||
"users_no_linked": "No Minecraft accounts linked yet.",
|
||||
"users_link_add": "Link Account",
|
||||
"users_link_source": "Auth Source",
|
||||
"users_link_confirm": "Link",
|
||||
"users_unlink_tooltip": "Unlink this Minecraft account",
|
||||
"users_unlink_dlg_title": "Unlink Minecraft Account",
|
||||
"users_unlink_dlg_desc": "Are you sure you want to unlink this Minecraft account? The user will lose any in-game identity tied to this UUID.",
|
||||
"users_unlink_btn": "Unlink",
|
||||
"users_quotas": "Quotas",
|
||||
"users_quota_servers": "Max Servers",
|
||||
"users_quota_cpu": "Max CPU (millicore)",
|
||||
"users_quota_memory": "Max Memory (MiB)",
|
||||
"users_quota_storage": "Max Storage (GiB)",
|
||||
"users_quota_unlimited": "Unlimited",
|
||||
"users_sessions": "Sessions",
|
||||
"users_no_sessions": "No active sessions.",
|
||||
"users_session_expires": "Expires",
|
||||
"users_sessions_revoke_all": "Revoke All",
|
||||
"users_session_revoke_one": "Revoke this session",
|
||||
"users_sessions_revoked": "All sessions revoked.",
|
||||
"users_session_revoke_one_dlg_title": "Revoke Session",
|
||||
"users_session_revoke_one_dlg_desc": "Are you sure you want to revoke this session? The user will be logged out from this device immediately.",
|
||||
"users_session_revoke_all_dlg_title": "Revoke All Sessions",
|
||||
"users_session_revoke_all_dlg_desc": "Are you sure you want to revoke all active sessions? The user will be logged out from every device.",
|
||||
"users_session_revoke_confirm": "Revoke",
|
||||
"users_danger_zone": "Danger Zone",
|
||||
"users_danger_disable": "Disable User",
|
||||
"users_danger_disable_desc": "Prevent this user from logging in. All active sessions will be revoked immediately.",
|
||||
"users_danger_disable_btn": "Disable User",
|
||||
"users_danger_enable": "Enable User",
|
||||
"users_danger_enable_desc": "Allow this user to log in again.",
|
||||
"users_danger_enable_btn": "Enable",
|
||||
"users_danger_reset_pw": "Reset Password",
|
||||
"users_danger_reset_pw_desc": "A high-entropy random password will be generated and the user will be forced to change it on next login. All active sessions are revoked immediately.",
|
||||
"users_danger_reset_pw_desc_email": "A high-entropy random password will be generated and sent to {{email}}. The user will be forced to change it on next login. All active sessions are revoked immediately.",
|
||||
"users_danger_reset_pw_btn": "Reset Password",
|
||||
"users_danger_reset_pw_confirm": "Reset Password",
|
||||
"users_pw_reset_ok": "Password reset. The new password was sent to {{email}}.",
|
||||
"users_danger_disable_dlg_title": "Disable User",
|
||||
"users_danger_disable_dlg_desc": "This user will be unable to log in. All active sessions will be revoked immediately.",
|
||||
"users_danger_enable_dlg_title": "Enable User",
|
||||
"users_danger_enable_dlg_desc": "This user will be able to log in again.",
|
||||
"users_danger_reset_pw_dlg_title": "Reset Password",
|
||||
"users_danger_reset_pw_dlg_desc_email": "A high-entropy random password will be generated and sent to {{email}}. The user will be forced to change it on next login. All existing sessions will be revoked.",
|
||||
"users_danger_reset_pw_dlg_desc_no_email": "A high-entropy random password will be generated. Since this user has no email address, it will be logged server-side. The user will be forced to change it on next login. All existing sessions will be revoked.",
|
||||
"users_danger_delete_dlg_title": "Delete User",
|
||||
"users_danger_delete_dlg_desc": "This action is permanent. The user's owned servers will be released, all sessions revoked, and the account permanently disabled. This cannot be undone through the panel.",
|
||||
"users_danger_delete": "Delete User",
|
||||
"users_danger_delete_desc": "Soft-delete this user. Owned servers are released, all sessions are revoked, and the account is permanently disabled. This action cannot be undone through the panel.",
|
||||
"users_danger_delete_btn": "Delete User",
|
||||
"users_danger_delete_confirm": "This action is permanent. The user's servers will be released and their sessions revoked. Are you absolutely sure?",
|
||||
"users_danger_delete_yes": "Yes, Delete Permanently"
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
"my_submissions": "My Submissions",
|
||||
"account": "Account",
|
||||
"admin_section": "Admin",
|
||||
"owner_section": "Platform",
|
||||
"admin_users": "Users",
|
||||
"admin_images": "Images",
|
||||
"admin_builds": "Build Pipeline",
|
||||
"admin_submissions": "Submissions",
|
||||
|
||||
@@ -100,5 +100,94 @@
|
||||
"build_import_submission_none": "无匹配的提交或暂未加载",
|
||||
"build_import_submission_hint": "选择提交将自动填充镜像引用、构建上下文引用和对应的 Dockerfile 审计头。",
|
||||
"build_import_submission_warning_title": "警告:该提交状态为「{{status}}」",
|
||||
"build_import_submission_warning_desc": "手动触发构建不会自动将该提交标记为已同意,也不会更新其关联的构建状态。仅适用于紧急调试或测试。"
|
||||
"build_import_submission_warning_desc": "手动触发构建不会自动将该提交标记为已同意,也不会更新其关联的构建状态。仅适用于紧急调试或测试。",
|
||||
|
||||
"_users_comment": "用户管理(仅管理员可见)。",
|
||||
"users_title": "用户管理",
|
||||
"users_subtitle": "管理平台用户账号、配额和会话。",
|
||||
"users_create_btn": "创建用户",
|
||||
"users_create_title": "创建新用户",
|
||||
"users_create_desc": "创建一个新的平台账号。用户将收到初始密码,如果开启「首次登录修改密码」,用户将在首次登录时被要求修改密码。",
|
||||
"users_create_username_placeholder": "例如: alice",
|
||||
"users_create_validation_username": "用户名为必填项。",
|
||||
"users_create_validation_password": "密码至少需要 8 个字符。",
|
||||
"users_create_must_change": "要求首次登录修改密码",
|
||||
"users_search_placeholder": "搜索用户名或邮箱...",
|
||||
"users_search_btn": "搜索",
|
||||
"users_filter_role_all": "全部角色",
|
||||
"users_filter_status_all": "全部状态",
|
||||
"users_status_active": "正常",
|
||||
"users_status_disabled": "已禁用",
|
||||
"users_role_admin": "管理员",
|
||||
"users_role_owner": "所有者",
|
||||
"users_role_user": "普通用户",
|
||||
"users_col_role": "角色",
|
||||
"users_col_servers": "服务器数",
|
||||
"users_col_status": "状态",
|
||||
"users_col_created": "创建时间",
|
||||
"users_view_detail": "详情",
|
||||
"users_total_count": "共 {{count}} 个用户",
|
||||
"users_empty_title": "未找到用户",
|
||||
"users_empty_hint": "没有匹配当前筛选条件的用户。",
|
||||
"users_back_to_list": "返回用户列表",
|
||||
"users_edit_profile": "编辑资料",
|
||||
"users_field_username": "用户名",
|
||||
"users_field_email": "邮箱",
|
||||
"users_field_role": "角色",
|
||||
"users_field_password": "初始密码",
|
||||
"users_save_btn": "保存更改",
|
||||
"users_save_ok": "更改保存成功。",
|
||||
"users_linked_accounts": "已关联的 Minecraft 账号",
|
||||
"users_no_linked": "尚未关联任何 Minecraft 账号。",
|
||||
"users_link_add": "关联账号",
|
||||
"users_link_source": "验证源",
|
||||
"users_link_confirm": "关联",
|
||||
"users_unlink_tooltip": "解除此 Minecraft 账号关联",
|
||||
"users_unlink_dlg_title": "解除 Minecraft 关联",
|
||||
"users_unlink_dlg_desc": "确定解除此 Minecraft 账号的关联吗?用户将失去该 UUID 绑定的游戏内身份。",
|
||||
"users_unlink_btn": "解除关联",
|
||||
"users_quotas": "配额",
|
||||
"users_quota_servers": "最大服务器数",
|
||||
"users_quota_cpu": "最大 CPU(毫核)",
|
||||
"users_quota_memory": "最大内存(MiB)",
|
||||
"users_quota_storage": "最大存储(GiB)",
|
||||
"users_quota_unlimited": "无限制",
|
||||
"users_sessions": "活跃会话",
|
||||
"users_no_sessions": "无活跃会话。",
|
||||
"users_session_expires": "过期时间",
|
||||
"users_sessions_revoke_all": "全部撤销",
|
||||
"users_session_revoke_one": "单独撤销",
|
||||
"users_sessions_revoked": "所有会话已撤销。",
|
||||
"users_session_revoke_one_dlg_title": "撤销会话",
|
||||
"users_session_revoke_one_dlg_desc": "确定撤销此会话吗?用户将立即从该设备登出。",
|
||||
"users_session_revoke_all_dlg_title": "撤销所有会话",
|
||||
"users_session_revoke_all_dlg_desc": "确定撤销所有活跃会话吗?用户将从所有设备登出。",
|
||||
"users_session_revoke_confirm": "撤销",
|
||||
"users_danger_zone": "危险操作区",
|
||||
"users_danger_disable": "禁用用户",
|
||||
"users_danger_disable_desc": "阻止此用户登录。所有活跃会话将被立即撤销。",
|
||||
"users_danger_disable_btn": "禁用用户",
|
||||
"users_danger_enable": "启用用户",
|
||||
"users_danger_enable_desc": "允许此用户重新登录。",
|
||||
"users_danger_enable_btn": "启用",
|
||||
"users_danger_reset_pw": "重置密码",
|
||||
"users_danger_reset_pw_desc": "将生成高熵随机密码,用户下次登录时将被强制修改密码。所有活跃会话将被立即撤销。",
|
||||
"users_danger_reset_pw_desc_email": "将生成高熵随机密码并发送至 {{email}}。用户下次登录时将被强制修改密码。所有活跃会话将被立即撤销。",
|
||||
"users_danger_reset_pw_btn": "重置密码",
|
||||
"users_danger_reset_pw_confirm": "确认重置",
|
||||
"users_pw_reset_ok": "密码已重置,新密码已发送至 {{email}}。",
|
||||
"users_danger_disable_dlg_title": "禁用用户",
|
||||
"users_danger_disable_dlg_desc": "此用户将无法登录。所有活跃会话将被立即撤销。",
|
||||
"users_danger_enable_dlg_title": "启用用户",
|
||||
"users_danger_enable_dlg_desc": "此用户将可以重新登录。",
|
||||
"users_danger_reset_pw_dlg_title": "重置密码",
|
||||
"users_danger_reset_pw_dlg_desc_email": "将生成高熵随机密码并发送至 {{email}}。用户下次登录时将被强制修改密码。所有现有会话将被撤销。",
|
||||
"users_danger_reset_pw_dlg_desc_no_email": "将生成高熵随机密码。由于此用户未设置邮箱地址,密码将记录在服务端日志中。用户下次登录时将被强制修改密码。所有现有会话将被撤销。",
|
||||
"users_danger_delete_dlg_title": "删除用户",
|
||||
"users_danger_delete_dlg_desc": "此操作不可逆。该用户拥有的所有服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
|
||||
"users_danger_delete": "删除用户",
|
||||
"users_danger_delete_desc": "软删除此用户。其拥有的服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
|
||||
"users_danger_delete_btn": "删除用户",
|
||||
"users_danger_delete_confirm": "此操作不可逆。该用户的服务器将被释放,会话将被撤销。确定要执行吗?",
|
||||
"users_danger_delete_yes": "是的,永久删除"
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
"my_submissions": "我的提交",
|
||||
"account": "账户",
|
||||
"admin_section": "管理",
|
||||
"owner_section": "平台",
|
||||
"admin_users": "用户管理",
|
||||
"admin_images": "镜像",
|
||||
"admin_builds": "构建流水线",
|
||||
"admin_submissions": "审核提交",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
BanlistResult,
|
||||
Build,
|
||||
CreateServerRequest,
|
||||
CreateUserRequest,
|
||||
FleetServer,
|
||||
Identity,
|
||||
KickResult,
|
||||
@@ -13,8 +14,14 @@ import type {
|
||||
LinkStatus,
|
||||
LoginResult,
|
||||
BindResult,
|
||||
PatchUserRequest,
|
||||
PlayersResult,
|
||||
QuotaInput,
|
||||
QuotaView,
|
||||
ServerInfo,
|
||||
SessionView,
|
||||
UserDetail,
|
||||
UserView,
|
||||
WhitelistImage,
|
||||
WhitelistResult,
|
||||
Submission,
|
||||
@@ -339,6 +346,72 @@ export const api = {
|
||||
getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/window"),
|
||||
|
||||
setUpdateWindow: (window: UpdateWindow) => request<UpdateWindow>("PUT", "/updates/window", window),
|
||||
|
||||
// ---- User admin (admin-tier, spec §7 user admin) ----
|
||||
|
||||
listUsers: (params?: {
|
||||
query?: string;
|
||||
role?: "admin" | "user";
|
||||
disabled?: "true" | "false";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.query) sp.set("query", params.query);
|
||||
if (params?.role) sp.set("role", params.role);
|
||||
if (params?.disabled) sp.set("disabled", params.disabled);
|
||||
if (params?.limit) sp.set("limit", String(params.limit));
|
||||
if (params?.offset) sp.set("offset", String(params.offset));
|
||||
const qs = sp.toString();
|
||||
return request<{ users: UserView[]; total: number }>(
|
||||
"GET",
|
||||
`/users${qs ? `?${qs}` : ""}`,
|
||||
).then((r) => ({ users: r.users ?? [], total: r.total ?? 0 }));
|
||||
},
|
||||
|
||||
getUser: (id: string) => request<UserDetail>("GET", `/users/${id}`),
|
||||
|
||||
createUser: (req: CreateUserRequest) =>
|
||||
request<UserView>("POST", "/users", req),
|
||||
|
||||
patchUser: (id: string, patch: PatchUserRequest) =>
|
||||
request<UserView>("PATCH", `/users/${id}`, patch),
|
||||
|
||||
deleteUser: (id: string) =>
|
||||
request<{ deleted: boolean }>("DELETE", `/users/${id}`),
|
||||
|
||||
disableUser: (id: string, disabled: boolean) =>
|
||||
request<{ id: string; disabled: boolean }>("POST", `/users/${id}/disable`, { disabled }),
|
||||
|
||||
resetUserPassword: (id: string) =>
|
||||
request<{ ok: boolean; email: string }>("POST", `/users/${id}/reset-password`),
|
||||
|
||||
getUserQuotas: (id: string) => request<QuotaView>("GET", `/users/${id}/quotas`),
|
||||
|
||||
setUserQuotas: (id: string, quotas: QuotaInput) =>
|
||||
request<QuotaView>("PUT", `/users/${id}/quotas`, quotas),
|
||||
|
||||
listUserSessions: (id: string) =>
|
||||
request<{ sessions: SessionView[] }>("GET", `/users/${id}/sessions`).then((r) => r.sessions ?? []),
|
||||
|
||||
revokeUserSessions: (id: string) =>
|
||||
request<{ ok: boolean }>("DELETE", `/users/${id}/sessions`),
|
||||
|
||||
revokeUserSession: (id: string, hash: string) =>
|
||||
request<{ ok: boolean }>("DELETE", `/users/${id}/sessions/${encodeURIComponent(hash)}`),
|
||||
|
||||
linkAccount: (id: string, mcUuid: string, authSource?: string) =>
|
||||
request<{ ok: boolean; mc_uuid: string; auth_source: string }>(
|
||||
"POST",
|
||||
`/users/${id}/links`,
|
||||
{ mc_uuid: mcUuid, auth_source: authSource ?? "mojang" },
|
||||
),
|
||||
|
||||
unlinkAccount: (id: string, mcUuid: string) =>
|
||||
request<{ ok: boolean; mc_uuid: string }>(
|
||||
"DELETE",
|
||||
`/users/${id}/links/${encodeURIComponent(mcUuid)}`,
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+24
-4
@@ -2,6 +2,7 @@ import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
UserRound,
|
||||
Users,
|
||||
Boxes,
|
||||
Cpu,
|
||||
ClipboardCheck,
|
||||
@@ -30,11 +31,14 @@ export interface NavItem {
|
||||
}
|
||||
|
||||
export interface NavSection {
|
||||
id: "user" | "admin";
|
||||
id: "user" | "admin" | "owner";
|
||||
/** Section heading key; null renders no heading (User-Side flat list). */
|
||||
titleKey: string | null;
|
||||
/** When true the section is shown only to admins (is_admin === true). */
|
||||
adminOnly: boolean;
|
||||
/** When true the section is shown only to the owner (is_owner === true).
|
||||
* An owner-visible section is implicitly invisible to a plain admin. */
|
||||
ownerOnly: boolean;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
@@ -43,6 +47,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
id: "user",
|
||||
titleKey: null,
|
||||
adminOnly: false,
|
||||
ownerOnly: false,
|
||||
items: [
|
||||
{ to: "/", key: "dashboard", icon: LayoutDashboard, end: true },
|
||||
{ to: "/servers", key: "my_servers", icon: Server },
|
||||
@@ -54,6 +59,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
id: "admin",
|
||||
titleKey: "admin_section",
|
||||
adminOnly: true,
|
||||
ownerOnly: false,
|
||||
items: [
|
||||
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
||||
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
||||
@@ -61,14 +67,28 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
{ to: "/admin/updates", key: "admin_updates", icon: Clock },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "owner",
|
||||
titleKey: "owner_section",
|
||||
adminOnly: false,
|
||||
ownerOnly: true,
|
||||
items: [
|
||||
{ to: "/admin/users", key: "admin_users", icon: Users },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* visibleSections returns the sections a caller with the given admin flag may see.
|
||||
* Pure and total: a non-admin (or the fail-closed `false` used while /me is still
|
||||
* loading or after it errors) gets exactly the User-Side section; an admin gets all
|
||||
* three. This is the single decision the sidebar renders from.
|
||||
* admin sections; an owner additionally gets the owner-gated sections. This is the
|
||||
* single decision the sidebar renders from.
|
||||
*/
|
||||
export function visibleSections(isAdmin: boolean): NavSection[] {
|
||||
return NAV_SECTIONS.filter((s) => !s.adminOnly || isAdmin);
|
||||
export function visibleSections(isAdmin: boolean, isOwner: boolean): NavSection[] {
|
||||
return NAV_SECTIONS.filter((s) => {
|
||||
if (s.ownerOnly) return isOwner;
|
||||
if (s.adminOnly) return isAdmin;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,9 @@ import { deriveAuth, type AuthState } from "./auth";
|
||||
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
|
||||
|
||||
export interface TierState extends AuthState {
|
||||
/** Owner (platform-level, one above admin) — exposed so nav can show the Users
|
||||
* section only to the owner identity. Computed server-side, fail-closed. */
|
||||
isOwner: boolean;
|
||||
/** Re-fetch /me and recompute the auth state. Awaitable so callers can sequence a
|
||||
* navigation after the context has settled (login → refresh → redirect). */
|
||||
refresh: () => Promise<void>;
|
||||
@@ -42,6 +45,7 @@ const TierContext = createContext<TierState>({
|
||||
identity: null,
|
||||
loading: true,
|
||||
isAdmin: false,
|
||||
isOwner: false,
|
||||
unauthenticated: false,
|
||||
mustChangePassword: false,
|
||||
refresh: async () => {},
|
||||
@@ -82,9 +86,13 @@ export function TierProvider({ children }: { children: ReactNode }) {
|
||||
}, [refresh]);
|
||||
|
||||
const state = deriveAuth(identity, error, loading);
|
||||
// isOwner is separate from isAdmin: an owner implicitly passes isAdmin (the
|
||||
// backend grades by operation), but isOwner gates user management. Both are
|
||||
// fail-closed — identity === null or missing fields → false.
|
||||
const isOwner = identity?.is_owner === true;
|
||||
|
||||
return (
|
||||
<TierContext.Provider value={{ ...state, refresh }}>
|
||||
<TierContext.Provider value={{ ...state, isOwner, refresh }}>
|
||||
{children}
|
||||
</TierContext.Provider>
|
||||
);
|
||||
|
||||
+67
-2
@@ -208,8 +208,11 @@ export interface ApiError {
|
||||
export interface Identity {
|
||||
user_id: string;
|
||||
email: string;
|
||||
role: "user" | "admin";
|
||||
role: "user" | "admin" | "owner";
|
||||
is_admin: boolean;
|
||||
/** Server-computed Principal.IsOwner() — true only for the platform-level
|
||||
* owner account (one above admin). Owners get user management; admins don't. */
|
||||
is_owner: boolean;
|
||||
/** Local-password path only: the account owes a forced first-login password
|
||||
* change. The JWT/Access path always leaves it false. Like `is_admin` it crosses
|
||||
* the untyped fetch().json() boundary, so consumers MUST compare `=== true` — an
|
||||
@@ -224,7 +227,7 @@ export interface Identity {
|
||||
* change-password card before any other surface. */
|
||||
export interface LoginResult {
|
||||
user_id: string;
|
||||
role: "user" | "admin";
|
||||
role: "user" | "admin" | "owner";
|
||||
must_change_password: boolean;
|
||||
}
|
||||
|
||||
@@ -274,3 +277,65 @@ export interface UpdateWindow {
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
}
|
||||
|
||||
// ---- User admin types (internal/api/repo.go UserView, UserDetail, QuotaView, SessionView) ----
|
||||
|
||||
export interface UserView {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: "admin" | "user" | "owner";
|
||||
disabled: boolean;
|
||||
email_verified: boolean;
|
||||
server_count: number;
|
||||
must_change_password: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LinkedAccount {
|
||||
mc_uuid: string;
|
||||
auth_source: string;
|
||||
verified_at: string;
|
||||
}
|
||||
|
||||
export interface UserDetail extends UserView {
|
||||
deleted_at?: string | null;
|
||||
linked_accounts: LinkedAccount[];
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
email?: string;
|
||||
role: "admin" | "user";
|
||||
password: string;
|
||||
must_change_password: boolean;
|
||||
}
|
||||
|
||||
export interface PatchUserRequest {
|
||||
username?: string;
|
||||
email?: string;
|
||||
role?: "admin" | "user";
|
||||
}
|
||||
|
||||
export interface QuotaView {
|
||||
user_id: string;
|
||||
max_servers?: number | null;
|
||||
max_cpu_milli?: number | null;
|
||||
max_memory_mb?: number | null;
|
||||
max_storage_gb?: number | null;
|
||||
}
|
||||
|
||||
export interface QuotaInput {
|
||||
max_servers?: number | null;
|
||||
max_cpu_milli?: number | null;
|
||||
max_memory_mb?: number | null;
|
||||
max_storage_gb?: number | null;
|
||||
}
|
||||
|
||||
export interface SessionView {
|
||||
token_hash: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
revoked_at?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,949 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
UserRound,
|
||||
Mail,
|
||||
Shield,
|
||||
Crown,
|
||||
Calendar,
|
||||
Circle,
|
||||
Key,
|
||||
Clock,
|
||||
Trash2,
|
||||
Power,
|
||||
PowerOff,
|
||||
Save,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
Unlink,
|
||||
Link,
|
||||
X,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Loading, ErrorState } from "@/components/States";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { formatAbsolute } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UserDetail, SessionView } from "@/lib/types";
|
||||
|
||||
export function UserDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation("admin");
|
||||
const locale = i18n.language;
|
||||
const { identity } = useTier();
|
||||
const isSelf = identity?.user_id === id;
|
||||
|
||||
const { data: user, error, loading, reload } = useAsync(
|
||||
() => api.getUser(id!),
|
||||
[id],
|
||||
);
|
||||
|
||||
if (loading && !user) return <Loading />;
|
||||
if (error) return <ErrorState error={error} onRetry={reload} />;
|
||||
if (!user) return <ErrorState error={new Error("user not found")} />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back link */}
|
||||
<button
|
||||
onClick={() => navigate("/admin/users")}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("users_back_to_list")}
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"rounded-full p-2",
|
||||
user.role === "admin" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
<UserRound className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{user.username}</h1>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1 text-sm text-muted-foreground">
|
||||
{user.email && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
{user.email}
|
||||
{user.email_verified && (
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{formatAbsolute(user.created_at, locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{user.disabled ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-3 py-1 text-xs font-medium text-destructive">
|
||||
<Circle className="h-2 w-2 fill-destructive" />
|
||||
{t("users_status_disabled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-500">
|
||||
<Circle className="h-2 w-2 fill-emerald-500" />
|
||||
{t("users_status_active")}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium",
|
||||
user.role === "owner"
|
||||
? "bg-yellow-500/10 text-yellow-600"
|
||||
: user.role === "admin"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{user.role === "owner" ? (
|
||||
<Crown className="h-3 w-3" />
|
||||
) : (
|
||||
<Shield className="h-3 w-3" />
|
||||
)}
|
||||
{user.role === "owner" ? t("users_role_owner") : user.role === "admin" ? t("users_role_admin") : t("users_role_user")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Edit profile */}
|
||||
<EditProfileCard user={user} onSaved={reload} isSelf={identity?.user_id === id} />
|
||||
{/* Linked accounts */}
|
||||
<LinkedAccountsCard user={user} onChanged={reload} />
|
||||
{/* Quotas */}
|
||||
<QuotasCard userId={user.id} />
|
||||
{/* Sessions */}
|
||||
<SessionsCard userId={user.id} onChanged={reload} />
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
<DangerZone user={user} onChanged={reload} navigate={navigate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved: () => void; isSelf: boolean }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const [username, setUsername] = useState(user.username);
|
||||
const [email, setEmail] = useState(user.email ?? "");
|
||||
const [role, setRole] = useState(user.role);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
|
||||
const dirty = username !== user.username || email !== (user.email ?? "") || role !== user.role;
|
||||
|
||||
async function handleSave() {
|
||||
if (!dirty) return;
|
||||
setSaving(true);
|
||||
setErr(null);
|
||||
setOk(null);
|
||||
try {
|
||||
const patch: any = {};
|
||||
if (username !== user.username) patch.username = username;
|
||||
if (email !== (user.email ?? "")) patch.email = email;
|
||||
if (role !== user.role) patch.role = role;
|
||||
await api.patchUser(user.id, patch);
|
||||
setOk(t("users_save_ok"));
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setErr(humanizeError(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">{t("users_edit_profile")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_username")}</Label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_email")}</Label>
|
||||
<Input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{!isSelf && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_role")}</Label>
|
||||
<Select value={role} onValueChange={(v: "admin" | "user") => setRole(v)}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">{t("users_role_user")}</SelectItem>
|
||||
<SelectItem value="admin">{t("users_role_admin")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<p>{err}</p>
|
||||
</div>
|
||||
)}
|
||||
{ok && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<p>{ok}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t("users_save_btn")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged: () => void }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const accounts = user.linked_accounts ?? [];
|
||||
const [unlinking, setUnlinking] = useState<string | null>(null);
|
||||
const [unlinkDlg, setUnlinkDlg] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newUUID, setNewUUID] = useState("");
|
||||
const [newSource, setNewSource] = useState("mojang");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function handleUnlinkConfirm() {
|
||||
if (!unlinkDlg) return;
|
||||
const mcUuid = unlinkDlg;
|
||||
setUnlinking(mcUuid);
|
||||
setErr(null);
|
||||
try {
|
||||
await api.unlinkAccount(user.id, mcUuid);
|
||||
setUnlinkDlg(null);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setErr(humanizeError(e));
|
||||
} finally {
|
||||
setUnlinking(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newUUID.trim()) return;
|
||||
setAdding(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await api.linkAccount(user.id, newUUID.trim(), newSource);
|
||||
setNewUUID("");
|
||||
setShowAdd(false);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setErr(humanizeError(e));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("users_linked_accounts")} ({accounts.length})
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 text-xs"
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
>
|
||||
<Link className="h-3.5 w-3.5" />
|
||||
{showAdd ? t("common:cancel") : t("users_link_add")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Add form */}
|
||||
{showAdd && (
|
||||
<form onSubmit={handleAdd} className="space-y-3 rounded-md border border-border/50 bg-muted/20 p-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">Minecraft UUID</Label>
|
||||
<Input
|
||||
value={newUUID}
|
||||
onChange={(e) => setNewUUID(e.target.value)}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28 space-y-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">{t("users_link_source")}</Label>
|
||||
<Select value={newSource} onValueChange={setNewSource}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mojang">Mojang</SelectItem>
|
||||
<SelectItem value="thirdparty">Third-party Yggdrasil</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{err && (
|
||||
<p className="text-xs text-destructive">{err}</p>
|
||||
)}
|
||||
<Button type="submit" size="sm" disabled={adding || !newUUID.trim()} className="h-8 text-xs gap-1">
|
||||
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link className="h-3.5 w-3.5" />}
|
||||
{t("users_link_confirm")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{accounts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("users_no_linked")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{accounts.map((acc) => (
|
||||
<div key={acc.mc_uuid} className="flex items-center justify-between rounded-md border border-border/50 bg-muted/20 pl-3 pr-1 py-2 text-sm">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-mono text-xs truncate block">{acc.mc_uuid}</span>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{acc.auth_source} · {formatAbsolute(acc.verified_at, "en-US")}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0 mr-0.5"
|
||||
disabled={unlinking === acc.mc_uuid}
|
||||
onClick={() => setUnlinkDlg(acc.mc_uuid)}
|
||||
title={t("users_unlink_tooltip")}
|
||||
>
|
||||
{unlinking === acc.mc_uuid ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Unlink confirmation dialog */}
|
||||
<Dialog open={!!unlinkDlg} onOpenChange={() => setUnlinkDlg(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("users_unlink_dlg_title")}</DialogTitle>
|
||||
<DialogDescription>{t("users_unlink_dlg_desc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setUnlinkDlg(null)}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleUnlinkConfirm}>
|
||||
{t("users_unlink_btn")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotasCard({ userId }: { userId: string }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const { data: quotas, error, loading, reload } = useAsync(
|
||||
() => api.getUserQuotas(userId),
|
||||
[userId],
|
||||
);
|
||||
|
||||
const [maxServers, setMaxServers] = useState<string>("");
|
||||
const [maxCpu, setMaxCpu] = useState<string>("");
|
||||
const [maxMem, setMaxMem] = useState<string>("");
|
||||
const [maxStorage, setMaxStorage] = useState<string>("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (quotas) {
|
||||
setMaxServers(quotas.max_servers != null ? String(quotas.max_servers) : "");
|
||||
setMaxCpu(quotas.max_cpu_milli != null ? String(quotas.max_cpu_milli) : "");
|
||||
setMaxMem(quotas.max_memory_mb != null ? String(quotas.max_memory_mb) : "");
|
||||
setMaxStorage(quotas.max_storage_gb != null ? String(quotas.max_storage_gb) : "");
|
||||
}
|
||||
}, [quotas]);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setErr(null);
|
||||
setOk(null);
|
||||
try {
|
||||
const toNum = (s: string) => (s === "" ? null : parseInt(s, 10));
|
||||
await api.setUserQuotas(userId, {
|
||||
max_servers: toNum(maxServers),
|
||||
max_cpu_milli: toNum(maxCpu),
|
||||
max_memory_mb: toNum(maxMem),
|
||||
max_storage_gb: toNum(maxStorage),
|
||||
});
|
||||
setOk(t("users_save_ok"));
|
||||
reload();
|
||||
} catch (e) {
|
||||
setErr(humanizeError(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !quotas) return <Loading label={t("common:loading")} />;
|
||||
if (error) return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base font-semibold">{t("users_quotas")}</CardTitle></CardHeader>
|
||||
<CardContent><ErrorState error={error} onRetry={reload} /></CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">{t("users_quotas")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_quota_servers")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={maxServers}
|
||||
onChange={(e) => setMaxServers(e.target.value)}
|
||||
placeholder={t("users_quota_unlimited")}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_quota_cpu")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={maxCpu}
|
||||
onChange={(e) => setMaxCpu(e.target.value)}
|
||||
placeholder={t("users_quota_unlimited")}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_quota_memory")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={maxMem}
|
||||
onChange={(e) => setMaxMem(e.target.value)}
|
||||
placeholder={t("users_quota_unlimited")}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">{t("users_quota_storage")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={maxStorage}
|
||||
onChange={(e) => setMaxStorage(e.target.value)}
|
||||
placeholder={t("users_quota_unlimited")}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<p>{err}</p>
|
||||
</div>
|
||||
)}
|
||||
{ok && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<p>{ok}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={saving} size="sm" className="gap-1.5">
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
{t("users_save_btn")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () => void }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const { data: sessions, error, loading, reload } = useAsync(
|
||||
() => api.listUserSessions(userId),
|
||||
[userId],
|
||||
);
|
||||
const [revoking, setRevoking] = useState<string | null>(null);
|
||||
const [revokingAll, setRevokingAll] = useState(false);
|
||||
const [revokeOneDlg, setRevokeOneDlg] = useState<string | null>(null);
|
||||
const [revokeAllDlg, setRevokeAllDlg] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
|
||||
async function handleRevokeOne() {
|
||||
if (!revokeOneDlg) return;
|
||||
const hash = revokeOneDlg;
|
||||
setRevoking(hash);
|
||||
setErr(null);
|
||||
try {
|
||||
await api.revokeUserSession(userId, hash);
|
||||
setRevokeOneDlg(null);
|
||||
await reload();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setErr(humanizeError(e));
|
||||
} finally {
|
||||
setRevoking(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevokeAll() {
|
||||
setRevokingAll(true);
|
||||
setErr(null);
|
||||
setOk(null);
|
||||
try {
|
||||
await api.revokeUserSessions(userId);
|
||||
setRevokeAllDlg(false);
|
||||
setOk(t("users_sessions_revoked"));
|
||||
await reload();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setErr(humanizeError(e));
|
||||
} finally {
|
||||
setRevokingAll(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("users_sessions")} ({sessions?.length ?? 0})
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
disabled={!sessions || sessions.length === 0 || revokingAll}
|
||||
onClick={() => setRevokeAllDlg(true)}
|
||||
>
|
||||
{revokingAll ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("users_sessions_revoke_all")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{err && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive mb-3">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<p>{err}</p>
|
||||
</div>
|
||||
)}
|
||||
{ok && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500 mb-3">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<p>{ok}</p>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<Loading label="" />
|
||||
) : error ? (
|
||||
<ErrorState error={error} onRetry={reload} />
|
||||
) : !sessions || sessions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("users_no_sessions")}</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[350px] overflow-y-auto">
|
||||
{sessions.map((s: SessionView) => (
|
||||
<div
|
||||
key={s.token_hash}
|
||||
className="flex items-center justify-between rounded-md border border-border/50 bg-muted/20 pl-3 pr-1 py-2 text-xs"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-mono text-[11px] text-muted-foreground truncate block">
|
||||
{s.token_hash.slice(0, 20)}...
|
||||
</span>
|
||||
<div className="mt-0.5 text-muted-foreground/70">
|
||||
<Clock className="inline h-3 w-3 mr-0.5" />
|
||||
{t("users_session_expires")}: {formatAbsolute(s.expires_at, "en-US")}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0 ml-2 mr-0.5"
|
||||
disabled={revoking === s.token_hash}
|
||||
onClick={() => setRevokeOneDlg(s.token_hash)}
|
||||
title={t("users_session_revoke_one")}
|
||||
>
|
||||
{revoking === s.token_hash ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Revoke one confirmation dialog */}
|
||||
<Dialog open={!!revokeOneDlg} onOpenChange={() => setRevokeOneDlg(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("users_session_revoke_one_dlg_title")}</DialogTitle>
|
||||
<DialogDescription>{t("users_session_revoke_one_dlg_desc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setRevokeOneDlg(null)}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleRevokeOne}>
|
||||
{t("users_session_revoke_confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Revoke all confirmation dialog */}
|
||||
<Dialog open={revokeAllDlg} onOpenChange={setRevokeAllDlg}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("users_session_revoke_all_dlg_title")}</DialogTitle>
|
||||
<DialogDescription>{t("users_session_revoke_all_dlg_desc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setRevokeAllDlg(false)}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleRevokeAll}>
|
||||
{t("users_session_revoke_confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DangerZone({
|
||||
user,
|
||||
onChanged,
|
||||
navigate,
|
||||
}: {
|
||||
user: UserDetail;
|
||||
onChanged: () => void;
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("admin");
|
||||
const [dlg, setDlg] = useState<"disable" | "resetPw" | "delete" | null>(null);
|
||||
|
||||
return (
|
||||
<Card className="border-destructive/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold text-destructive">{t("users_danger_zone")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{/* Enable / Disable */}
|
||||
<DangerRow
|
||||
icon={user.disabled ? Power : PowerOff}
|
||||
title={user.disabled ? t("users_danger_enable") : t("users_danger_disable")}
|
||||
desc={user.disabled ? t("users_danger_enable_desc") : t("users_danger_disable_desc")}
|
||||
btnLabel={user.disabled ? t("users_danger_enable_btn") : t("users_danger_disable_btn")}
|
||||
btnVariant={user.disabled ? "default" : "destructive"}
|
||||
onAction={() => setDlg("disable")}
|
||||
/>
|
||||
|
||||
{/* Reset password */}
|
||||
<DangerRow
|
||||
icon={Key}
|
||||
title={t("users_danger_reset_pw")}
|
||||
desc={user.email
|
||||
? t("users_danger_reset_pw_desc_email", { email: user.email })
|
||||
: t("users_danger_reset_pw_desc")}
|
||||
btnLabel={t("users_danger_reset_pw_btn")}
|
||||
btnVariant="destructive"
|
||||
onAction={() => setDlg("resetPw")}
|
||||
/>
|
||||
|
||||
{/* Delete user */}
|
||||
<DangerRow
|
||||
icon={Trash2}
|
||||
title={t("users_danger_delete")}
|
||||
desc={t("users_danger_delete_desc")}
|
||||
btnLabel={t("users_danger_delete_btn")}
|
||||
btnVariant="destructive"
|
||||
onAction={() => setDlg("delete")}
|
||||
/>
|
||||
|
||||
<DangerDialogs dlg={dlg} setDlg={setDlg} user={user} onChanged={onChanged} navigate={navigate} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DangerRow({
|
||||
icon: Icon,
|
||||
title,
|
||||
desc,
|
||||
btnLabel,
|
||||
btnVariant,
|
||||
onAction,
|
||||
}: {
|
||||
icon: typeof Power;
|
||||
title: string;
|
||||
desc: string;
|
||||
btnLabel: string;
|
||||
btnVariant: "default" | "destructive" | "outline";
|
||||
onAction: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/50 bg-muted/20 p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{desc}</p>
|
||||
</div>
|
||||
<Button variant={btnVariant} size="sm" onClick={onAction} className="gap-1.5">
|
||||
<Icon className="h-4 w-4" />
|
||||
{btnLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DangerDialogs({
|
||||
dlg,
|
||||
setDlg,
|
||||
user,
|
||||
onChanged,
|
||||
navigate,
|
||||
}: {
|
||||
dlg: "disable" | "resetPw" | "delete" | null;
|
||||
setDlg: (v: null) => void;
|
||||
user: UserDetail;
|
||||
onChanged: () => void;
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("admin");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
|
||||
function close() {
|
||||
setDlg(null);
|
||||
setErr(null);
|
||||
setOk(null);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleDisable() {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await api.disableUser(user.id, !user.disabled);
|
||||
close();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setErr(humanizeError(e));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await api.resetUserPassword(user.id);
|
||||
setOk(t("users_pw_reset_ok", { email: r.email }));
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setErr(humanizeError(e));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await api.deleteUser(user.id);
|
||||
navigate("/admin/users");
|
||||
} catch (e) {
|
||||
setErr(humanizeError(e));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Disable / Enable dialog */}
|
||||
<Dialog open={dlg === "disable"} onOpenChange={(v) => { if (!v) close(); }}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
{user.disabled ? t("users_danger_enable_dlg_title") : t("users_danger_disable_dlg_title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{user.disabled ? t("users_danger_enable_dlg_desc") : t("users_danger_disable_dlg_desc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{err && <p className="text-sm text-destructive">{err}</p>}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={close} disabled={loading}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={user.disabled ? "default" : "destructive"}
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
onClick={handleDisable}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{user.disabled ? t("users_danger_enable_btn") : t("users_danger_disable_btn")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Reset password dialog */}
|
||||
<Dialog open={dlg === "resetPw"} onOpenChange={(v) => { if (!v) close(); }}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-primary" />
|
||||
{t("users_danger_reset_pw_dlg_title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{user.email
|
||||
? t("users_danger_reset_pw_dlg_desc_email", { email: user.email })
|
||||
: t("users_danger_reset_pw_dlg_desc_no_email")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{err && <p className="text-sm text-destructive">{err}</p>}
|
||||
{ok && (
|
||||
<p className="rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500">
|
||||
<CheckCircle2 className="inline h-4 w-4 mr-1" />
|
||||
{ok}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={close} disabled={loading}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={loading || ok !== null}
|
||||
onClick={handleResetPassword}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t("users_danger_reset_pw_confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete user dialog */}
|
||||
<Dialog open={dlg === "delete"} onOpenChange={(v) => { if (!v) close(); }}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
{t("users_danger_delete_dlg_title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("users_danger_delete_dlg_desc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{err && <p className="text-sm text-destructive">{err}</p>}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={close} disabled={loading}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
onClick={handleDelete}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t("users_danger_delete_yes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Users,
|
||||
Search,
|
||||
Circle,
|
||||
Shield,
|
||||
UserRound,
|
||||
Crown,
|
||||
Server,
|
||||
Mail,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
||||
import { CreateUserDialog } from "@/components/CreateUserDialog";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import { formatAbsolute } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UserView } from "@/lib/types";
|
||||
|
||||
export function UsersPage() {
|
||||
const { t, i18n } = useTranslation("admin");
|
||||
const locale = i18n.language;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("");
|
||||
const [disabledFilter, setDisabledFilter] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 20;
|
||||
|
||||
const fetchUsers = useCallback(
|
||||
() =>
|
||||
api.listUsers({
|
||||
query: query || undefined,
|
||||
role: (roleFilter || undefined) as "admin" | "user" | undefined,
|
||||
disabled: (disabledFilter || undefined) as "true" | "false" | undefined,
|
||||
limit: pageSize,
|
||||
offset: page * pageSize,
|
||||
}),
|
||||
[query, roleFilter, disabledFilter, page],
|
||||
);
|
||||
|
||||
const { data, error, loading, reload } = useAsync(fetchUsers, [fetchUsers]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(0);
|
||||
reload();
|
||||
};
|
||||
|
||||
const totalPages = data ? Math.max(1, Math.ceil(data.total / pageSize)) : 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("users_title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("users_subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<CreateUserDialog
|
||||
onCreated={(id) => {
|
||||
reload();
|
||||
navigate(`/admin/users/${id}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<form onSubmit={handleSearch} className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[200px] flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("users_search_placeholder")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-8 h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={(v) => { setRoleFilter(v); setPage(0); }}>
|
||||
<SelectTrigger className="h-9 w-[120px] text-sm">
|
||||
<SelectValue placeholder={t("users_filter_role_all")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t("users_filter_role_all")}</SelectItem>
|
||||
<SelectItem value="owner">{t("users_role_owner")}</SelectItem>
|
||||
<SelectItem value="admin">{t("users_role_admin")}</SelectItem>
|
||||
<SelectItem value="user">{t("users_role_user")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={disabledFilter} onValueChange={(v) => { setDisabledFilter(v); setPage(0); }}>
|
||||
<SelectTrigger className="h-9 w-[130px] text-sm">
|
||||
<SelectValue placeholder={t("users_filter_status_all")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t("users_filter_status_all")}</SelectItem>
|
||||
<SelectItem value="false">{t("users_status_active")}</SelectItem>
|
||||
<SelectItem value="true">{t("users_status_disabled")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="submit" variant="outline" size="sm" className="h-9 text-sm">
|
||||
{t("users_search_btn")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
{loading && !data ? (
|
||||
<Loading />
|
||||
) : error ? (
|
||||
<ErrorState error={error} onRetry={reload} />
|
||||
) : !data || data.users.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("users_empty_title")}
|
||||
hint={t("users_empty_hint")}
|
||||
>
|
||||
<CreateUserDialog
|
||||
onCreated={(id) => {
|
||||
reload();
|
||||
navigate(`/admin/users/${id}`);
|
||||
}}
|
||||
/>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30 text-left">
|
||||
<th className="px-4 py-3 font-medium text-muted-foreground">{t("users_col_user")}</th>
|
||||
<th className="px-4 py-3 font-medium text-muted-foreground hidden sm:table-cell">{t("users_col_role")}</th>
|
||||
<th className="px-4 py-3 font-medium text-muted-foreground hidden md:table-cell">{t("users_col_servers")}</th>
|
||||
<th className="px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell">{t("users_col_status")}</th>
|
||||
<th className="px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell">{t("users_col_created")}</th>
|
||||
<th className="px-4 py-3 font-medium text-muted-foreground w-0" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.users.map((u: UserView) => (
|
||||
<UserRow key={u.id} user={u} locale={locale} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("users_total_count", { count: data.total })}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
{t("pagination_prev")}
|
||||
</Button>
|
||||
<span className="px-2 text-muted-foreground">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t("pagination_next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({ user, locale }: { user: UserView; locale: string }) {
|
||||
const { t } = useTranslation("admin");
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-border/50 hover:bg-muted/20 transition-colors",
|
||||
user.disabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
to={`/admin/users/${user.id}`}
|
||||
className="font-medium text-foreground hover:text-primary hover:underline"
|
||||
>
|
||||
{user.username}
|
||||
</Link>
|
||||
{user.email && (
|
||||
<div className="flex items-center gap-1 mt-0.5 text-xs text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
{user.email}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
user.role === "owner"
|
||||
? "bg-yellow-500/10 text-yellow-600"
|
||||
: user.role === "admin"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{user.role === "owner" ? (
|
||||
<Crown className="h-3 w-3" />
|
||||
) : user.role === "admin" ? (
|
||||
<Shield className="h-3 w-3" />
|
||||
) : (
|
||||
<UserRound className="h-3 w-3" />
|
||||
)}
|
||||
{user.role === "owner" ? t("users_role_owner") : user.role === "admin" ? t("users_role_admin") : t("users_role_user")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
{user.server_count}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden lg:table-cell">
|
||||
{user.disabled ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||
<Circle className="h-2 w-2 fill-destructive" />
|
||||
{t("users_status_disabled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-500">
|
||||
<Circle className="h-2 w-2 fill-emerald-500" />
|
||||
{t("users_status_active")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs">
|
||||
{formatAbsolute(user.created_at, locale)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs"
|
||||
asChild
|
||||
>
|
||||
<Link to={`/admin/users/${user.id}`}>
|
||||
{t("users_view_detail")}
|
||||
<ChevronRight className="h-3.5 w-3.5 opacity-60" />
|
||||
</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user