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
+55 -6
View File
@@ -15,6 +15,8 @@ import {
export interface User {
pubkey: string;
role: string;
isSuperAdmin?: boolean;
permissions?: string[];
username?: string;
name?: string;
picture?: string;
@@ -32,17 +34,25 @@ interface AuthContextType {
logout: () => void;
isAdmin: boolean;
isModerator: boolean;
isSuperAdmin: boolean;
permissions: string[];
can: (permission: string) => boolean;
refreshAccess: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
login: async () => ({ pubkey: "", role: "USER" }),
loginWithBunker: async () => ({ pubkey: "", role: "USER" }),
loginWithConnectedSigner: async () => ({ pubkey: "", role: "USER" }),
login: async () => ({ pubkey: "", role: "guest" }),
loginWithBunker: async () => ({ pubkey: "", role: "guest" }),
loginWithConnectedSigner: async () => ({ pubkey: "", role: "guest" }),
logout: () => {},
isAdmin: false,
isModerator: false,
isSuperAdmin: false,
permissions: [],
can: () => false,
refreshAccess: async () => {},
});
export function useAuth() {
@@ -53,6 +63,28 @@ export function useAuthProvider(): AuthContextType {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refreshAccess = useCallback(async () => {
const token = localStorage.getItem("bbe_token");
if (!token) return;
try {
const me = await api.getMe();
setUser((prev) => {
if (!prev) return prev;
const next: User = {
...prev,
role: me.role,
isSuperAdmin: me.isSuperAdmin,
permissions: me.permissions,
username: me.username ?? prev.username,
};
localStorage.setItem("bbe_user", JSON.stringify(next));
return next;
});
} catch {
// Best-effort refresh. The stored snapshot remains usable.
}
}, []);
useEffect(() => {
const stored = localStorage.getItem("bbe_user");
const token = localStorage.getItem("bbe_token");
@@ -63,9 +95,12 @@ export function useAuthProvider(): AuthContextType {
localStorage.removeItem("bbe_user");
localStorage.removeItem("bbe_token");
}
// Refresh effective role and permissions live so a stale JWT snapshot does
// not drive what the user can see or do.
void refreshAccess();
}
setLoading(false);
}, []);
}, [refreshAccess]);
const completeAuth = useCallback(
async (
@@ -87,6 +122,8 @@ export function useAuthProvider(): AuthContextType {
const fullUser: User = {
...userData,
isSuperAdmin: userData.isSuperAdmin ?? false,
permissions: userData.permissions ?? [],
name: profile.name,
displayName: profile.displayName,
picture: profile.picture,
@@ -138,6 +175,13 @@ export function useAuthProvider(): AuthContextType {
setUser(null);
}, []);
const permissions = user?.permissions ?? [];
const isSuperAdmin = user?.isSuperAdmin ?? false;
const can = useCallback(
(permission: string) => isSuperAdmin || permissions.includes(permission),
[isSuperAdmin, permissions]
);
return {
user,
loading,
@@ -145,7 +189,12 @@ export function useAuthProvider(): AuthContextType {
loginWithBunker,
loginWithConnectedSigner,
logout,
isAdmin: user?.role === "ADMIN",
isModerator: user?.role === "MODERATOR" || user?.role === "ADMIN",
isSuperAdmin,
permissions,
can,
refreshAccess,
// Compatibility shims for any remaining role-name checks.
isAdmin: isSuperAdmin || user?.role === "admin",
isModerator: isSuperAdmin || user?.role === "admin" || user?.role === "moderator",
};
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useEffect, useState } from "react";
import { loadNostrProfile, type NostrProfile } from "@/lib/nostr";
export interface UseNostrProfileResult {
profile: NostrProfile | null;
loading: boolean;
}
// Fetches a single user's Nostr kind:0 metadata from relays. Concurrent calls
// across components are batched into one relay query by the shared loader.
export function useNostrProfile(
pubkey: string | null | undefined
): UseNostrProfileResult {
const [profile, setProfile] = useState<NostrProfile | null>(null);
const [loading, setLoading] = useState<boolean>(!!pubkey);
useEffect(() => {
if (!pubkey) {
setProfile(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
loadNostrProfile(pubkey)
.then((p) => {
if (!cancelled) {
setProfile(p);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setProfile({});
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [pubkey]);
return { profile, loading };
}