Files
BelgianBitcoinEmbassy/frontend/hooks/useAuth.ts
T
bbeandCursor 70e3e0633d 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>
2026-06-23 08:46:56 +02:00

201 lines
5.4 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, createContext, useContext } from "react";
import { api } from "@/lib/api";
import {
hasNostrExtension,
getPublicKey,
signEvent,
createAuthEvent,
fetchNostrProfile,
createBunkerSigner,
type BunkerSignerInterface,
type NostrProfile,
} from "@/lib/nostr";
export interface User {
pubkey: string;
role: string;
isSuperAdmin?: boolean;
permissions?: string[];
username?: string;
name?: string;
picture?: string;
about?: string;
nip05?: string;
displayName?: string;
}
interface AuthContextType {
user: User | null;
loading: boolean;
login: () => Promise<User>;
loginWithBunker: (input: string) => Promise<User>;
loginWithConnectedSigner: (signer: BunkerSignerInterface) => Promise<User>;
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: "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() {
return useContext(AuthContext);
}
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");
if (stored && token) {
try {
setUser(JSON.parse(stored));
} catch {
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 (
getPubKey: () => Promise<string>,
sign: (event: any) => Promise<any>
): Promise<User> => {
const pubkey = await getPubKey();
const { challenge } = await api.getChallenge(pubkey);
const event = createAuthEvent(pubkey, challenge);
const signedEvent = await sign(event);
const { token, user: userData } = await api.verify(pubkey, signedEvent);
let profile: NostrProfile = {};
try {
profile = await fetchNostrProfile(pubkey);
} catch {
// Profile fetch is best-effort
}
const fullUser: User = {
...userData,
isSuperAdmin: userData.isSuperAdmin ?? false,
permissions: userData.permissions ?? [],
name: profile.name,
displayName: profile.displayName,
picture: profile.picture,
about: profile.about,
nip05: profile.nip05,
username: userData.username,
};
localStorage.setItem("bbe_token", token);
localStorage.setItem("bbe_user", JSON.stringify(fullUser));
setUser(fullUser);
return fullUser;
},
[]
);
const login = useCallback(async (): Promise<User> => {
if (!hasNostrExtension()) {
throw new Error("Please install a Nostr extension (e.g., Alby, nos2x)");
}
return completeAuth(getPublicKey, signEvent);
}, [completeAuth]);
const loginWithConnectedSigner = useCallback(
async (signer: BunkerSignerInterface): Promise<User> => {
return completeAuth(
() => signer.getPublicKey(),
(event) => signer.signEvent(event)
);
},
[completeAuth]
);
const loginWithBunker = useCallback(
async (input: string): Promise<User> => {
const { signer } = await createBunkerSigner(input);
try {
return await loginWithConnectedSigner(signer);
} finally {
await signer.close().catch(() => {});
}
},
[loginWithConnectedSigner]
);
const logout = useCallback(() => {
localStorage.removeItem("bbe_token");
localStorage.removeItem("bbe_user");
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,
login,
loginWithBunker,
loginWithConnectedSigner,
logout,
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",
};
}