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:
bbe
2026-06-23 08:46:56 +02:00
co-authored by Cursor
parent 3bdd01dedf
commit 70e3e0633d
38 changed files with 1556 additions and 419 deletions
+32 -20
View File
@@ -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;
+62
View File
@@ -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>
);
}
+2 -1
View File
@@ -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);