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:
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user