feat(panel): implement user management administration panel with sessions and minecraft link support

This commit is contained in:
Lemon-miaow committed 2026-07-04 04:08:14 +08:00
1 parent 83e57b4c45
commit 3347cc05d5
28 files changed
+4263 -68

No files matched your search

+8
View File
@@ -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 />} />
+6 -5
View File
@@ -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(() => {
+189
View File
@@ -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>
);
}
+17
View File
@@ -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 />;
}
+90 -1
View File
@@ -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",
+90 -1
View File
@@ -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": "审核提交",
+73
View File
@@ -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
View File
@@ -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;
});
}
+9 -1
View File
@@ -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
View File
@@ -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;
}
+949
View File
@@ -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} &middot; {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>
</>
);
}
+284
View File
@@ -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>
);
}