Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"

This reverts commit 488b6e57c4, reversing
changes made to b506b4499a.
This commit is contained in:
Simo committed 2026-08-30 21:31:34 +02:00
1 parent 488b6e57c4
commit b1ddda66ff
802 files changed
+61370 -76659

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
import { asc, count, eq } from "drizzle-orm";
import { notFound, redirect } from "next/navigation";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { AclModelPermission, AclPermission, AclRole, db, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
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 { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)) {
redirect("/admin");
}
const rankId = parseInt(rawId, 10);
if (Number.isNaN(rankId)) notFound();
const rank = await fetchEmulatorRankForEdit(db, rankId);
if (!rank) notFound();
// Fetch users with this rank (limit 200)
const users = await db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.where(eq(User.rank, rankId))
.orderBy(asc(User.username))
.limit(200);
const [totalUsersRow] = await db
.select({ total: count() })
.from(User)
.where(eq(User.rank, rankId));
const totalUsers = totalUsersRow?.total ?? 0;
// Fetch all CMS permissions
const allCmsPermissions = await db
.select({
id: AclPermission.id,
slug: AclPermission.slug,
title: AclPermission.title,
})
.from(AclPermission)
.orderBy(asc(AclPermission.slug));
// Auto-create CMS role for rank >= 4
let cmsRole: {
id: number;
slug: string;
title: string;
permissionSlugs: string[];
} | null = null;
if (rankId >= 4) {
const slug = `rank_${rankId}`;
const title = rank.rank_name || `Rank ${rankId}`;
const [existing] = await db
.select({ id: AclRole.id })
.from(AclRole)
.where(eq(AclRole.slug, slug))
.limit(1);
let roleId: number;
if (existing) {
roleId = existing.id;
await db.update(AclRole).set({ title }).where(eq(AclRole.id, roleId));
} else {
const inserted = await db.insert(AclRole).values({ slug, title });
roleId = inserted[0].insertId;
}
const permissionRows = await db
.select({ slug: AclPermission.slug })
.from(AclModelPermission)
.innerJoin(
AclPermission,
eq(AclModelPermission.permissionId, AclPermission.id),
)
.where(eq(AclModelPermission.modelId, roleId));
cmsRole = {
id: roleId,
slug,
title,
permissionSlugs: permissionRows.map((rp) => rp.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>
);
}
@@ -0,0 +1,803 @@
"use client";
import {
ArrowLeft,
ChevronDown,
Crown,
Loader2,
Save,
Search,
Settings,
Shield,
ShieldCheck,
Terminal,
UserCheck,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import {
saveRank as saveRankAction,
setCmsPermissions,
} from "@/actions/permissions";
import Link from "@/components/link";
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>;
}
interface RankUser {
id: number;
username: string;
look: string;
}
interface CmsRoleData {
id: number;
slug: string;
title: string;
permissionSlugs: string[];
}
interface CmsPermission {
id: number;
slug: string;
title: string;
}
interface Props {
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[] = [];
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);
}
return { commands, account, other };
}
/* ─── Tri-state Toggle ───────────────────────────────────── */
function PermToggle({
value,
maxValue,
onChange,
}: {
value: string;
maxValue: number;
onChange: (v: string) => void;
}) {
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) => (
<button
key={v}
type="button"
onClick={() => onChange(v)}
className={cn(
"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}
</button>
))}
</div>
);
}
/* ─── Main Component ─────────────────────────────────────── */
export function RankEditClient({
rank: initialRank,
users,
totalUsers,
cmsRole: initialCmsRole,
allCmsPermissions,
}: Props) {
const { run, isPending } = useServerAction();
const [general, setGeneral] = useState({
rank_name: initialRank.rank_name,
badge: initialRank.badge,
prefix: initialRank.prefix,
prefix_color: initialRank.prefix_color,
hidden_rank: initialRank.hidden_rank,
log_commands: initialRank.log_commands,
room_effect: initialRank.room_effect,
auto_credits_amount: initialRank.auto_credits_amount,
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 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;
function setNum(field: string, val: string) {
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",
});
}
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);
try {
const r = await setCmsPermissions({
roleId: initialCmsRole.id,
permissionSlugs: next,
});
if (!r.ok) {
setCmsPermSlugs(prev);
toast.error("Failed");
}
} catch {
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;
});
}
/* ── Collapsible perm section ──────────────────────────── */
function setSectionAll(keys: string[], value: string) {
setPerms((p) => {
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;
});
}
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;
void _allOn;
const open = !collapsed.has(sectionKey);
return (
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center bg-muted/30">
<button
type="button"
onClick={() => toggle(sectionKey)}
className="flex flex-1 items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors"
>
<span className="text-sm font-medium">{title}</span>
<div className="flex items-center gap-2">
<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",
)}
/>
</div>
</button>
{open && (
<div className="flex gap-1 pr-3 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px]"
onClick={(e) => {
e.stopPropagation();
setSectionAll(filtered, "1");
}}
>
All On
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px]"
onClick={(e) => {
e.stopPropagation();
setSectionAll(filtered, "0");
}}
>
All Off
</Button>
</div>
)}
</div>
{open && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border">
{filtered.map((key) => (
<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)]",
)}
>
<span className="font-mono text-xs truncate mr-3">{key}</span>
<PermToggle
value={perms[key] ?? "0"}
maxValue={initialRank.permissionMaxValues[key] ?? 1}
onChange={(v) => setPerms((p) => ({ ...p, [key]: v }))}
/>
</div>
))}
</div>
)}
</div>
);
}
const filteredCms = cmsSearch
? allCmsPermissions.filter(
(p) =>
p.slug.toLowerCase().includes(cmsSearch.toLowerCase()) ||
p.title?.toLowerCase().includes(cmsSearch.toLowerCase()),
)
: allCmsPermissions;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<Link href="/admin/permissions">
<Button variant="ghost" size="icon" className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
{initialRank.id >= 7 ? (
<Crown className="h-6 w-6 text-[var(--admin-warning)]" />
) : initialRank.id >= 5 ? (
<ShieldCheck className="h-6 w-6 text-[var(--admin-info)]" />
) : (
<Shield className="h-6 w-6 text-muted-foreground" />
)}
<div>
<h1 className="text-2xl font-bold">
{general.rank_name || `Rank ${initialRank.id}`}
</h1>
<p className="text-sm text-muted-foreground">
Rank #{initialRank.id} &middot; Level {initialRank.level} &middot;{" "}
{totalUsers} users
</p>
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="general">
<TabsList>
<TabsTrigger value="general">
<Settings className="w-3.5 h-3.5 mr-1" /> General
</TabsTrigger>
<TabsTrigger value="emulator">
<Terminal className="w-3.5 h-3.5 mr-1" /> Emulator ({enabledCount})
</TabsTrigger>
<TabsTrigger value="cms">
<UserCheck className="w-3.5 h-3.5 mr-1" /> CMS (
{cmsPermSlugs.length})
</TabsTrigger>
<TabsTrigger value="users">Users ({totalUsers})</TabsTrigger>
</TabsList>
{/* ═══ General ═══ */}
<TabsContent value="general" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Rank Settings</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">Rank Name</Label>
<Input
value={general.rank_name}
onChange={(e) =>
setGeneral({ ...general, rank_name: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Badge</Label>
<Input
value={general.badge}
onChange={(e) =>
setGeneral({ ...general, badge: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Prefix</Label>
<Input
value={general.prefix}
onChange={(e) =>
setGeneral({ ...general, prefix: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Prefix Color</Label>
<div className="flex gap-2">
<Input
value={general.prefix_color}
onChange={(e) =>
setGeneral({ ...general, prefix_color: e.target.value })
}
/>
{general.prefix_color && (
<div
className="w-9 h-9 rounded border shrink-0"
style={{
backgroundColor: general.prefix_color.startsWith("#")
? general.prefix_color
: `#${general.prefix_color}`,
}}
/>
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm">Hidden Rank</Label>
<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" })
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm">Log Commands</Label>
<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" })
}
/>
</div>
<div className="space-y-1.5 rounded-lg border p-3">
<Label className="text-xs">Room Effect ID</Label>
<Input
type="number"
value={general.room_effect}
onChange={(e) => setNum("room_effect", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4">
<div className="space-y-1.5">
<Label className="text-xs">Auto Credits</Label>
<Input
type="number"
value={general.auto_credits_amount}
onChange={(e) =>
setNum("auto_credits_amount", e.target.value)
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Auto Pixels</Label>
<Input
type="number"
value={general.auto_pixels_amount}
onChange={(e) =>
setNum("auto_pixels_amount", e.target.value)
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Auto GOTW</Label>
<Input
type="number"
value={general.auto_gotw_amount}
onChange={(e) => setNum("auto_gotw_amount", e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Auto Points</Label>
<Input
type="number"
value={general.auto_points_amount}
onChange={(e) =>
setNum("auto_points_amount", e.target.value)
}
/>
</div>
</div>
<div className="flex justify-end mt-4">
<Button onClick={saveRank} disabled={isPending} size="sm">
{isPending ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Save className="w-4 h-4 mr-1" />
)}
Save
</Button>
</div>
</CardContent>
</Card>
{/* Users preview */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Users with this rank
{totalUsers > users.length && (
<span className="text-muted-foreground font-normal ml-1 text-sm">
(showing {users.length} of {totalUsers})
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
{users.length === 0 ? (
<p className="text-sm text-muted-foreground">
No users with this rank.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
{users.map((u) => (
<Link
key={u.id}
href={`/admin/users/show/${u.id}`}
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,
})}
alt=""
className="w-8 h-12 object-cover"
/>
<span className="text-xs font-medium truncate">
{u.username}
</span>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* ═══ Emulator Permissions ═══ */}
<TabsContent value="emulator" className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<p className="text-sm text-muted-foreground">
{enabledCount} of {totalCount} permissions enabled
</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")}
>
Enable All
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setAllPerms("0")}
>
Disable All
</Button>
</div>
<div className="flex gap-2 text-[11px] text-muted-foreground">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded bg-[var(--admin-text-muted)] inline-block" />{" "}
Off
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded bg-[var(--admin-success-subtle)] inline-block" />{" "}
On
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded bg-[var(--admin-warning-subtle)] inline-block" />{" "}
Owner
</span>
</div>
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search permissions..."
value={permSearch}
onChange={(e) => setPermSearch(e.target.value)}
className="pl-9"
/>
</div>
<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" />
)}
<div className="flex justify-end">
<Button onClick={saveRank} disabled={isPending}>
{isPending ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Save className="w-4 h-4 mr-1" />
)}
Save Rank
</Button>
</div>
</TabsContent>
{/* ═══ CMS Permissions ═══ */}
<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>
</div>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{cmsPermSlugs.length} of {allCmsPermissions.length} assigned
</p>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={async () => {
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");
}
}}
>
Select All
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={async () => {
if (!initialCmsRole) return;
setCmsPermSlugs([]);
const r = await setCmsPermissions({
roleId: initialCmsRole.id,
permissionSlugs: [],
});
if (!r.ok) {
setCmsPermSlugs(initialCmsRole.permissionSlugs);
toast.error("Failed");
}
}}
>
Deselect All
</Button>
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search CMS permissions..."
value={cmsSearch}
onChange={(e) => setCmsSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="space-y-1">
{filteredCms.map((perm) => {
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",
)}
>
<Checkbox
checked={on}
onCheckedChange={() => toggleCms(perm.slug)}
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-mono">{perm.slug}</span>
{perm.title && (
<span className="text-xs text-muted-foreground ml-2">
{perm.title}
</span>
)}
</div>
{on && (
<Badge className="bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-[var(--admin-success-border)] text-[10px]">
Active
</Badge>
)}
</div>
);
})}
</div>
{filteredCms.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No permissions match your search.
</p>
)}
</>
)}
</TabsContent>
{/* ═══ Users ═══ */}
<TabsContent value="users">
<Card>
<CardHeader>
<CardTitle className="text-base">
Users with this rank
{totalUsers > users.length && (
<span className="text-muted-foreground font-normal ml-1 text-sm">
(showing {users.length} of {totalUsers})
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
{users.length === 0 ? (
<p className="text-sm text-muted-foreground">
No users with this rank.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
{users.map((u) => (
<Link
key={u.id}
href={`/admin/users/show/${u.id}`}
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,
})}
alt=""
className="w-8 h-12 object-cover"
/>
<span className="text-xs font-medium truncate">
{u.username}
</span>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
+31
View File
@@ -0,0 +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 }, (_, i) => i).map((n) => (
<div
key={`skeleton-row-${n}`}
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>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { sql } from "drizzle-orm";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
import { PermissionsList } from "./permissions-list";
export default async function PermissionsPage({
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams?: Promise<{ from?: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)) {
redirect("/admin");
}
const sp = (await searchParams) ?? {};
const fromHousekeeping = sp.from === "housekeeping";
const ranks = await fetchEmulatorRankSummaries(db);
// User counts per rank
const [rawCounts] = (await db.execute(
sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``,
)) as unknown as [{ rank: number; cnt: number }[], unknown[]];
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,
}));
return (
<PermissionsList
ranks={ranksWithCounts}
fromHousekeeping={fromHousekeeping}
/>
);
}
@@ -0,0 +1,316 @@
"use client";
import {
Crown,
EyeOff,
Pencil,
Plus,
Shield,
ShieldCheck,
Trash2,
UserCheck,
Users,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
createRank,
deleteRank,
repairAdminNavAclGrants,
} from "@/actions/permissions";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
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;
}
interface Props {
ranks: RankSummary[];
fromHousekeeping?: boolean;
}
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)]" />,
};
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)]",
};
export function PermissionsList({
ranks: initialRanks,
fromHousekeeping = false,
}: Props) {
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 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 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">
{fromHousekeeping ? (
<div
className="rounded-lg border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-4 py-3 text-sm"
role="status"
>
You were sent here from the permission archive. Edit rank access below
— this is the live control panel.
</div>
) : null}
<div
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm text-[var(--admin-text)]"
role="status"
>
<p className="m-0 font-semibold">Live access control</p>
<p className="m-0 mt-1 text-muted-foreground">
Changes here decide who can open admin and mod pages. The permission
archive is only for compare/export.
</p>
</div>
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-muted-foreground">
{ranks.length} ranks &middot; {totalUsers.toLocaleString()} users
total
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
disabled={isPending}
onClick={() =>
run(() => repairAdminNavAclGrants(), {
successMessage: "Admin nav ACL grants repaired",
})
}
title="Fill missing admin.* grants for ranks that already have dashboard access (same as migration 0018)"
>
<ShieldCheck className="h-4 w-4 mr-1.5" />
Repair nav grants
</Button>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-1.5" />
New Rank
</Button>
</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>
{/* 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>
{/* 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 &quot;{deleteTarget?.rank_name}&quot;
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>
);
}