style: format code with prettier
Local Build and Deploy / deploy (push) Successful in 49s

This commit is contained in:
openhands committed 2026-07-12 21:07:34 +02:00
1 parent e85e4d74ea
commit 2e4ed76121
378 files changed
+22404 -20686

No files matched your search

+24 -28
View File
@@ -1,42 +1,38 @@
import { notFound, redirect } from 'next/navigation'
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
import { getAdminContext } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { fetchEmulatorRankForEdit } from '@/lib/services/permission-ranks'
import { RankEditClient } from './rank-edit-client'
import { notFound, redirect } from "next/navigation";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { getAdminContext } from "@/lib/permissions";
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' },
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' },
})
orderBy: { slug: "asc" },
});
// Auto-create CMS role for rank >= 4
let cmsRole: { id: number; slug: string; title: string; permissionSlugs: string[] } | null = null
let cmsRole: { id: number; slug: string; title: string; permissionSlugs: string[] } | null = null;
if (rankId >= 4) {
const role = await prisma.aclRole.upsert({
@@ -46,20 +42,20 @@ export default async function RankEditPage({
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: "Permissions", href: "/admin/permissions" },
{ label: rank.rank_name || `Rank ${rankId}` },
]}
/>
@@ -71,5 +67,5 @@ export default async function RankEditPage({
allCmsPermissions={allCmsPermissions}
/>
</div>
)
);
}
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
ArrowLeft,
@@ -12,79 +12,79 @@ import {
ShieldCheck,
Terminal,
UserCheck,
} from 'lucide-react'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { saveRank as saveRankAction, setCmsPermissions } from '@/actions/permissions'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useServerAction } from '@/hooks/use-server-action'
import { getAvatarUrl } from '@/lib/imager'
import { cn } from '@/lib/utils'
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { saveRank as saveRankAction, setCmsPermissions } from "@/actions/permissions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useServerAction } from "@/hooks/use-server-action";
import { getAvatarUrl } from "@/lib/imager";
import { cn } from "@/lib/utils";
/* ─── Types ──────────────────────────────────────────────── */
interface RankData {
id: number
rank_name: string
badge: string
level: number
prefix: string
prefix_color: string
hidden_rank: string
log_commands: string
room_effect: number
auto_credits_amount: number
auto_pixels_amount: number
auto_gotw_amount: number
auto_points_amount: number
permissions: Record<string, string>
permissionMaxValues: Record<string, number>
id: number;
rank_name: string;
badge: string;
level: number;
prefix: string;
prefix_color: string;
hidden_rank: string;
log_commands: string;
room_effect: number;
auto_credits_amount: number;
auto_pixels_amount: number;
auto_gotw_amount: number;
auto_points_amount: number;
permissions: Record<string, string>;
permissionMaxValues: Record<string, number>;
}
interface RankUser {
id: number
username: string
look: string
id: number;
username: string;
look: string;
}
interface CmsRoleData {
id: number
slug: string
title: string
permissionSlugs: string[]
id: number;
slug: string;
title: string;
permissionSlugs: string[];
}
interface CmsPermission {
id: number
slug: string
title: string
id: number;
slug: string;
title: string;
}
interface Props {
rank: RankData
users: RankUser[]
totalUsers: number
cmsRole: CmsRoleData | null
allCmsPermissions: CmsPermission[]
rank: RankData;
users: RankUser[];
totalUsers: number;
cmsRole: CmsRoleData | null;
allCmsPermissions: CmsPermission[];
}
/* ─── Helpers ────────────────────────────────────────────── */
function categorizePerms(keys: string[]) {
const commands: string[] = []
const account: string[] = []
const other: string[] = []
const commands: string[] = [];
const account: string[] = [];
const other: string[] = [];
for (const key of keys.sort()) {
if (key.startsWith('cmd_') || key === 'kiss_cmd') commands.push(key)
else if (key.startsWith('acc_')) account.push(key)
else other.push(key)
if (key.startsWith("cmd_") || key === "kiss_cmd") commands.push(key);
else if (key.startsWith("acc_")) account.push(key);
else other.push(key);
}
return { commands, account, other }
return { commands, account, other };
}
/* ─── Tri-state Toggle ───────────────────────────────────── */
@@ -94,11 +94,11 @@ function PermToggle({
maxValue,
onChange,
}: {
value: string
maxValue: number
onChange: (v: string) => void
value: string;
maxValue: number;
onChange: (v: string) => void;
}) {
const values = Array.from({ length: Math.max(1, maxValue) + 1 }, (_, i) => String(i))
const values = Array.from({ length: Math.max(1, maxValue) + 1 }, (_, i) => String(i));
return (
<div className="flex gap-0.5 shrink-0">
{values.map((v) => (
@@ -107,21 +107,21 @@ function PermToggle({
type="button"
onClick={() => onChange(v)}
className={cn(
'w-7 h-6 text-[11px] font-mono rounded transition-all font-medium',
"w-7 h-6 text-[11px] font-mono rounded transition-all font-medium",
v === value
? v === '0'
? 'bg-[var(--admin-text-muted)] text-[var(--color-surface-text-readable)] shadow-sm'
: v === '1'
? 'bg-[var(--admin-success)] text-[var(--admin-success-foreground)] shadow-sm'
: 'bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] shadow-sm'
: 'bg-muted/60 text-muted-foreground/40 hover:bg-muted hover:text-muted-foreground',
? v === "0"
? "bg-[var(--admin-text-muted)] text-[var(--color-surface-text-readable)] shadow-sm"
: v === "1"
? "bg-[var(--admin-success)] text-[var(--admin-success-foreground)] shadow-sm"
: "bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] shadow-sm"
: "bg-muted/60 text-muted-foreground/40 hover:bg-muted hover:text-muted-foreground",
)}
>
{v}
</button>
))}
</div>
)
);
}
/* ─── Main Component ─────────────────────────────────────── */
@@ -133,7 +133,7 @@ export function RankEditClient({
cmsRole: initialCmsRole,
allCmsPermissions,
}: Props) {
const { run, isPending } = useServerAction()
const { run, isPending } = useServerAction();
const [general, setGeneral] = useState({
rank_name: initialRank.rank_name,
@@ -147,94 +147,86 @@ export function RankEditClient({
auto_pixels_amount: initialRank.auto_pixels_amount,
auto_gotw_amount: initialRank.auto_gotw_amount,
auto_points_amount: initialRank.auto_points_amount,
})
});
const [perms, setPerms] = useState<Record<string, string>>(initialRank.permissions)
const [cmsPermSlugs, setCmsPermSlugs] = useState<string[]>(initialCmsRole?.permissionSlugs ?? [])
const [permSearch, setPermSearch] = useState('')
const [cmsSearch, setCmsSearch] = useState('')
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const [perms, setPerms] = useState<Record<string, string>>(initialRank.permissions);
const [cmsPermSlugs, setCmsPermSlugs] = useState<string[]>(initialCmsRole?.permissionSlugs ?? []);
const [permSearch, setPermSearch] = useState("");
const [cmsSearch, setCmsSearch] = useState("");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const cats = useMemo(
() => categorizePerms(Object.keys(initialRank.permissions)),
[initialRank.permissions],
)
const enabledCount = Object.values(perms).filter((v) => v !== '0').length
const totalCount = Object.keys(perms).length
);
const enabledCount = Object.values(perms).filter((v) => v !== "0").length;
const totalCount = Object.keys(perms).length;
function setNum(field: string, val: string) {
setGeneral((p) => ({ ...p, [field]: parseInt(val, 10) || 0 }))
setGeneral((p) => ({ ...p, [field]: parseInt(val, 10) || 0 }));
}
function saveRank() {
const fields = Object.fromEntries(
Object.entries({ ...general, ...perms }).map(([key, value]) => [key, String(value)]),
)
);
run(() => saveRankAction({ id: initialRank.id, fields }), {
successMessage: 'Rank saved',
})
successMessage: "Rank saved",
});
}
async function toggleCms(slug: string) {
if (!initialCmsRole) return
const prev = cmsPermSlugs
const next = prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]
setCmsPermSlugs(next)
if (!initialCmsRole) return;
const prev = cmsPermSlugs;
const next = prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug];
setCmsPermSlugs(next);
try {
const r = await setCmsPermissions({ roleId: initialCmsRole.id, permissionSlugs: next })
const r = await setCmsPermissions({ roleId: initialCmsRole.id, permissionSlugs: next });
if (!r.ok) {
setCmsPermSlugs(prev)
toast.error('Failed')
setCmsPermSlugs(prev);
toast.error("Failed");
}
} catch {
setCmsPermSlugs(prev)
toast.error('Failed')
setCmsPermSlugs(prev);
toast.error("Failed");
}
}
function toggle(key: string) {
setCollapsed((p) => {
const n = new Set(p)
if (n.has(key)) n.delete(key)
else n.add(key)
return n
})
const n = new Set(p);
if (n.has(key)) n.delete(key);
else n.add(key);
return n;
});
}
/* ── Collapsible perm section ──────────────────────────── */
function setSectionAll(keys: string[], value: string) {
setPerms((p) => {
const next = { ...p }
for (const k of keys) next[k] = value
return next
})
const next = { ...p };
for (const k of keys) next[k] = value;
return next;
});
}
function setAllPerms(value: string) {
setPerms((p) => {
const next = { ...p }
for (const k of Object.keys(next)) next[k] = value
return next
})
const next = { ...p };
for (const k of Object.keys(next)) next[k] = value;
return next;
});
}
function PermSection({
title,
keys,
sectionKey,
}: {
title: string
keys: string[]
sectionKey: string
}) {
function PermSection({ title, keys, sectionKey }: { title: string; keys: string[]; sectionKey: string }) {
const filtered = permSearch
? keys.filter((k) => k.toLowerCase().includes(permSearch.toLowerCase()))
: keys
if (permSearch && filtered.length === 0) return null
const on = filtered.filter((k) => (perms[k] ?? '0') !== '0').length
const _allOn = on === filtered.length
const open = !collapsed.has(sectionKey)
: keys;
if (permSearch && filtered.length === 0) return null;
const on = filtered.filter((k) => (perms[k] ?? "0") !== "0").length;
const _allOn = on === filtered.length;
const open = !collapsed.has(sectionKey);
return (
<div className="border rounded-lg overflow-hidden">
@@ -246,14 +238,11 @@ export function RankEditClient({
>
<span className="text-sm font-medium">{title}</span>
<div className="flex items-center gap-2">
<Badge variant={on > 0 ? 'default' : 'secondary'} className="text-[10px]">
<Badge variant={on > 0 ? "default" : "secondary"} className="text-[10px]">
{on}/{filtered.length}
</Badge>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
!open && '-rotate-90',
)}
className={cn("h-4 w-4 text-muted-foreground transition-transform", !open && "-rotate-90")}
/>
</div>
</button>
@@ -264,8 +253,8 @@ export function RankEditClient({
size="sm"
className="h-6 px-2 text-[10px]"
onClick={(e) => {
e.stopPropagation()
setSectionAll(filtered, '1')
e.stopPropagation();
setSectionAll(filtered, "1");
}}
>
All On
@@ -275,8 +264,8 @@ export function RankEditClient({
size="sm"
className="h-6 px-2 text-[10px]"
onClick={(e) => {
e.stopPropagation()
setSectionAll(filtered, '0')
e.stopPropagation();
setSectionAll(filtered, "0");
}}
>
All Off
@@ -290,13 +279,13 @@ export function RankEditClient({
<div
key={key}
className={cn(
'flex items-center justify-between px-3 py-2 bg-card',
(perms[key] ?? '0') !== '0' && 'bg-[var(--admin-success-subtle)]',
"flex items-center justify-between px-3 py-2 bg-card",
(perms[key] ?? "0") !== "0" && "bg-[var(--admin-success-subtle)]",
)}
>
<span className="font-mono text-xs truncate mr-3">{key}</span>
<PermToggle
value={perms[key] ?? '0'}
value={perms[key] ?? "0"}
maxValue={initialRank.permissionMaxValues[key] ?? 1}
onChange={(v) => setPerms((p) => ({ ...p, [key]: v }))}
/>
@@ -305,7 +294,7 @@ export function RankEditClient({
</div>
)}
</div>
)
);
}
const filteredCms = cmsSearch
@@ -314,7 +303,7 @@ export function RankEditClient({
p.slug.toLowerCase().includes(cmsSearch.toLowerCase()) ||
p.title?.toLowerCase().includes(cmsSearch.toLowerCase()),
)
: allCmsPermissions
: allCmsPermissions;
return (
<div className="space-y-4">
@@ -395,7 +384,7 @@ export function RankEditClient({
<div
className="w-9 h-9 rounded border shrink-0"
style={{
backgroundColor: general.prefix_color.startsWith('#')
backgroundColor: general.prefix_color.startsWith("#")
? general.prefix_color
: `#${general.prefix_color}`,
}}
@@ -411,8 +400,8 @@ export function RankEditClient({
<p className="text-xs text-muted-foreground">Hide from public</p>
</div>
<Switch
checked={general.hidden_rank === '1'}
onCheckedChange={(v) => setGeneral({ ...general, hidden_rank: v ? '1' : '0' })}
checked={general.hidden_rank === "1"}
onCheckedChange={(v) => setGeneral({ ...general, hidden_rank: v ? "1" : "0" })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
@@ -421,8 +410,8 @@ export function RankEditClient({
<p className="text-xs text-muted-foreground">Record to audit log</p>
</div>
<Switch
checked={general.log_commands === '1'}
onCheckedChange={(v) => setGeneral({ ...general, log_commands: v ? '1' : '0' })}
checked={general.log_commands === "1"}
onCheckedChange={(v) => setGeneral({ ...general, log_commands: v ? "1" : "0" })}
/>
</div>
<div className="space-y-1.5 rounded-lg border p-3">
@@ -430,7 +419,7 @@ export function RankEditClient({
<Input
type="number"
value={general.room_effect}
onChange={(e) => setNum('room_effect', e.target.value)}
onChange={(e) => setNum("room_effect", e.target.value)}
/>
</div>
</div>
@@ -440,7 +429,7 @@ export function RankEditClient({
<Input
type="number"
value={general.auto_credits_amount}
onChange={(e) => setNum('auto_credits_amount', e.target.value)}
onChange={(e) => setNum("auto_credits_amount", e.target.value)}
/>
</div>
<div className="space-y-1.5">
@@ -448,7 +437,7 @@ export function RankEditClient({
<Input
type="number"
value={general.auto_pixels_amount}
onChange={(e) => setNum('auto_pixels_amount', e.target.value)}
onChange={(e) => setNum("auto_pixels_amount", e.target.value)}
/>
</div>
<div className="space-y-1.5">
@@ -456,7 +445,7 @@ export function RankEditClient({
<Input
type="number"
value={general.auto_gotw_amount}
onChange={(e) => setNum('auto_gotw_amount', e.target.value)}
onChange={(e) => setNum("auto_gotw_amount", e.target.value)}
/>
</div>
<div className="space-y-1.5">
@@ -464,7 +453,7 @@ export function RankEditClient({
<Input
type="number"
value={general.auto_points_amount}
onChange={(e) => setNum('auto_points_amount', e.target.value)}
onChange={(e) => setNum("auto_points_amount", e.target.value)}
/>
</div>
</div>
@@ -505,7 +494,7 @@ export function RankEditClient({
className="flex items-center gap-2 rounded-lg border p-2 hover:bg-muted/50 transition-colors"
>
<img
src={getAvatarUrl(u.look, { size: 's', headDirection: 2 })}
src={getAvatarUrl(u.look, { size: "s", headDirection: 2 })}
alt=""
className="w-8 h-12 object-cover"
/>
@@ -526,20 +515,10 @@ export function RankEditClient({
</p>
<div className="flex items-center gap-3">
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setAllPerms('1')}
>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setAllPerms("1")}>
Enable All
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setAllPerms('0')}
>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setAllPerms("0")}>
Disable All
</Button>
</div>
@@ -569,9 +548,7 @@ export function RankEditClient({
<PermSection title="Commands" keys={cats.commands} sectionKey="cmd" />
<PermSection title="Account" keys={cats.account} sectionKey="acc" />
{cats.other.length > 0 && (
<PermSection title="Other" keys={cats.other} sectionKey="other" />
)}
{cats.other.length > 0 && <PermSection title="Other" keys={cats.other} sectionKey="other" />}
<div className="flex justify-end">
<Button onClick={saveRank} disabled={isPending}>
@@ -589,9 +566,7 @@ export function RankEditClient({
<TabsContent value="cms" className="space-y-4">
{initialRank.id < 4 ? (
<div className="rounded-lg border bg-card p-6 text-center">
<p className="text-muted-foreground">
CMS permissions are available from rank 4 and above.
</p>
<p className="text-muted-foreground">CMS permissions are available from rank 4 and above.</p>
</div>
) : (
<>
@@ -605,16 +580,16 @@ export function RankEditClient({
size="sm"
className="h-7 text-xs"
onClick={async () => {
if (!initialCmsRole) return
const all = allCmsPermissions.map((p) => p.slug)
setCmsPermSlugs(all)
if (!initialCmsRole) return;
const all = allCmsPermissions.map((p) => p.slug);
setCmsPermSlugs(all);
const r = await setCmsPermissions({
roleId: initialCmsRole.id,
permissionSlugs: all,
})
});
if (!r.ok) {
setCmsPermSlugs(initialCmsRole.permissionSlugs)
toast.error('Failed')
setCmsPermSlugs(initialCmsRole.permissionSlugs);
toast.error("Failed");
}
}}
>
@@ -625,15 +600,15 @@ export function RankEditClient({
size="sm"
className="h-7 text-xs"
onClick={async () => {
if (!initialCmsRole) return
setCmsPermSlugs([])
if (!initialCmsRole) return;
setCmsPermSlugs([]);
const r = await setCmsPermissions({
roleId: initialCmsRole.id,
permissionSlugs: [],
})
});
if (!r.ok) {
setCmsPermSlugs(initialCmsRole.permissionSlugs)
toast.error('Failed')
setCmsPermSlugs(initialCmsRole.permissionSlugs);
toast.error("Failed");
}
}}
>
@@ -654,13 +629,15 @@ export function RankEditClient({
<div className="space-y-1">
{filteredCms.map((perm) => {
const on = cmsPermSlugs.includes(perm.slug)
const on = cmsPermSlugs.includes(perm.slug);
return (
<div
key={perm.id}
className={cn(
'flex items-center gap-3 rounded-lg border px-4 py-3 transition-all',
on ? 'border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]' : 'hover:bg-muted/50',
"flex items-center gap-3 rounded-lg border px-4 py-3 transition-all",
on
? "border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]"
: "hover:bg-muted/50",
)}
>
<Checkbox checked={on} onCheckedChange={() => toggleCms(perm.slug)} />
@@ -676,7 +653,7 @@ export function RankEditClient({
</Badge>
)}
</div>
)
);
})}
</div>
{filteredCms.length === 0 && (
@@ -713,7 +690,7 @@ export function RankEditClient({
className="flex items-center gap-2 rounded-lg border p-2 hover:bg-muted/50 transition-colors"
>
<img
src={getAvatarUrl(u.look, { size: 's', headDirection: 2 })}
src={getAvatarUrl(u.look, { size: "s", headDirection: 2 })}
alt=""
className="w-8 h-12 object-cover"
/>
@@ -727,5 +704,5 @@ export function RankEditClient({
</TabsContent>
</Tabs>
</div>
)
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Skeleton } from "@/components/ui/skeleton";
export default function PermissionsLoading() {
return (
@@ -24,5 +24,5 @@ export default function PermissionsLoading() {
))}
</div>
</div>
)
);
}
+14 -14
View File
@@ -1,29 +1,29 @@
import { redirect } from 'next/navigation'
import { Prisma } from '@/generated/prisma/client'
import { getAdminContext } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { fetchEmulatorRankSummaries } from '@/lib/services/permission-ranks'
import { PermissionsList } from './permissions-list'
import { redirect } from "next/navigation";
import { Prisma } from "@/generated/prisma/client";
import { getAdminContext } from "@/lib/permissions";
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')
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> = {}
);
const countMap: Record<number, number> = {};
for (const row of rawCounts) {
countMap[Number(row.rank)] = Number(row.cnt)
countMap[Number(row.rank)] = Number(row.cnt);
}
const ranksWithCounts = ranks.map((rank) => ({
...rank,
userCount: countMap[rank.id] ?? 0,
}))
}));
return <PermissionsList ranks={ranksWithCounts} />
return <PermissionsList ranks={ranksWithCounts} />;
}
+58 -70
View File
@@ -1,22 +1,12 @@
'use client'
"use client";
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'
import { createRank, deleteRank } from '@/actions/permissions'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
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";
import { createRank, deleteRank } from "@/actions/permissions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -24,24 +14,24 @@ import {
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'
} 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> = {
@@ -49,51 +39,51 @@ const RANK_ICONS: Record<number, React.ReactNode> = {
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)
const level = parseInt(newRankLevel, 10);
if (!newRankName.trim() || !level || level < 1) {
toast.error('Name and a valid level are required')
return
toast.error("Name and a valid level are required");
return;
}
run(() => createRank({ rank_name: newRankName.trim(), level }), {
successMessage: 'Rank created',
successMessage: "Rank created",
onSuccess: () => {
setShowCreate(false)
setNewRankName('')
setNewRankLevel('')
setShowCreate(false);
setNewRankName("");
setNewRankLevel("");
},
})
});
}
function handleDelete() {
if (!deleteTarget) return
if (!deleteTarget) return;
run(() => deleteRank({ id: deleteTarget.id }), {
successMessage: 'Rank deleted',
successMessage: "Rank deleted",
onSuccess: () => {
setRanks((prev) => prev.filter((r) => r.id !== deleteTarget!.id))
setDeleteTarget(null)
setRanks((prev) => prev.filter((r) => r.id !== deleteTarget!.id));
setDeleteTarget(null);
},
})
});
}
return (
@@ -119,14 +109,14 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
<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.startsWith("#")
? rank.prefix_color
: `#${rank.prefix_color}`
: undefined
: 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] || ''}`}
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">
@@ -135,7 +125,7 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
<span className="font-bold">{rank.rank_name || `Rank ${rank.id}`}</span>
</div>
<div className="flex items-center gap-1.5">
{rank.hidden_rank === '1' && (
{rank.hidden_rank === "1" && (
<span title="Hidden rank">
<EyeOff className="h-3.5 w-3.5 text-muted-foreground" />
</span>
@@ -187,15 +177,13 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
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'
}
title={rank.userCount > 0 ? `Cannot delete: ${rank.userCount} users` : "Delete rank"}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
);
})}
</div>
@@ -236,7 +224,7 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
Cancel
</Button>
<Button onClick={handleCreate} disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
{isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
@@ -248,8 +236,8 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
<DialogHeader>
<DialogTitle>Delete Rank #{deleteTarget?.id}?</DialogTitle>
<DialogDescription>
This will permanently delete &quot;{deleteTarget?.rank_name}&quot; and its CMS role.
This cannot be undone.
This will permanently delete &quot;{deleteTarget?.rank_name}&quot; and its CMS role. This cannot
be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -257,11 +245,11 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>
{isPending ? 'Deleting...' : 'Delete'}
{isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
);
}