feat: roles/permissions system and Nostr profile display on admin users
Introduce granular role-based permissions with SuperAdmin env override, admin roles UI, and permission-gated API routes. Fix admin user Nostr metadata by batching relay profile fetches, normalizing npub pubkeys to hex, and adding reusable NostrAvatar/useNostrProfile components. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,28 +21,37 @@ import {
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
Building2,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/admin/overview", label: "Overview", icon: LayoutDashboard, adminOnly: false },
|
||||
{ href: "/admin/events", label: "Events", icon: Calendar, adminOnly: false },
|
||||
{ href: "/admin/organizers", label: "Organizers", icon: Building2, adminOnly: false },
|
||||
{ href: "/admin/gallery", label: "Gallery", icon: ImageIcon, adminOnly: false },
|
||||
{ href: "/admin/blog", label: "Blog", icon: FileText, adminOnly: false },
|
||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, adminOnly: false },
|
||||
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, adminOnly: false },
|
||||
{ href: "/admin/messages", label: "Board", icon: MessageSquare, adminOnly: false },
|
||||
{ href: "/admin/moderation", label: "Moderation", icon: Shield, adminOnly: false },
|
||||
{ href: "/admin/categories", label: "Categories", icon: Tag, adminOnly: false },
|
||||
{ href: "/admin/users", label: "Users", icon: Users, adminOnly: true },
|
||||
{ href: "/admin/relays", label: "Relays", icon: Radio, adminOnly: true },
|
||||
{ href: "/admin/settings", label: "Settings", icon: Settings, adminOnly: true },
|
||||
{ href: "/admin/nostr", label: "Nostr Tools", icon: Wrench, adminOnly: true },
|
||||
// Each item is shown when the user holds any of its permissions. Items with no
|
||||
// permissions are always visible. SuperAdmin sees everything via can().
|
||||
const navItems: {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: typeof LayoutDashboard;
|
||||
permissions?: string[];
|
||||
}[] = [
|
||||
{ href: "/admin/overview", label: "Overview", icon: LayoutDashboard },
|
||||
{ href: "/admin/events", label: "Events", icon: Calendar, permissions: ["events.create", "events.edit", "events.delete"] },
|
||||
{ href: "/admin/organizers", label: "Organizers", icon: Building2, permissions: ["organizers.manage"] },
|
||||
{ href: "/admin/gallery", label: "Gallery", icon: ImageIcon, permissions: ["gallery.upload", "gallery.delete"] },
|
||||
{ href: "/admin/blog", label: "Blog", icon: FileText, permissions: ["blog.draft", "blog.publish", "blog.delete"] },
|
||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, permissions: ["faq.manage"] },
|
||||
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, permissions: ["submissions.review"] },
|
||||
{ href: "/admin/messages", label: "Board", icon: MessageSquare, permissions: ["board.manage"] },
|
||||
{ href: "/admin/moderation", label: "Moderation", icon: Shield, permissions: ["moderation.act"] },
|
||||
{ href: "/admin/categories", label: "Categories", icon: Tag, permissions: ["categories.manage"] },
|
||||
{ href: "/admin/users", label: "Users", icon: Users, permissions: ["users.assign_role", "nip05.assign"] },
|
||||
{ href: "/admin/roles", label: "Roles", icon: KeyRound, permissions: ["roles.edit_permissions"] },
|
||||
{ href: "/admin/relays", label: "Relays", icon: Radio, permissions: ["relays.manage"] },
|
||||
{ href: "/admin/settings", label: "Settings", icon: Settings, permissions: ["settings.edit"] },
|
||||
{ href: "/admin/nostr", label: "Nostr Tools", icon: Wrench, permissions: ["nostr_tools.use"] },
|
||||
];
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
const { user, logout, can } = useAuth();
|
||||
|
||||
const shortPubkey = user?.pubkey
|
||||
? `${user.pubkey.slice(0, 8)}...${user.pubkey.slice(-8)}`
|
||||
@@ -68,19 +77,22 @@ export function AdminSidebar() {
|
||||
<p className="text-on-surface/70 text-sm font-mono truncate">{shortPubkey}</p>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block mt-1 rounded-full px-3 py-1 text-xs font-bold",
|
||||
user.role === "ADMIN"
|
||||
"inline-block mt-1 rounded-full px-3 py-1 text-xs font-bold capitalize",
|
||||
user.isSuperAdmin || user.role === "admin"
|
||||
? "bg-primary-container/20 text-primary"
|
||||
: "bg-secondary-container text-on-secondary-container"
|
||||
)}
|
||||
>
|
||||
{user.role}
|
||||
{user.isSuperAdmin ? "SuperAdmin" : user.role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1">
|
||||
{navItems
|
||||
.filter((item) => !item.adminOnly || isAdmin)
|
||||
.filter(
|
||||
(item) =>
|
||||
!item.permissions || item.permissions.some((p) => can(p))
|
||||
)
|
||||
.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = pathname === item.href;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useNostrProfile } from "@/hooks/useNostrProfile";
|
||||
import type { NostrProfile } from "@/lib/nostr";
|
||||
|
||||
function computeInitials(profile: NostrProfile | null, fallback?: string): string {
|
||||
const name = profile?.name || profile?.displayName;
|
||||
if (name?.trim()) return name.trim().slice(0, 2).toUpperCase();
|
||||
const fb = fallback?.trim();
|
||||
if (fb) {
|
||||
// For npub-style fallbacks skip the "npub1" prefix for nicer initials.
|
||||
if (fb.startsWith("npub1") && fb.length >= 8) return fb.slice(5, 7).toUpperCase();
|
||||
return fb.slice(0, 2).toUpperCase();
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
export interface NostrAvatarProps {
|
||||
pubkey: string | null | undefined;
|
||||
/** Rendered size in pixels (square). */
|
||||
size?: number;
|
||||
/** Text used to derive initials when no Nostr name/picture is available. */
|
||||
fallbackText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Self-contained avatar: fetches the user's Nostr metadata from relays and
|
||||
// renders their profile picture, falling back to initials.
|
||||
export function NostrAvatar({
|
||||
pubkey,
|
||||
size = 56,
|
||||
fallbackText,
|
||||
className = "",
|
||||
}: NostrAvatarProps) {
|
||||
const { profile, loading } = useNostrProfile(pubkey);
|
||||
const displayName = profile?.name || profile?.displayName;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shrink-0 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden text-on-surface ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="text-on-surface/40 text-xs">…</span>
|
||||
) : profile?.picture ? (
|
||||
<Image
|
||||
src={profile.picture}
|
||||
alt={displayName ? `Avatar: ${displayName}` : "Nostr profile picture"}
|
||||
width={size}
|
||||
height={size}
|
||||
className="object-cover w-full h-full"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<span className="font-semibold text-sm" aria-hidden>
|
||||
{computeInitials(profile, fallbackText)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -80,7 +80,8 @@ export function Navbar() {
|
||||
}
|
||||
|
||||
const displayName = user?.name || user?.displayName || shortenPubkey(user?.pubkey || "");
|
||||
const isStaff = user?.role === "ADMIN" || user?.role === "MODERATOR";
|
||||
const isStaff =
|
||||
!!user?.isSuperAdmin || (user?.permissions?.length ?? 0) > 0;
|
||||
|
||||
function handleLogout() {
|
||||
setDropdownOpen(false);
|
||||
|
||||
Reference in New Issue
Block a user