Reapply "Add missing admin action files and navigation links"
This reverts commit 4d515bc400.
This commit is contained in:
1 parent
96ed768f14
commit
5b4228261a
338 files changed
+28143
-5948
No files matched your search
@@ -0,0 +1,75 @@
|
||||
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')
|
||||
|
||||
const rankId = parseInt(rawId, 10)
|
||||
if (Number.isNaN(rankId)) 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,
|
||||
})
|
||||
|
||||
const totalUsers = await prisma.user.count({ where: { rank: rankId } })
|
||||
|
||||
// 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
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
Crown,
|
||||
Loader2,
|
||||
Save,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
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'
|
||||
|
||||
/* ─── 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-zinc-500 text-white shadow-sm'
|
||||
: v === '1'
|
||||
? 'bg-emerald-500 text-white shadow-sm'
|
||||
: 'bg-amber-500 text-white 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() {
|
||||
run(() => saveRankAction({ id: initialRank.id, fields: { ...general, ...perms } }), {
|
||||
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
|
||||
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-emerald-500/5',
|
||||
)}
|
||||
>
|
||||
<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-yellow-500" />
|
||||
) : initialRank.id >= 5 ? (
|
||||
<ShieldCheck className="h-6 w-6 text-blue-500" />
|
||||
) : (
|
||||
<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} · Level {initialRank.level} · {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-zinc-500 inline-block" /> Off
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded bg-emerald-500 inline-block" /> On
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded bg-amber-500 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-emerald-500/40 bg-emerald-500/5' : '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-emerald-500/15 text-emerald-600 border-emerald-500/30 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,150 +1,40 @@
|
||||
import {
|
||||
createPermission,
|
||||
deletePermission,
|
||||
updatePermission,
|
||||
} from "@/actions/admin-permissions";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Shield } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
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 const dynamic = "force-dynamic";
|
||||
export default async function PermissionsPage(_props: { params: Promise<{ locale: string }> }) {
|
||||
const { permissions } = await getAdminContext()
|
||||
if (!permissions.isSuperAdmin) redirect('/admin')
|
||||
|
||||
// Manages website_permissions (model WebsitePermissions) — the CMS-owned
|
||||
// permission -> minimum-rank mapping. Each row names a permission and the lowest
|
||||
// rank allowed to use it, with an optional description.
|
||||
type PermissionRow = {
|
||||
id: bigint;
|
||||
permission: string;
|
||||
minRank: number;
|
||||
description: string | null;
|
||||
};
|
||||
const ranks = await fetchEmulatorRankSummaries(prisma)
|
||||
|
||||
export default async function AdminPermissions() {
|
||||
const t = await getTranslations("pages.admin.permissions");
|
||||
let permissions: PermissionRow[] = [];
|
||||
try {
|
||||
permissions = await prisma.websitePermissions.findMany({
|
||||
select: { id: true, permission: true, minRank: true, description: true },
|
||||
orderBy: { minRank: "asc" },
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
permissions = [];
|
||||
// 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 highestRank = permissions.reduce((max, p) => Math.max(max, p.minRank), 0);
|
||||
// Auto-create CMS roles for ranks >= 4
|
||||
for (const rank of ranks) {
|
||||
if (rank.id >= 4) {
|
||||
await prisma.aclRole.upsert({
|
||||
where: { slug: `rank_${rank.id}` },
|
||||
create: { slug: `rank_${rank.id}`, title: rank.rank_name || `Rank ${rank.id}` },
|
||||
update: {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
|
||||
<Shield size={20} className="text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
const ranksWithCounts = ranks.map((rank) => ({
|
||||
...rank,
|
||||
userCount: countMap[rank.id] ?? 0,
|
||||
}))
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("permissions")} value={permissions.length} icon="🔑" />
|
||||
<StatusCard label={t("highestMinRank")} value={highestRank} icon="🎖️" />
|
||||
</div>
|
||||
|
||||
<form action={createPermission} className="admin-card mb-6">
|
||||
<h3 className="text-sm font-bold text-[var(--color-text)] m-0 mb-3">{t("addPermission")}</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input name="permission" placeholder={t("permissionPlaceholder")} required maxLength={255} />
|
||||
<input
|
||||
name="minRank"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("minRankPlaceholder")}
|
||||
defaultValue={1}
|
||||
className="w-[110px]"
|
||||
/>
|
||||
<input
|
||||
name="description"
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("permissions")} ({permissions.length})</h2>
|
||||
{permissions.length === 0 ? (
|
||||
<div className="admin-empty">{t("noPermissions")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colId")}</th>
|
||||
<th>{t("colPermission")}</th>
|
||||
<th>{t("colMinRank")}</th>
|
||||
<th>{t("colDescription")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions.map((p) => (
|
||||
<tr key={String(p.id)}>
|
||||
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{String(p.id)}</td>
|
||||
<td>
|
||||
<form
|
||||
id={`perm-${String(p.id)}`}
|
||||
action={updatePermission}
|
||||
style={{ display: "contents" }}
|
||||
>
|
||||
<input type="hidden" name="id" value={String(p.id)} />
|
||||
<input name="permission" defaultValue={p.permission} required maxLength={255} />
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
form={`perm-${String(p.id)}`}
|
||||
name="minRank"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={p.minRank}
|
||||
className="w-[90px]"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
form={`perm-${String(p.id)}`}
|
||||
name="description"
|
||||
defaultValue={p.description ?? ""}
|
||||
className="w-full"
|
||||
/>
|
||||
</td>
|
||||
<td className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
form={`perm-${String(p.id)}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
<form action={deletePermission}>
|
||||
<input type="hidden" name="id" value={String(p.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("delete")}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
return <PermissionsList ranks={ranksWithCounts} />
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'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 {
|
||||
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[]
|
||||
}
|
||||
|
||||
const RANK_ICONS: Record<number, React.ReactNode> = {
|
||||
7: <Crown className="h-4 w-4 text-yellow-500" />,
|
||||
6: <ShieldCheck className="h-4 w-4 text-red-500" />,
|
||||
5: <Shield className="h-4 w-4 text-blue-500" />,
|
||||
4: <UserCheck className="h-4 w-4 text-green-500" />,
|
||||
}
|
||||
|
||||
const RANK_COLORS: Record<number, string> = {
|
||||
7: 'border-yellow-500/50 bg-yellow-500/5',
|
||||
6: 'border-red-500/30 bg-red-500/5',
|
||||
5: 'border-blue-500/30 bg-blue-500/5',
|
||||
4: 'border-green-500/30 bg-green-500/5',
|
||||
}
|
||||
|
||||
export function PermissionsList({ ranks: initialRanks }: 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">
|
||||
{/* 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>
|
||||
|
||||
{/* 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 "{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