This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -5,67 +5,79 @@ import { prisma } from "@/lib/prisma";
|
||||
import { fetchEmulatorRankForEdit } from "@/lib/services/permission-ranks";
|
||||
import { RankEditClient } from "./rank-edit-client";
|
||||
|
||||
export default async function RankEditPage({ params }: { params: Promise<{ locale: string; id: string }> }) {
|
||||
const { id: rawId } = await params;
|
||||
const { permissions } = await getAdminContext();
|
||||
if (!permissions.isSuperAdmin) redirect("/admin");
|
||||
export default async function RankEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}) {
|
||||
const { id: rawId } = await params;
|
||||
const { permissions } = await getAdminContext();
|
||||
if (!permissions.isSuperAdmin) redirect("/admin");
|
||||
|
||||
const rankId = parseInt(rawId, 10);
|
||||
if (Number.isNaN(rankId)) notFound();
|
||||
const rankId = parseInt(rawId, 10);
|
||||
if (Number.isNaN(rankId)) notFound();
|
||||
|
||||
const rank = await fetchEmulatorRankForEdit(prisma, rankId);
|
||||
if (!rank) notFound();
|
||||
const rank = await fetchEmulatorRankForEdit(prisma, rankId);
|
||||
if (!rank) notFound();
|
||||
|
||||
// Fetch users with this rank (limit 200)
|
||||
const users = await prisma.user.findMany({
|
||||
where: { rank: rankId },
|
||||
select: { id: true, username: true, look: true },
|
||||
orderBy: { username: "asc" },
|
||||
take: 200,
|
||||
});
|
||||
// Fetch users with this rank (limit 200)
|
||||
const users = await prisma.user.findMany({
|
||||
where: { rank: rankId },
|
||||
select: { id: true, username: true, look: true },
|
||||
orderBy: { username: "asc" },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
const totalUsers = await prisma.user.count({ where: { rank: rankId } });
|
||||
const totalUsers = await prisma.user.count({ where: { rank: rankId } });
|
||||
|
||||
// Fetch all CMS permissions
|
||||
const allCmsPermissions = await prisma.aclPermission.findMany({
|
||||
orderBy: { slug: "asc" },
|
||||
});
|
||||
// Fetch all CMS permissions
|
||||
const allCmsPermissions = await prisma.aclPermission.findMany({
|
||||
orderBy: { slug: "asc" },
|
||||
});
|
||||
|
||||
// Auto-create CMS role for rank >= 4
|
||||
let cmsRole: { id: number; slug: string; title: string; permissionSlugs: string[] } | null = null;
|
||||
// Auto-create CMS role for rank >= 4
|
||||
let cmsRole: {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
permissionSlugs: string[];
|
||||
} | null = null;
|
||||
|
||||
if (rankId >= 4) {
|
||||
const role = await prisma.aclRole.upsert({
|
||||
where: { slug: `rank_${rankId}` },
|
||||
create: { slug: `rank_${rankId}`, title: rank.rank_name || `Rank ${rankId}` },
|
||||
update: {},
|
||||
include: {
|
||||
rolePermissions: { include: { permission: true } },
|
||||
},
|
||||
});
|
||||
cmsRole = {
|
||||
id: role.id,
|
||||
slug: role.slug,
|
||||
title: role.title,
|
||||
permissionSlugs: role.rolePermissions.map((rp) => rp.permission.slug),
|
||||
};
|
||||
}
|
||||
if (rankId >= 4) {
|
||||
const role = await prisma.aclRole.upsert({
|
||||
where: { slug: `rank_${rankId}` },
|
||||
create: {
|
||||
slug: `rank_${rankId}`,
|
||||
title: rank.rank_name || `Rank ${rankId}`,
|
||||
},
|
||||
update: {},
|
||||
include: {
|
||||
rolePermissions: { include: { permission: true } },
|
||||
},
|
||||
});
|
||||
cmsRole = {
|
||||
id: role.id,
|
||||
slug: role.slug,
|
||||
title: role.title,
|
||||
permissionSlugs: role.rolePermissions.map((rp) => rp.permission.slug),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AdminBreadcrumb
|
||||
items={[
|
||||
{ label: "Permissions", href: "/admin/permissions" },
|
||||
{ label: rank.rank_name || `Rank ${rankId}` },
|
||||
]}
|
||||
/>
|
||||
<RankEditClient
|
||||
rank={rank}
|
||||
users={users}
|
||||
totalUsers={totalUsers}
|
||||
cmsRole={cmsRole}
|
||||
allCmsPermissions={allCmsPermissions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AdminBreadcrumb
|
||||
items={[
|
||||
{ label: "Permissions", href: "/admin/permissions" },
|
||||
{ label: rank.rank_name || `Rank ${rankId}` },
|
||||
]}
|
||||
/>
|
||||
<RankEditClient
|
||||
rank={rank}
|
||||
users={users}
|
||||
totalUsers={totalUsers}
|
||||
cmsRole={cmsRole}
|
||||
allCmsPermissions={allCmsPermissions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,28 +1,31 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function PermissionsLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-5 w-64 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-5 w-64 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,25 +5,27 @@ import { prisma } from "@/lib/prisma";
|
||||
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
||||
import { PermissionsList } from "./permissions-list";
|
||||
|
||||
export default async function PermissionsPage(_props: { params: Promise<{ locale: string }> }) {
|
||||
const { permissions } = await getAdminContext();
|
||||
if (!permissions.isSuperAdmin) redirect("/admin");
|
||||
export default async function PermissionsPage(_props: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { permissions } = await getAdminContext();
|
||||
if (!permissions.isSuperAdmin) redirect("/admin");
|
||||
|
||||
const ranks = await fetchEmulatorRankSummaries(prisma);
|
||||
const ranks = await fetchEmulatorRankSummaries(prisma);
|
||||
|
||||
// User counts per rank
|
||||
const rawCounts: { rank: number; cnt: number }[] = await prisma.$queryRaw(
|
||||
Prisma.sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``,
|
||||
);
|
||||
const countMap: Record<number, number> = {};
|
||||
for (const row of rawCounts) {
|
||||
countMap[Number(row.rank)] = Number(row.cnt);
|
||||
}
|
||||
// User counts per rank
|
||||
const rawCounts: { rank: number; cnt: number }[] = await prisma.$queryRaw(
|
||||
Prisma.sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``,
|
||||
);
|
||||
const countMap: Record<number, number> = {};
|
||||
for (const row of rawCounts) {
|
||||
countMap[Number(row.rank)] = Number(row.cnt);
|
||||
}
|
||||
|
||||
const ranksWithCounts = ranks.map((rank) => ({
|
||||
...rank,
|
||||
userCount: countMap[rank.id] ?? 0,
|
||||
}));
|
||||
const ranksWithCounts = ranks.map((rank) => ({
|
||||
...rank,
|
||||
userCount: countMap[rank.id] ?? 0,
|
||||
}));
|
||||
|
||||
return <PermissionsList ranks={ranksWithCounts} />;
|
||||
return <PermissionsList ranks={ranksWithCounts} />;
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Crown, EyeOff, Pencil, Plus, Shield, ShieldCheck, Trash2, UserCheck, Users } from "lucide-react";
|
||||
import {
|
||||
Crown,
|
||||
EyeOff,
|
||||
Pencil,
|
||||
Plus,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -8,248 +18,262 @@ import { createRank, deleteRank } from "@/actions/permissions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
interface RankSummary {
|
||||
id: number;
|
||||
rank_name: string;
|
||||
badge: string;
|
||||
level: number;
|
||||
prefix: string;
|
||||
prefix_color: string;
|
||||
hidden_rank: string;
|
||||
userCount: number;
|
||||
id: number;
|
||||
rank_name: string;
|
||||
badge: string;
|
||||
level: number;
|
||||
prefix: string;
|
||||
prefix_color: string;
|
||||
hidden_rank: string;
|
||||
userCount: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ranks: RankSummary[];
|
||||
ranks: RankSummary[];
|
||||
}
|
||||
|
||||
const RANK_ICONS: Record<number, React.ReactNode> = {
|
||||
7: <Crown className="h-4 w-4 text-[var(--admin-warning)]" />,
|
||||
6: <ShieldCheck className="h-4 w-4 text-[var(--admin-error)]" />,
|
||||
5: <Shield className="h-4 w-4 text-[var(--admin-info)]" />,
|
||||
4: <UserCheck className="h-4 w-4 text-[var(--admin-success)]" />,
|
||||
7: <Crown className="h-4 w-4 text-[var(--admin-warning)]" />,
|
||||
6: <ShieldCheck className="h-4 w-4 text-[var(--admin-error)]" />,
|
||||
5: <Shield className="h-4 w-4 text-[var(--admin-info)]" />,
|
||||
4: <UserCheck className="h-4 w-4 text-[var(--admin-success)]" />,
|
||||
};
|
||||
|
||||
const RANK_COLORS: Record<number, string> = {
|
||||
7: "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]",
|
||||
6: "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]",
|
||||
5: "border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)]",
|
||||
4: "border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]",
|
||||
7: "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]",
|
||||
6: "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]",
|
||||
5: "border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)]",
|
||||
4: "border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]",
|
||||
};
|
||||
|
||||
export function PermissionsList({ ranks: initialRanks }: Props) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const [ranks, setRanks] = useState(initialRanks);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RankSummary | null>(null);
|
||||
const { run, isPending } = useServerAction();
|
||||
const [ranks, setRanks] = useState(initialRanks);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RankSummary | null>(null);
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newRankName, setNewRankName] = useState("");
|
||||
const [newRankLevel, setNewRankLevel] = useState("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newRankName, setNewRankName] = useState("");
|
||||
const [newRankLevel, setNewRankLevel] = useState("");
|
||||
|
||||
const totalUsers = ranks.reduce((sum, r) => sum + r.userCount, 0);
|
||||
const totalUsers = ranks.reduce((sum, r) => sum + r.userCount, 0);
|
||||
|
||||
function handleCreate() {
|
||||
const level = parseInt(newRankLevel, 10);
|
||||
if (!newRankName.trim() || !level || level < 1) {
|
||||
toast.error("Name and a valid level are required");
|
||||
return;
|
||||
}
|
||||
run(() => createRank({ rank_name: newRankName.trim(), level }), {
|
||||
successMessage: "Rank created",
|
||||
onSuccess: () => {
|
||||
setShowCreate(false);
|
||||
setNewRankName("");
|
||||
setNewRankLevel("");
|
||||
},
|
||||
});
|
||||
}
|
||||
function handleCreate() {
|
||||
const level = parseInt(newRankLevel, 10);
|
||||
if (!newRankName.trim() || !level || level < 1) {
|
||||
toast.error("Name and a valid level are required");
|
||||
return;
|
||||
}
|
||||
run(() => createRank({ rank_name: newRankName.trim(), level }), {
|
||||
successMessage: "Rank created",
|
||||
onSuccess: () => {
|
||||
setShowCreate(false);
|
||||
setNewRankName("");
|
||||
setNewRankLevel("");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
run(() => deleteRank({ id: deleteTarget.id }), {
|
||||
successMessage: "Rank deleted",
|
||||
onSuccess: () => {
|
||||
setRanks((prev) => prev.filter((r) => r.id !== deleteTarget!.id));
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
run(() => deleteRank({ id: deleteTarget.id }), {
|
||||
successMessage: "Rank deleted",
|
||||
onSuccess: () => {
|
||||
setRanks((prev) => prev.filter((r) => r.id !== deleteTarget?.id));
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<ShieldCheck className="h-7 w-7" />
|
||||
Permissions
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{ranks.length} ranks · {totalUsers.toLocaleString()} users total
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
New Rank
|
||||
</Button>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<ShieldCheck className="h-7 w-7" />
|
||||
Permissions
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{ranks.length} ranks · {totalUsers.toLocaleString()} users
|
||||
total
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
New Rank
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Rank cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{ranks.map((rank) => {
|
||||
const prefixColor = rank.prefix_color
|
||||
? rank.prefix_color.startsWith("#")
|
||||
? rank.prefix_color
|
||||
: `#${rank.prefix_color}`
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
key={rank.id}
|
||||
className={`rounded-xl border p-4 flex flex-col gap-3 hover:shadow-lg transition-all ${RANK_COLORS[rank.id] || ""}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{RANK_ICONS[rank.id] || <Shield className="h-4 w-4 text-muted-foreground" />}
|
||||
<span className="font-bold">{rank.rank_name || `Rank ${rank.id}`}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{rank.hidden_rank === "1" && (
|
||||
<span title="Hidden rank">
|
||||
<EyeOff className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||
#{rank.id}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{/* Rank cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{ranks.map((rank) => {
|
||||
const prefixColor = rank.prefix_color
|
||||
? rank.prefix_color.startsWith("#")
|
||||
? rank.prefix_color
|
||||
: `#${rank.prefix_color}`
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
key={rank.id}
|
||||
className={`rounded-xl border p-4 flex flex-col gap-3 hover:shadow-lg transition-all ${RANK_COLORS[rank.id] || ""}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{RANK_ICONS[rank.id] || (
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-bold">
|
||||
{rank.rank_name || `Rank ${rank.id}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{rank.hidden_rank === "1" && (
|
||||
<span title="Hidden rank">
|
||||
<EyeOff className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||
#{rank.id}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges row */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
Level {rank.level}
|
||||
</Badge>
|
||||
{rank.badge && (
|
||||
<Badge variant="outline" className="font-mono text-[11px]">
|
||||
{rank.badge}
|
||||
</Badge>
|
||||
)}
|
||||
{rank.prefix && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[11px]"
|
||||
style={{ color: prefixColor, borderColor: prefixColor }}
|
||||
>
|
||||
{rank.prefix}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Badges row */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
Level {rank.level}
|
||||
</Badge>
|
||||
{rank.badge && (
|
||||
<Badge variant="outline" className="font-mono text-[11px]">
|
||||
{rank.badge}
|
||||
</Badge>
|
||||
)}
|
||||
{rank.prefix && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[11px]"
|
||||
style={{ color: prefixColor, borderColor: prefixColor }}
|
||||
>
|
||||
{rank.prefix}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User count */}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Users className="h-3 w-3" />
|
||||
{rank.userCount.toLocaleString()} users
|
||||
</div>
|
||||
{/* User count */}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Users className="h-3 w-3" />
|
||||
{rank.userCount.toLocaleString()} users
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-auto pt-1">
|
||||
<Link href={`/admin/permissions/${rank.id}`} className="flex-1">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Pencil className="h-3.5 w-3.5 mr-1.5" />
|
||||
Configure
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive shrink-0"
|
||||
disabled={rank.userCount > 0 || isPending}
|
||||
onClick={() => setDeleteTarget(rank)}
|
||||
title={rank.userCount > 0 ? `Cannot delete: ${rank.userCount} users` : "Delete rank"}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-auto pt-1">
|
||||
<Link href={`/admin/permissions/${rank.id}`} className="flex-1">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Pencil className="h-3.5 w-3.5 mr-1.5" />
|
||||
Configure
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive shrink-0"
|
||||
disabled={rank.userCount > 0 || isPending}
|
||||
onClick={() => setDeleteTarget(rank)}
|
||||
title={
|
||||
rank.userCount > 0
|
||||
? `Cannot delete: ${rank.userCount} users`
|
||||
: "Delete rank"
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Create rank dialog */}
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Rank</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new permission rank. You can configure its permissions after creation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Rank Name</Label>
|
||||
<Input
|
||||
value={newRankName}
|
||||
onChange={(e) => setNewRankName(e.target.value)}
|
||||
placeholder="e.g. Moderator"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Level</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={newRankLevel}
|
||||
onChange={(e) => setNewRankLevel(e.target.value)}
|
||||
placeholder="e.g. 5"
|
||||
min={1}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Higher level = more base permissions in the emulator
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isPending}>
|
||||
{isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Create rank dialog */}
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Rank</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new permission rank. You can configure its permissions after
|
||||
creation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Rank Name</Label>
|
||||
<Input
|
||||
value={newRankName}
|
||||
onChange={(e) => setNewRankName(e.target.value)}
|
||||
placeholder="e.g. Moderator"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Level</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={newRankLevel}
|
||||
onChange={(e) => setNewRankLevel(e.target.value)}
|
||||
placeholder="e.g. 5"
|
||||
min={1}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Higher level = more base permissions in the emulator
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isPending}>
|
||||
{isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Rank #{deleteTarget?.id}?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete "{deleteTarget?.rank_name}" and its CMS role. This cannot
|
||||
be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>
|
||||
{isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Rank #{deleteTarget?.id}?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete "{deleteTarget?.rank_name}"
|
||||
and its CMS role. This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user