From 70e3e0633dc42f8b01d4418417f29e9e18297f6a Mon Sep 17 00:00:00 2001 From: bbe Date: Tue, 23 Jun 2026 08:46:56 +0200 Subject: [PATCH] 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 --- .env.example | 7 +- CHANGELOG.md | 41 ++ README.md | 49 +- .../migration.sql | 83 ++++ backend/prisma/schema.prisma | 13 +- backend/prisma/seed.ts | 13 + backend/src/api/adminMessages.ts | 4 +- backend/src/api/auth.ts | 46 +- backend/src/api/categories.ts | 8 +- backend/src/api/faqs.ts | 12 +- backend/src/api/media.ts | 8 +- backend/src/api/meetups.ts | 12 +- backend/src/api/moderation.ts | 14 +- backend/src/api/nostr.ts | 8 +- backend/src/api/organizers.ts | 8 +- backend/src/api/posts.ts | 8 +- backend/src/api/relays.ts | 12 +- backend/src/api/roles.ts | 128 +++++ backend/src/api/settings.ts | 6 +- backend/src/api/submissions.ts | 6 +- backend/src/api/users.ts | 106 +++-- backend/src/constants/permissions.ts | 97 ++++ backend/src/index.ts | 2 + backend/src/middleware/auth.ts | 22 +- backend/src/services/auth.ts | 83 +++- frontend/app/admin/layout.tsx | 12 +- frontend/app/admin/overview/page.tsx | 6 +- frontend/app/admin/page.tsx | 6 +- frontend/app/admin/roles/page.tsx | 230 +++++++++ frontend/app/admin/users/page.tsx | 447 +++++++++--------- frontend/app/login/page.tsx | 12 +- frontend/components/admin/AdminSidebar.tsx | 52 +- frontend/components/nostr/NostrAvatar.tsx | 62 +++ frontend/components/public/Navbar.tsx | 3 +- frontend/hooks/useAuth.ts | 61 ++- frontend/hooks/useNostrProfile.ts | 48 ++ frontend/lib/api.ts | 41 +- frontend/lib/nostr.ts | 199 +++++++- 38 files changed, 1556 insertions(+), 419 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 backend/prisma/migrations/20260623000000_roles_permissions/migration.sql create mode 100644 backend/src/api/roles.ts create mode 100644 backend/src/constants/permissions.ts create mode 100644 frontend/app/admin/roles/page.tsx create mode 100644 frontend/components/nostr/NostrAvatar.tsx create mode 100644 frontend/hooks/useNostrProfile.ts diff --git a/.env.example b/.env.example index 549ed86..5e0c460 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ -# Admin pubkeys (comma-separated hex pubkeys) -ADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2 +# SuperAdmin pubkeys (comma-separated hex or npub). SuperAdmins always have every +# permission, are env-only, and can never be edited or removed through the UI. +# SUPERADMIN_PUBKEYS replaces the older ADMIN_PUBKEYS. If SUPERADMIN_PUBKEYS is +# unset, the app falls back to ADMIN_PUBKEYS so existing deployments keep working. +SUPERADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2 # Nostr relays (comma-separated) RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d385e68 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +## Unreleased + +### Roles and permissions system + +Replaced the single moderator promote/demote toggle with a full role hierarchy +and a runtime-editable, granular permission system. + +- **Role hierarchy**: SuperAdmin (env-only) > Admin > Moderator > Writer > Guest + (absence of a role). SuperAdmin always holds every permission and can never be + edited, demoted, or removed through the UI. +- **New env var `SUPERADMIN_PUBKEYS`** (comma-separated hex or npub) replaces the + old env-admin concept. Falls back to `ADMIN_PUBKEYS` when unset so existing + deployments keep working. +- **Permission registry**: a central list of granular keys in + `backend/src/constants/permissions.ts` is the single source of truth. Adding a + key there surfaces it automatically in the API and the Roles matrix page. +- **Authorization**: a single `requires(permission)` guard replaces the old + role-name middleware and is applied to every protected endpoint. Roles and + permissions are resolved live per request, so a stale JWT no longer drives + access. SuperAdmin bypasses all checks. +- **Data model**: `User.role` is now nullable (null means Guest) and uses the + lowercase enum `admin`, `moderator`, `writer`. A new `RolePermission` table + maps roles to permission keys. A migration backfills legacy uppercase roles and + seeds default permission sets; it is idempotent and safe on a live database. +- **New endpoints**: `GET /api/auth/me`, `GET /api/admin/permissions`, + `GET /api/admin/roles`, `PUT /api/admin/roles/:role/permissions`, and + `PUT /api/users/:pubkey/role` (replacing `POST /api/users/promote` and + `POST /api/users/demote`). +- **Dashboard**: User Management now uses a per-user role selector with hierarchy + enforcement and a locked SuperAdmin badge. A new SuperAdmin-only Roles page + renders a permission-by-role matrix. Nav items are gated on resolved + permissions. The NIP-05 reserved-username assignment is unchanged. + +#### Migration notes + +After pulling, from `backend` run `npm run migrate:deploy` (or, for databases +created with `db push`, `npm run db:baseline-and-migrate`). Set +`SUPERADMIN_PUBKEYS` in your `.env`. Existing moderators become `moderator`, +existing database admins become `admin`, and prior env admins become SuperAdmin. diff --git a/README.md b/README.md index 676e8e5..4fe3edb 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ cp .env.example backend/.env cp .env.example frontend/.env.local ``` -Edit `backend/.env` with your admin pubkeys and a secure JWT secret. +Edit `backend/.env` with your SuperAdmin pubkeys (`SUPERADMIN_PUBKEYS`) and a secure JWT secret. ### 3. Set up database @@ -91,15 +91,54 @@ npm run dev | PATCH | /api/meetups/:id | Update meetup | | POST | /api/moderation/hide | Hide content | | POST | /api/moderation/block | Block pubkey | +| GET | /api/auth/me | Current user's effective role and permissions | | GET | /api/users | List users | -| POST | /api/users/promote | Promote user | +| PUT | /api/users/:pubkey/role | Assign a role to a user | +| GET | /api/admin/permissions | Permission registry (keys, labels, groups) | +| GET | /api/admin/roles | Each role and its permission set | +| PUT | /api/admin/roles/:role/permissions | Update a role's permissions | | GET | /api/categories | List categories | | POST | /api/categories | Create category | -## Roles +## Roles and permissions -- **Admin**: Full access. Defined by pubkeys in `.env` -- **Moderator**: Content moderation. Assigned by admins via dashboard. +Access is controlled by an ordered role hierarchy backed by a runtime-editable, +granular permission system. From highest to lowest: + +1. **SuperAdmin** sourced only from the `SUPERADMIN_PUBKEYS` env var. Always has + every permission. Cannot be created, edited, demoted, or removed through the + UI. This replaces the old "admin set in env" concept. If `SUPERADMIN_PUBKEYS` + is unset, the app falls back to the legacy `ADMIN_PUBKEYS` variable. +2. **Admin** highest role assignable through the dashboard. +3. **Moderator** +4. **Writer** +5. **Guest** the fallback for any logged-in pubkey with no assigned role. Guest is + not a stored record, it is simply the absence of a role. + +Each assignable role (Admin, Moderator, Writer) maps to a set of permission keys +stored in the `RolePermission` table. SuperAdmins manage these from the dashboard +**Roles** page, which renders a permission-by-role matrix. The dashboard nav and +actions are gated on the current user's resolved permissions, not on role names. + +### Safeguards + +- SuperAdmin is never assignable through the UI or API. Only the env var grants it. +- A user cannot assign a role at or above their own, or modify a SuperAdmin. +- A user cannot grant a role a permission they do not themselves hold. +- A user cannot remove their own `users.assign_role` or `roles.edit_permissions`. + SuperAdmin (env-sourced) is always the recovery path. +- Unknown role values and permission keys are rejected by the server. + +### Adding a new permission key + +1. Add an entry to `PERMISSIONS` in + [backend/src/constants/permissions.ts](backend/src/constants/permissions.ts) + with a `key`, `label`, and `group`. +2. The key automatically appears in `GET /admin/permissions` and the Roles matrix + page, so no UI changes are needed. +3. Apply it to the relevant endpoints with the `requires('')` guard. +4. Optionally grant it to roles by default in `DEFAULT_ROLE_PERMISSIONS` (used by + the seed) and in the migration so fresh and existing databases get it. ## License diff --git a/backend/prisma/migrations/20260623000000_roles_permissions/migration.sql b/backend/prisma/migrations/20260623000000_roles_permissions/migration.sql new file mode 100644 index 0000000..4d27aeb --- /dev/null +++ b/backend/prisma/migrations/20260623000000_roles_permissions/migration.sql @@ -0,0 +1,83 @@ +-- Roles and permissions system. +-- Makes User.role nullable (null means Guest), backfills legacy uppercase roles +-- to the new lowercase enum, adds the RolePermission table, and seeds default +-- permission sets. Safe and idempotent to run on a live database. + +-- RedefineTables: make User.role nullable and drop its default, converting values. +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "pubkey" TEXT NOT NULL, + "role" TEXT, + "displayName" TEXT, + "username" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_User" ("id", "pubkey", "role", "displayName", "username", "createdAt", "updatedAt") +SELECT + "id", + "pubkey", + CASE + WHEN "role" = 'ADMIN' THEN 'admin' + WHEN "role" = 'MODERATOR' THEN 'moderator' + WHEN "role" = 'WRITER' THEN 'writer' + WHEN "role" IN ('admin', 'moderator', 'writer') THEN "role" + ELSE NULL + END, + "displayName", + "username", + "createdAt", + "updatedAt" +FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey"); +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "RolePermission" ( + "role" TEXT NOT NULL, + "permission" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY ("role", "permission") +); + +-- Seed default permission sets. INSERT OR IGNORE keeps this idempotent and never +-- clobbers permissions an operator has already customized at runtime. +INSERT OR IGNORE INTO "RolePermission" ("role", "permission") VALUES + ('admin', 'events.create'), + ('admin', 'events.edit'), + ('admin', 'events.delete'), + ('admin', 'organizers.manage'), + ('admin', 'gallery.upload'), + ('admin', 'gallery.delete'), + ('admin', 'blog.draft'), + ('admin', 'blog.publish'), + ('admin', 'blog.delete'), + ('admin', 'faq.manage'), + ('admin', 'submissions.review'), + ('admin', 'board.manage'), + ('admin', 'moderation.act'), + ('admin', 'categories.manage'), + ('admin', 'users.assign_role'), + ('admin', 'nip05.assign'), + ('admin', 'relays.manage'), + ('admin', 'settings.edit'), + ('admin', 'nostr_tools.use'), + ('moderator', 'events.create'), + ('moderator', 'events.edit'), + ('moderator', 'events.delete'), + ('moderator', 'submissions.review'), + ('moderator', 'moderation.act'), + ('moderator', 'gallery.upload'), + ('moderator', 'board.manage'), + ('moderator', 'categories.manage'), + ('moderator', 'faq.manage'), + ('writer', 'blog.draft'), + ('writer', 'gallery.upload'), + ('writer', 'events.create'); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 59e653a..6fced5d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -10,13 +10,24 @@ datasource db { model User { id String @id @default(uuid()) pubkey String @unique - role String @default("USER") // USER, MODERATOR, ADMIN + role String? // admin, moderator, writer. Null means Guest (no elevated role). SuperAdmin is env-sourced and never stored. displayName String? username String? @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } +// Maps an assignable role to a granted permission key. Editable at runtime. +// One row per (role, permission). SuperAdmin is never stored here; it bypasses +// all checks via the env list. +model RolePermission { + role String + permission String + createdAt DateTime @default(now()) + + @@id([role, permission]) +} + model Organizer { id String @id @default(uuid()) name String diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 9dd545b..55cc321 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -1,6 +1,7 @@ import dotenv from 'dotenv'; import path from 'path'; import { PrismaClient } from '@prisma/client'; +import { DEFAULT_ROLE_PERMISSIONS } from '../src/constants/permissions'; dotenv.config({ path: path.resolve(__dirname, '../../.env') }); @@ -87,6 +88,18 @@ async function main() { }); } + // Seed default role permissions. This only fills in missing rows so it never + // clobbers permissions an operator has customized at runtime. + for (const [role, permissions] of Object.entries(DEFAULT_ROLE_PERMISSIONS)) { + for (const permission of permissions) { + await prisma.rolePermission.upsert({ + where: { role_permission: { role, permission } }, + update: {}, + create: { role, permission }, + }); + } + } + console.log('Seed completed successfully.'); } diff --git a/backend/src/api/adminMessages.ts b/backend/src/api/adminMessages.ts index e92c5cf..a5f7a83 100644 --- a/backend/src/api/adminMessages.ts +++ b/backend/src/api/adminMessages.ts @@ -1,10 +1,10 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; -import { requireAuth, requireRole } from '../middleware/auth'; +import { requireAuth, requires } from '../middleware/auth'; const router = Router(); -router.use(requireAuth, requireRole(['ADMIN', 'MODERATOR'])); +router.use(requireAuth, requires('board.manage')); router.get('/', async (_req: Request, res: Response) => { try { diff --git a/backend/src/api/auth.ts b/backend/src/api/auth.ts index 8a1b7cd..0d3511e 100644 --- a/backend/src/api/auth.ts +++ b/backend/src/api/auth.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from 'express'; import { authService } from '../services/auth'; import { prisma } from '../db/prisma'; +import { requireAuth } from '../middleware/auth'; const router = Router(); @@ -34,20 +35,53 @@ router.post('/verify', async (req: Request, res: Response) => { return; } - const role = await authService.getRole(pubkey); - + // Ensure a user row exists, but never overwrite an existing role on login. + // The role is managed only through role assignment, and SuperAdmin is env-sourced. const dbUser = await prisma.user.upsert({ where: { pubkey }, - update: { role }, - create: { pubkey, role }, + update: {}, + create: { pubkey }, }); - const token = authService.generateToken(pubkey, role); - res.json({ token, user: { pubkey, role, username: dbUser.username ?? undefined } }); + const access = await authService.resolveAccess(pubkey); + + // The token carries a display role for convenience only. Authorization is + // always resolved live from the env list and the database. + const token = authService.generateToken(pubkey, access.role); + res.json({ + token, + user: { + pubkey, + role: access.role, + isSuperAdmin: access.isSuperAdmin, + permissions: [...access.permissions], + username: dbUser.username ?? undefined, + }, + }); } catch (err) { console.error('Verify error:', err); res.status(500).json({ error: 'Internal server error' }); } }); +// Returns the current user's live effective role and permission set, for +// frontend gating. Identity comes from the JWT, authorization is resolved fresh. +router.get('/me', requireAuth, async (req: Request, res: Response) => { + try { + const pubkey = req.user!.pubkey; + const access = await authService.resolveAccess(pubkey); + const dbUser = await prisma.user.findUnique({ where: { pubkey } }); + res.json({ + pubkey, + role: access.role, + isSuperAdmin: access.isSuperAdmin, + permissions: [...access.permissions], + username: dbUser?.username ?? undefined, + }); + } catch (err) { + console.error('Me error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + export default router; diff --git a/backend/src/api/categories.ts b/backend/src/api/categories.ts index 7b7811b..ebcaf4a 100644 --- a/backend/src/api/categories.ts +++ b/backend/src/api/categories.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; -import { requireAuth, requireRole } from '../middleware/auth'; +import { requireAuth, requires } from '../middleware/auth'; const router = Router(); @@ -19,7 +19,7 @@ router.get('/', async (_req: Request, res: Response) => { router.post( '/', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('categories.manage'), async (req: Request, res: Response) => { try { const { name, slug, sortOrder } = req.body; @@ -47,7 +47,7 @@ router.post( router.patch( '/:id', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('categories.manage'), async (req: Request, res: Response) => { try { const category = await prisma.category.findUnique({ @@ -80,7 +80,7 @@ router.patch( router.delete( '/:id', requireAuth, - requireRole(['ADMIN']), + requires('categories.manage'), async (req: Request, res: Response) => { try { const category = await prisma.category.findUnique({ diff --git a/backend/src/api/faqs.ts b/backend/src/api/faqs.ts index 8b96e5b..9aa2f86 100644 --- a/backend/src/api/faqs.ts +++ b/backend/src/api/faqs.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; -import { requireAuth, requireRole } from '../middleware/auth'; +import { requireAuth, requires } from '../middleware/auth'; const router = Router(); @@ -23,7 +23,7 @@ router.get('/', async (req: Request, res: Response) => { router.get( '/all', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('faq.manage'), async (_req: Request, res: Response) => { try { const faqs = await prisma.faq.findMany({ @@ -41,7 +41,7 @@ router.get( router.post( '/', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('faq.manage'), async (req: Request, res: Response) => { try { const { question, answer, showOnHomepage } = req.body; @@ -75,7 +75,7 @@ router.post( router.patch( '/:id', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('faq.manage'), async (req: Request, res: Response) => { try { const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } }); @@ -107,7 +107,7 @@ router.patch( router.delete( '/:id', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('faq.manage'), async (req: Request, res: Response) => { try { const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } }); @@ -129,7 +129,7 @@ router.delete( router.post( '/reorder', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('faq.manage'), async (req: Request, res: Response) => { try { const { items } = req.body as { items: { id: string; order: number }[] }; diff --git a/backend/src/api/media.ts b/backend/src/api/media.ts index 064729a..7597223 100644 --- a/backend/src/api/media.ts +++ b/backend/src/api/media.ts @@ -5,7 +5,7 @@ import fs from 'fs'; import { ulid } from 'ulid'; import slugify from 'slugify'; import { prisma } from '../db/prisma'; -import { requireAuth, requireRole } from '../middleware/auth'; +import { requireAuth, requires } from '../middleware/auth'; const REPO_ROOT = path.resolve(__dirname, '../../..'); const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH @@ -43,7 +43,7 @@ const router = Router(); router.post( '/upload', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('gallery.upload'), upload.single('file'), async (req: Request, res: Response) => { try { @@ -142,7 +142,7 @@ router.get('/:id', async (req: Request, res: Response) => { router.patch( '/:id', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('gallery.upload'), async (req: Request, res: Response) => { try { const media = await prisma.media.findUnique({ @@ -180,7 +180,7 @@ router.patch( router.delete( '/:id', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('gallery.delete'), async (req: Request, res: Response) => { try { const media = await prisma.media.findUnique({ diff --git a/backend/src/api/meetups.ts b/backend/src/api/meetups.ts index 28f7d7c..14d990c 100644 --- a/backend/src/api/meetups.ts +++ b/backend/src/api/meetups.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; -import { requireAuth, requireRole } from '../middleware/auth'; +import { requireAuth, requires } from '../middleware/auth'; import { DEFAULT_ORGANIZER_SLUG } from '../constants/organizer'; import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint'; @@ -80,7 +80,7 @@ router.get('/:id', async (req: Request, res: Response) => { router.post( '/', requireAuth, - requireRole(['ADMIN']), + requires('events.create'), async (req: Request, res: Response) => { try { const { @@ -138,7 +138,7 @@ router.post( router.post( '/bulk', requireAuth, - requireRole(['ADMIN']), + requires('events.edit'), async (req: Request, res: Response) => { try { const { action, ids } = req.body as { action: string; ids: string[] }; @@ -200,7 +200,7 @@ router.post( router.post( '/:id/duplicate', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('events.create'), async (req: Request, res: Response) => { try { const original = await prisma.meetup.findUnique({ where: { id: req.params.id as string } }); @@ -237,7 +237,7 @@ router.post( router.patch( '/:id', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('events.edit'), async (req: Request, res: Response) => { try { const meetup = await prisma.meetup.findUnique({ @@ -292,7 +292,7 @@ router.patch( router.delete( '/:id', requireAuth, - requireRole(['ADMIN']), + requires('events.delete'), async (req: Request, res: Response) => { try { const meetup = await prisma.meetup.findUnique({ diff --git a/backend/src/api/moderation.ts b/backend/src/api/moderation.ts index 4b68244..1081a08 100644 --- a/backend/src/api/moderation.ts +++ b/backend/src/api/moderation.ts @@ -1,13 +1,13 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; -import { requireAuth, requireRole } from '../middleware/auth'; +import { requireAuth, requires } from '../middleware/auth'; const router = Router(); router.get( '/hidden', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('moderation.act'), async (_req: Request, res: Response) => { try { const hidden = await prisma.hiddenContent.findMany({ @@ -24,7 +24,7 @@ router.get( router.post( '/hide', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('moderation.act'), async (req: Request, res: Response) => { try { const { nostrEventId, reason } = req.body; @@ -52,7 +52,7 @@ router.post( router.delete( '/unhide/:id', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('moderation.act'), async (req: Request, res: Response) => { try { const item = await prisma.hiddenContent.findUnique({ @@ -75,7 +75,7 @@ router.delete( router.get( '/blocked', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('moderation.act'), async (_req: Request, res: Response) => { try { const blocked = await prisma.blockedPubkey.findMany({ @@ -92,7 +92,7 @@ router.get( router.post( '/block', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('moderation.act'), async (req: Request, res: Response) => { try { const { pubkey, reason } = req.body; @@ -120,7 +120,7 @@ router.post( router.delete( '/unblock/:id', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('moderation.act'), async (req: Request, res: Response) => { try { const item = await prisma.blockedPubkey.findUnique({ diff --git a/backend/src/api/nostr.ts b/backend/src/api/nostr.ts index a30053d..8ea117e 100644 --- a/backend/src/api/nostr.ts +++ b/backend/src/api/nostr.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; -import { requireAuth, requireRole } from '../middleware/auth'; +import { requireAuth, requires } from '../middleware/auth'; import { nostrService } from '../services/nostr'; const router = Router(); @@ -8,7 +8,7 @@ const router = Router(); router.post( '/fetch', requireAuth, - requireRole(['ADMIN']), + requires('nostr_tools.use'), async (req: Request, res: Response) => { try { const { eventId, naddr } = req.body; @@ -40,7 +40,7 @@ router.post( router.post( '/cache/refresh', requireAuth, - requireRole(['ADMIN']), + requires('nostr_tools.use'), async (_req: Request, res: Response) => { try { const cachedEvents = await prisma.nostrEventCache.findMany(); @@ -62,7 +62,7 @@ router.post( router.get( '/debug/:eventId', requireAuth, - requireRole(['ADMIN']), + requires('nostr_tools.use'), async (req: Request, res: Response) => { try { const cached = await prisma.nostrEventCache.findUnique({ diff --git a/backend/src/api/organizers.ts b/backend/src/api/organizers.ts index c8fabfe..3440788 100644 --- a/backend/src/api/organizers.ts +++ b/backend/src/api/organizers.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; -import { requireAuth, requireRole } from '../middleware/auth'; +import { requireAuth, requires } from '../middleware/auth'; import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint'; const router = Router(); @@ -38,7 +38,7 @@ router.get('/by-slug/:slug', async (req: Request, res: Response) => { router.post( '/', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('organizers.manage'), async (req: Request, res: Response) => { try { const { name, slug } = req.body; @@ -66,7 +66,7 @@ router.post( router.patch( '/:id', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('organizers.manage'), async (req: Request, res: Response) => { try { const organizer = await prisma.organizer.findUnique({ @@ -102,7 +102,7 @@ router.patch( router.delete( '/:id', requireAuth, - requireRole(['ADMIN']), + requires('organizers.manage'), async (req: Request, res: Response) => { try { const organizer = await prisma.organizer.findUnique({ diff --git a/backend/src/api/posts.ts b/backend/src/api/posts.ts index b25d725..7d5250b 100644 --- a/backend/src/api/posts.ts +++ b/backend/src/api/posts.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; -import { requireAuth, requireRole } from '../middleware/auth'; +import { requireAuth, requires } from '../middleware/auth'; import { nostrService } from '../services/nostr'; const router = Router(); @@ -66,7 +66,7 @@ router.get('/:slug', async (req: Request, res: Response) => { router.post( '/import', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('blog.draft'), async (req: Request, res: Response) => { try { const { nostrEventId, naddr, title, excerpt, authorPubkey, publishedAt, tags } = req.body; @@ -145,7 +145,7 @@ router.post( router.patch( '/:id', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('blog.draft'), async (req: Request, res: Response) => { try { const { title, slug, excerpt, featured, visible, categories } = req.body; @@ -240,7 +240,7 @@ router.get('/:slug/replies', async (req: Request, res: Response) => { router.delete( '/:id', requireAuth, - requireRole(['ADMIN']), + requires('blog.delete'), async (req: Request, res: Response) => { try { const post = await prisma.post.findUnique({ where: { id: req.params.id as string } }); diff --git a/backend/src/api/relays.ts b/backend/src/api/relays.ts index cb2586d..84439bc 100644 --- a/backend/src/api/relays.ts +++ b/backend/src/api/relays.ts @@ -1,7 +1,7 @@ import { Router, Request, Response } from 'express'; import { SimplePool } from 'nostr-tools'; import { prisma } from '../db/prisma'; -import { requireAuth, requireRole } from '../middleware/auth'; +import { requireAuth, requires } from '../middleware/auth'; const router = Router(); @@ -24,7 +24,7 @@ router.get( router.get( '/', requireAuth, - requireRole(['ADMIN']), + requires('relays.manage'), async (_req: Request, res: Response) => { try { const relays = await prisma.relay.findMany({ @@ -41,7 +41,7 @@ router.get( router.post( '/', requireAuth, - requireRole(['ADMIN']), + requires('relays.manage'), async (req: Request, res: Response) => { try { const { url, priority } = req.body; @@ -68,7 +68,7 @@ router.post( router.patch( '/:id', requireAuth, - requireRole(['ADMIN']), + requires('relays.manage'), async (req: Request, res: Response) => { try { const relay = await prisma.relay.findUnique({ @@ -101,7 +101,7 @@ router.patch( router.delete( '/:id', requireAuth, - requireRole(['ADMIN']), + requires('relays.manage'), async (req: Request, res: Response) => { try { const relay = await prisma.relay.findUnique({ @@ -124,7 +124,7 @@ router.delete( router.post( '/:id/test', requireAuth, - requireRole(['ADMIN']), + requires('relays.manage'), async (req: Request, res: Response) => { try { const relay = await prisma.relay.findUnique({ diff --git a/backend/src/api/roles.ts b/backend/src/api/roles.ts new file mode 100644 index 0000000..503bdf6 --- /dev/null +++ b/backend/src/api/roles.ts @@ -0,0 +1,128 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; +import { requireAuth, requires } from '../middleware/auth'; +import { + PERMISSIONS, + ASSIGNABLE_ROLES, + isAssignableRole, + isValidPermissionKey, +} from '../constants/permissions'; + +const router = Router(); + +// Returns the full permission registry plus the assignable roles. Adding a key +// to the registry surfaces it here automatically. +router.get( + '/permissions', + requireAuth, + requires('roles.edit_permissions'), + async (_req: Request, res: Response) => { + res.json({ + permissions: PERMISSIONS, + roles: ASSIGNABLE_ROLES, + }); + } +); + +// Returns each assignable role with its current permission set. +router.get( + '/roles', + requireAuth, + requires('roles.edit_permissions'), + async (_req: Request, res: Response) => { + try { + const rows = await prisma.rolePermission.findMany(); + const byRole: Record = {}; + for (const role of ASSIGNABLE_ROLES) byRole[role] = []; + for (const row of rows) { + if (byRole[row.role]) byRole[row.role].push(row.permission); + } + res.json({ + roles: ASSIGNABLE_ROLES.map((role) => ({ role, permissions: byRole[role] })), + }); + } catch (err) { + console.error('List roles error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +// Replaces a role's permission set. Validates the role and every key, prevents +// privilege escalation, and prevents the caller from locking themselves out. +router.put( + '/roles/:role/permissions', + requireAuth, + requires('roles.edit_permissions'), + async (req: Request, res: Response) => { + try { + const roleRaw = req.params.role; + const role = typeof roleRaw === 'string' ? roleRaw : Array.isArray(roleRaw) ? roleRaw[0] : ''; + if (!isAssignableRole(role)) { + res.status(400).json({ error: 'Invalid role' }); + return; + } + + const { permissions } = req.body as { permissions?: unknown }; + if (!Array.isArray(permissions) || permissions.some((p) => typeof p !== 'string')) { + res.status(400).json({ error: 'permissions must be an array of strings' }); + return; + } + + const requested = [...new Set(permissions as string[])]; + const unknown = requested.filter((p) => !isValidPermissionKey(p)); + if (unknown.length > 0) { + res.status(400).json({ error: `Unknown permission keys: ${unknown.join(', ')}` }); + return; + } + + const caller = req.access!; + const newSet = new Set(requested); + + const existingRows = await prisma.rolePermission.findMany({ where: { role } }); + const currentSet = new Set(existingRows.map((r) => r.permission)); + + // No privilege escalation: any newly granted permission must be held by the + // caller. SuperAdmin holds everything and is exempt. + if (!caller.isSuperAdmin) { + const added = requested.filter((p) => !currentSet.has(p)); + const escalated = added.filter((p) => !caller.permissions.has(p)); + if (escalated.length > 0) { + res.status(403).json({ + error: `You cannot grant permissions you do not hold: ${escalated.join(', ')}`, + }); + return; + } + + // Prevent self-lockout: when editing your own role, you cannot drop the + // permissions that keep you able to manage roles and users. + if (caller.role === role) { + const protectedKeys = ['users.assign_role', 'roles.edit_permissions']; + const lockedOut = protectedKeys.filter( + (k) => caller.permissions.has(k) && !newSet.has(k) + ); + if (lockedOut.length > 0) { + res.status(403).json({ + error: `You cannot remove your own ${lockedOut.join(', ')}`, + }); + return; + } + } + } + + // Replace the role's permission rows transactionally. + await prisma.$transaction([ + prisma.rolePermission.deleteMany({ where: { role } }), + prisma.rolePermission.createMany({ + data: requested.map((permission) => ({ role, permission })), + }), + ]); + + res.json({ role, permissions: requested }); + } catch (err) { + console.error('Update role permissions error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/api/settings.ts b/backend/src/api/settings.ts index d5db4f8..bcd9a73 100644 --- a/backend/src/api/settings.ts +++ b/backend/src/api/settings.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; -import { requireAuth, requireRole } from '../middleware/auth'; +import { requireAuth, requires } from '../middleware/auth'; const router = Router(); @@ -18,7 +18,7 @@ const PUBLIC_SETTINGS = [ router.get( '/', requireAuth, - requireRole(['ADMIN']), + requires('settings.edit'), async (req: Request, res: Response) => { try { const settings = await prisma.setting.findMany(); @@ -53,7 +53,7 @@ router.get('/public', async (_req: Request, res: Response) => { router.patch( '/', requireAuth, - requireRole(['ADMIN']), + requires('settings.edit'), async (req: Request, res: Response) => { try { const { key, value } = req.body; diff --git a/backend/src/api/submissions.ts b/backend/src/api/submissions.ts index 158afd9..07f6a6d 100644 --- a/backend/src/api/submissions.ts +++ b/backend/src/api/submissions.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; -import { requireAuth, requireRole } from '../middleware/auth'; +import { requireAuth, requires } from '../middleware/auth'; const router = Router(); @@ -53,7 +53,7 @@ router.get( router.get( '/', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('submissions.review'), async (req: Request, res: Response) => { try { const status = req.query.status as string | undefined; @@ -76,7 +76,7 @@ router.get( router.patch( '/:id', requireAuth, - requireRole(['ADMIN', 'MODERATOR']), + requires('submissions.review'), async (req: Request, res: Response) => { try { const { status, reviewNote } = req.body; diff --git a/backend/src/api/users.ts b/backend/src/api/users.ts index a87c1cf..d8fd04e 100644 --- a/backend/src/api/users.ts +++ b/backend/src/api/users.ts @@ -1,6 +1,12 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; -import { requireAuth, requireRole } from '../middleware/auth'; +import { requireAuth, requires } from '../middleware/auth'; +import { isSuperadmin } from '../services/auth'; +import { + ROLE_RANK, + isAssignableRole, + type EffectiveRole, +} from '../constants/permissions'; import fs from 'fs'; import path from 'path'; @@ -41,13 +47,23 @@ const router = Router(); router.get( '/', requireAuth, - requireRole(['ADMIN']), + requires('users.assign_role'), async (_req: Request, res: Response) => { try { const users = await prisma.user.findMany({ orderBy: { createdAt: 'desc' }, }); - res.json(users); + // Flag env-sourced SuperAdmins so the UI can render them locked. Their + // effective role is reported as superadmin regardless of any stored value. + const result = users.map((u) => { + const superAdmin = isSuperadmin(u.pubkey); + return { + ...u, + role: superAdmin ? 'superadmin' : (u.role ?? null), + isSuperAdmin: superAdmin, + }; + }); + res.json(result); } catch (err) { console.error('List users error:', err); res.status(500).json({ error: 'Internal server error' }); @@ -55,53 +71,73 @@ router.get( } ); -router.post( - '/promote', +// Assigns a role to a user, replacing the old promote/demote toggle. Enforces the +// hierarchy: SuperAdmins are never modifiable here, and a non-SuperAdmin caller +// cannot assign a role at or above their own or modify anyone at or above them. +router.put( + '/:pubkey/role', requireAuth, - requireRole(['ADMIN']), + requires('users.assign_role'), async (req: Request, res: Response) => { try { - const { pubkey } = req.body; + const pubkeyRaw = req.params.pubkey; + const pubkey = + typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : ''; if (!pubkey) { res.status(400).json({ error: 'pubkey is required' }); return; } - const user = await prisma.user.upsert({ - where: { pubkey }, - update: { role: 'MODERATOR' }, - create: { pubkey, role: 'MODERATOR' }, - }); + const { role } = req.body as { role?: string | null }; - res.json(user); - } catch (err) { - console.error('Promote user error:', err); - res.status(500).json({ error: 'Internal server error' }); - } - } -); - -router.post( - '/demote', - requireAuth, - requireRole(['ADMIN']), - async (req: Request, res: Response) => { - try { - const { pubkey } = req.body; - if (!pubkey) { - res.status(400).json({ error: 'pubkey is required' }); + // Normalize the requested role. Null, empty, or "guest" all mean remove + // any elevated role. SuperAdmin can never be assigned here. + let newRole: EffectiveRole; + if (role === null || role === undefined || role === '' || role === 'guest') { + newRole = 'guest'; + } else if (isAssignableRole(role)) { + newRole = role; + } else { + res.status(400).json({ error: 'Invalid role' }); return; } - const user = await prisma.user.upsert({ + if (isSuperadmin(pubkey)) { + res.status(403).json({ error: 'SuperAdmins cannot be modified' }); + return; + } + + const caller = req.access!; + const callerRank = ROLE_RANK[caller.role]; + const newRank = ROLE_RANK[newRole]; + + const target = await prisma.user.findUnique({ where: { pubkey } }); + const targetCurrent: EffectiveRole = isAssignableRole(target?.role) + ? (target!.role as EffectiveRole) + : 'guest'; + const targetRank = ROLE_RANK[targetCurrent]; + + if (!caller.isSuperAdmin) { + if (newRank >= callerRank) { + res.status(403).json({ error: 'You cannot assign a role at or above your own' }); + return; + } + if (targetRank >= callerRank) { + res.status(403).json({ error: 'You cannot modify a user at or above your own role' }); + return; + } + } + + const storedRole = newRole === 'guest' ? null : newRole; + const updated = await prisma.user.upsert({ where: { pubkey }, - update: { role: 'USER' }, - create: { pubkey, role: 'USER' }, + update: { role: storedRole }, + create: { pubkey, role: storedRole }, }); - res.json(user); + res.json({ ...updated, role: updated.role ?? null, isSuperAdmin: false }); } catch (err) { - console.error('Demote user error:', err); + console.error('Assign role error:', err); res.status(500).json({ error: 'Internal server error' }); } } @@ -182,7 +218,7 @@ router.patch( router.patch( '/:pubkey', requireAuth, - requireRole(['ADMIN']), + requires('nip05.assign'), async (req: Request, res: Response) => { try { const pubkeyRaw = req.params.pubkey; diff --git a/backend/src/constants/permissions.ts b/backend/src/constants/permissions.ts new file mode 100644 index 0000000..e1a253b --- /dev/null +++ b/backend/src/constants/permissions.ts @@ -0,0 +1,97 @@ +// Central permission registry. This is the single source of truth for every +// granular permission key in the dashboard. Adding a key here automatically +// surfaces it in the GET /admin/permissions endpoint and the Roles matrix UI. +// To add a new permission: add an entry to PERMISSIONS below, then (optionally) +// grant it to roles by default in DEFAULT_ROLE_PERMISSIONS and the migration. + +export type PermissionKey = string; + +export interface PermissionDef { + key: PermissionKey; + label: string; + group: string; +} + +// Ordered list of every permission, grouped by feature area for the matrix UI. +export const PERMISSIONS: PermissionDef[] = [ + { key: 'events.create', label: 'Create events', group: 'Events' }, + { key: 'events.edit', label: 'Edit events', group: 'Events' }, + { key: 'events.delete', label: 'Delete events', group: 'Events' }, + + { key: 'organizers.manage', label: 'Manage organizers', group: 'Organizers' }, + + { key: 'gallery.upload', label: 'Upload media', group: 'Gallery' }, + { key: 'gallery.delete', label: 'Delete media', group: 'Gallery' }, + + { key: 'blog.draft', label: 'Draft and edit posts', group: 'Blog' }, + { key: 'blog.publish', label: 'Publish posts', group: 'Blog' }, + { key: 'blog.delete', label: 'Delete posts', group: 'Blog' }, + + { key: 'faq.manage', label: 'Manage FAQ', group: 'FAQ' }, + + { key: 'submissions.review', label: 'Review submissions', group: 'Submissions' }, + + { key: 'board.manage', label: 'Moderate the message board', group: 'Board' }, + + { key: 'moderation.act', label: 'Hide content and block pubkeys', group: 'Moderation' }, + + { key: 'categories.manage', label: 'Manage categories', group: 'Categories' }, + + { key: 'users.assign_role', label: 'Assign user roles', group: 'Users' }, + { key: 'nip05.assign', label: 'Assign NIP-05 usernames', group: 'Users' }, + + { key: 'relays.manage', label: 'Manage relays', group: 'Relays' }, + + { key: 'settings.edit', label: 'Edit site settings', group: 'Settings' }, + + { key: 'roles.edit_permissions', label: 'Edit roles and permissions', group: 'Roles' }, + + { key: 'nostr_tools.use', label: 'Use Nostr tools', group: 'Nostr Tools' }, +]; + +// Assignable roles, highest to lowest. SuperAdmin is env-sourced and never stored +// or assignable, so it is not part of this list. Guest is the absence of a role. +export const ASSIGNABLE_ROLES = ['admin', 'moderator', 'writer'] as const; +export type AssignableRole = (typeof ASSIGNABLE_ROLES)[number]; + +export type EffectiveRole = 'superadmin' | AssignableRole | 'guest'; + +// Hierarchy ranks used for safeguard comparisons. +export const ROLE_RANK: Record = { + superadmin: 4, + admin: 3, + moderator: 2, + writer: 1, + guest: 0, +}; + +export const ALL_PERMISSION_KEYS: ReadonlySet = new Set( + PERMISSIONS.map((p) => p.key) +); + +export function isValidPermissionKey(key: string): boolean { + return ALL_PERMISSION_KEYS.has(key); +} + +export function isAssignableRole(role: unknown): role is AssignableRole { + return typeof role === 'string' && (ASSIGNABLE_ROLES as readonly string[]).includes(role); +} + +// Default permission sets seeded per role. Admin gets everything except the +// roles editor, which stays SuperAdmin-only by default. Guest gets nothing and +// is therefore not represented here. +export const DEFAULT_ROLE_PERMISSIONS: Record = { + admin: PERMISSIONS.map((p) => p.key).filter((k) => k !== 'roles.edit_permissions'), + moderator: [ + 'events.create', + 'events.edit', + 'events.delete', + 'submissions.review', + 'moderation.act', + 'gallery.upload', + 'board.manage', + 'categories.manage', + 'faq.manage', + ], + writer: ['blog.draft', 'gallery.upload', 'events.create'], +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index aaa9890..b650e23 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -25,6 +25,7 @@ import nip05Router from './api/nip05'; import messagesRouter from './api/messages'; import adminMessagesRouter from './api/adminMessages'; import userRelaysRouter from './api/userRelays'; +import rolesRouter from './api/roles'; const app = express(); const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10); @@ -55,6 +56,7 @@ app.use('/api/calendar', calendarRouter); app.use('/api/nip05', nip05Router); app.use('/api/messages', messagesRouter); app.use('/api/admin/messages', adminMessagesRouter); +app.use('/api/admin', rolesRouter); app.use('/api/user-relays', userRelaysRouter); app.get('/api/health', (_req, res) => { diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 016989e..c53b7fd 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; +import { authService, type ResolvedAccess } from '../services/auth'; const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'; @@ -12,6 +13,7 @@ declare global { namespace Express { interface Request { user?: AuthPayload; + access?: ResolvedAccess; } } } @@ -33,16 +35,26 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo } } -export function requireRole(roles: string[]) { - return (req: Request, res: Response, next: NextFunction): void => { +// Resolves the requester's effective access (env SuperAdmin list plus database) +// and gates the request on a single permission key. SuperAdmin bypasses every +// check. The resolved access is attached to req.access for downstream handlers. +export function requires(permission: string) { + return async (req: Request, res: Response, next: NextFunction): Promise => { if (!req.user) { res.status(401).json({ error: 'Not authenticated' }); return; } - if (!roles.includes(req.user.role)) { + try { + const access = await authService.resolveAccess(req.user.pubkey); + req.access = access; + if (access.isSuperAdmin || access.permissions.has(permission)) { + next(); + return; + } res.status(403).json({ error: 'Insufficient permissions' }); - return; + } catch (err) { + console.error('Permission check error:', err); + res.status(500).json({ error: 'Internal server error' }); } - next(); }; } diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts index 7cee99c..1fcb29b 100644 --- a/backend/src/services/auth.ts +++ b/backend/src/services/auth.ts @@ -2,10 +2,48 @@ import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import { verifyEvent, type VerifiedEvent, nip19 } from 'nostr-tools'; import { prisma } from '../db/prisma'; +import { + ALL_PERMISSION_KEYS, + isAssignableRole, + type EffectiveRole, +} from '../constants/permissions'; const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'; const CHALLENGE_TTL_MS = 5 * 60 * 1000; +export interface ResolvedAccess { + pubkey: string; + role: EffectiveRole; + isSuperAdmin: boolean; + permissions: Set; +} + +// Reads the SuperAdmin pubkey list from the environment, decoding npub to hex. +// Falls back to the legacy ADMIN_PUBKEYS variable so existing deployments keep +// working. The env-admin concept is now SuperAdmin. +function getSuperadminPubkeys(): string[] { + const raw = process.env.SUPERADMIN_PUBKEYS ?? process.env.ADMIN_PUBKEYS ?? ''; + return raw + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => { + if (p.startsWith('npub1')) { + try { + const { data } = nip19.decode(p); + return data as string; + } catch { + return p; + } + } + return p; + }); +} + +export function isSuperadmin(pubkey: string): boolean { + return getSuperadminPubkeys().includes(pubkey); +} + interface StoredChallenge { challenge: string; expiresAt: number; @@ -62,29 +100,34 @@ export const authService = { return jwt.sign({ pubkey, role }, JWT_SECRET, { expiresIn: '7d' }); }, - async getRole(pubkey: string): Promise { - const adminPubkeys = (process.env.ADMIN_PUBKEYS || '') - .split(',') - .map((p) => p.trim()) - .filter(Boolean) - .map((p) => { - if (p.startsWith('npub1')) { - try { - const { data } = nip19.decode(p); - return data as string; - } catch { - return p; - } - } - return p; - }); + isSuperadmin, - if (adminPubkeys.includes(pubkey)) return 'ADMIN'; + // Resolves the effective role and permission set for a pubkey, live, from the + // env SuperAdmin list plus the database. This is the authoritative source for + // authorization. The role baked into a JWT is only used for display. + async resolveAccess(pubkey: string): Promise { + if (isSuperadmin(pubkey)) { + return { + pubkey, + role: 'superadmin', + isSuperAdmin: true, + permissions: new Set(ALL_PERMISSION_KEYS), + }; + } const user = await prisma.user.findUnique({ where: { pubkey } }); - if (user?.role === 'MODERATOR') return 'MODERATOR'; - if (user?.role === 'ADMIN') return 'ADMIN'; + const role: EffectiveRole = isAssignableRole(user?.role) ? user!.role : 'guest'; - return 'USER'; + if (role === 'guest') { + return { pubkey, role, isSuperAdmin: false, permissions: new Set() }; + } + + const rows = await prisma.rolePermission.findMany({ where: { role } }); + return { + pubkey, + role, + isSuperAdmin: false, + permissions: new Set(rows.map((r) => r.permission)), + }; }, }; diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx index 6ded4dd..54493cb 100644 --- a/frontend/app/admin/layout.tsx +++ b/frontend/app/admin/layout.tsx @@ -6,21 +6,25 @@ import { useAuth } from "@/hooks/useAuth"; import { AdminSidebar } from "@/components/admin/AdminSidebar"; export default function AdminLayout({ children }: { children: React.ReactNode }) { - const { user, loading } = useAuth(); + const { user, loading, isSuperAdmin, permissions } = useAuth(); const router = useRouter(); + // Access to the dashboard requires at least one elevated permission, or being + // a SuperAdmin. Guests (no permissions) are sent to their own dashboard. + const hasDashboardAccess = isSuperAdmin || permissions.length > 0; + useEffect(() => { if (loading) return; if (!user) { router.push("/login"); return; } - if (user.role !== "ADMIN" && user.role !== "MODERATOR") { + if (!hasDashboardAccess) { router.push("/dashboard"); } - }, [user, loading, router]); + }, [user, loading, hasDashboardAccess, router]); - if (loading || !user || (user.role !== "ADMIN" && user.role !== "MODERATOR")) { + if (loading || !user || !hasDashboardAccess) { return null; } diff --git a/frontend/app/admin/overview/page.tsx b/frontend/app/admin/overview/page.tsx index 09f45c1..018a69e 100644 --- a/frontend/app/admin/overview/page.tsx +++ b/frontend/app/admin/overview/page.tsx @@ -86,7 +86,11 @@ export default function OverviewPage() { - + {upcomingMeetup && ( diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index e2ae3da..af30778 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -6,7 +6,7 @@ import { useAuth } from "@/hooks/useAuth"; import { LogIn } from "lucide-react"; export default function AdminPage() { - const { user, loading, login } = useAuth(); + const { user, loading, login, isSuperAdmin, permissions } = useAuth(); const router = useRouter(); const [error, setError] = useState(""); const [loggingIn, setLoggingIn] = useState(false); @@ -14,12 +14,12 @@ export default function AdminPage() { useEffect(() => { if (loading) return; if (!user) return; - if (user.role === "ADMIN" || user.role === "MODERATOR") { + if (isSuperAdmin || permissions.length > 0) { router.push("/admin/overview"); } else { router.push("/dashboard"); } - }, [user, loading, router]); + }, [user, loading, isSuperAdmin, permissions, router]); const handleLogin = async () => { setError(""); diff --git a/frontend/app/admin/roles/page.tsx b/frontend/app/admin/roles/page.tsx new file mode 100644 index 0000000..1003a1f --- /dev/null +++ b/frontend/app/admin/roles/page.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { Fragment, useEffect, useMemo, useState } from "react"; +import { api } from "@/lib/api"; +import { useAuth } from "@/hooks/useAuth"; +import { Check, Lock, Save } from "lucide-react"; + +interface PermissionDef { + key: string; + label: string; + group: string; +} + +type Matrix = Record>; + +const ROLE_LABELS: Record = { + admin: "Admin", + moderator: "Moderator", + writer: "Writer", +}; + +function buildMatrix( + roles: string[], + perms: PermissionDef[], + granted: Record +): Matrix { + const matrix: Matrix = {}; + for (const role of roles) { + matrix[role] = {}; + const set = new Set(granted[role] ?? []); + for (const perm of perms) { + matrix[role][perm.key] = set.has(perm.key); + } + } + return matrix; +} + +export default function RolesPage() { + const { can } = useAuth(); + const allowed = can("roles.edit_permissions"); + + const [permissions, setPermissions] = useState([]); + const [roles, setRoles] = useState([]); + const [matrix, setMatrix] = useState({}); + const [original, setOriginal] = useState({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [savingRole, setSavingRole] = useState(null); + const [notice, setNotice] = useState(""); + + const load = async () => { + setLoading(true); + try { + const [registry, current] = await Promise.all([ + api.getPermissionRegistry(), + api.getRolePermissions(), + ]); + const granted: Record = {}; + for (const r of current.roles) granted[r.role] = r.permissions; + const built = buildMatrix(registry.roles, registry.permissions, granted); + setPermissions(registry.permissions); + setRoles(registry.roles); + setMatrix(built); + setOriginal(built); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (allowed) load(); + else setLoading(false); + }, [allowed]); + + const groups = useMemo(() => { + const order: string[] = []; + const byGroup: Record = {}; + for (const perm of permissions) { + if (!byGroup[perm.group]) { + byGroup[perm.group] = []; + order.push(perm.group); + } + byGroup[perm.group].push(perm); + } + return order.map((group) => ({ group, perms: byGroup[group] })); + }, [permissions]); + + const dirtyRoles = useMemo(() => { + return roles.filter((role) => + permissions.some((perm) => matrix[role]?.[perm.key] !== original[role]?.[perm.key]) + ); + }, [roles, permissions, matrix, original]); + + const toggle = (role: string, key: string) => { + setNotice(""); + setMatrix((prev) => ({ + ...prev, + [role]: { ...prev[role], [key]: !prev[role]?.[key] }, + })); + }; + + const saveRole = async (role: string) => { + setSavingRole(role); + setError(""); + setNotice(""); + try { + const keys = permissions.filter((p) => matrix[role]?.[p.key]).map((p) => p.key); + await api.updateRolePermissions(role, keys); + setOriginal((prev) => ({ ...prev, [role]: { ...matrix[role] } })); + setNotice(`${ROLE_LABELS[role] ?? role} permissions saved.`); + } catch (err: any) { + setError(err.message); + } finally { + setSavingRole(null); + } + }; + + if (!allowed) { + return ( +
+
+ You do not have permission to manage roles. +
+
+ ); + } + + if (loading) { + return ( +
+
Loading roles...
+
+ ); + } + + return ( +
+
+

Roles and Permissions

+

+ Toggle what each role can do. SuperAdmin always has every permission and + cannot be changed. +

+
+ + {error &&

{error}

} + {notice &&

{notice}

} + +
+ + + + + {roles.map((role) => ( + + ))} + + + + + {groups.map(({ group, perms }) => ( + + + + + {perms.map((perm) => ( + + + {roles.map((role) => ( + + ))} + + + ))} + + ))} + +
+ Permission + + {ROLE_LABELS[role] ?? role} + + + + SuperAdmin + +
+ {group} +
+
{perm.label}
+
{perm.key}
+
+ toggle(role, perm.key)} + className="h-4 w-4 accent-primary cursor-pointer" + aria-label={`${ROLE_LABELS[role] ?? role}: ${perm.label}`} + /> + + +
+
+ +
+ {roles.map((role) => { + const dirty = dirtyRoles.includes(role); + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/app/admin/users/page.tsx b/frontend/app/admin/users/page.tsx index 4d66867..8f65fcf 100644 --- a/frontend/app/admin/users/page.tsx +++ b/frontend/app/admin/users/page.tsx @@ -1,16 +1,39 @@ "use client"; import { useEffect, useState } from "react"; -import Image from "next/image"; import { nip19 } from "nostr-tools"; import { api } from "@/lib/api"; -import { cn, formatDate } from "@/lib/utils"; -import { fetchNostrProfile, type NostrProfile } from "@/lib/nostr"; -import { ShieldCheck, ShieldOff, UserPlus } from "lucide-react"; +import { useAuth } from "@/hooks/useAuth"; +import { useNostrProfile } from "@/hooks/useNostrProfile"; +import { NostrAvatar } from "@/components/nostr/NostrAvatar"; +import { formatDate } from "@/lib/utils"; +import { Lock } from "lucide-react"; -function hexToNpub(hex: string): string { +const ROLE_RANK: Record = { + superadmin: 4, + admin: 3, + moderator: 2, + writer: 1, + guest: 0, +}; + +const ROLE_OPTIONS: { value: string; label: string }[] = [ + { value: "admin", label: "Admin" }, + { value: "moderator", label: "Moderator" }, + { value: "writer", label: "Writer" }, + { value: "guest", label: "Guest (no role)" }, +]; + +function normalizeRole(role: string | null | undefined): string { + return role && ROLE_RANK[role] !== undefined ? role : "guest"; +} + +function hexToNpub(pubkey: string): string { + if (!pubkey) return ""; + // Already an npub (some users are stored in npub form). + if (pubkey.startsWith("npub1")) return pubkey; try { - return nip19.npubEncode(hex); + return nip19.npubEncode(pubkey); } catch { return ""; } @@ -21,24 +44,169 @@ function shortenNpub(npub: string): string { return `${npub.slice(0, 14)}...${npub.slice(-10)}`; } -function profileInitials(profile: NostrProfile | undefined, npub: string): string { - const n = profile?.name || profile?.displayName; - if (n?.trim()) return n.trim().slice(0, 2).toUpperCase(); - if (npub.length >= 8) return npub.slice(5, 7).toUpperCase(); - return "?"; +interface UserRowProps { + user: any; + draft: string; + onDraftChange: (pubkey: string, value: string) => void; + savingPubkey: string | null; + onSaveUsername: (pubkey: string, currentUsername: string | null | undefined) => void; + onCancelUsername: (pubkey: string, stored: string | null | undefined) => void; + hostname: string; + copiedPubkey: string | null; + onCopyNpub: (pubkey: string) => void; + callerRank: number; + isSuperAdmin: boolean; + savingRole: string | null; + onRoleChange: (pubkey: string, role: string) => void; +} + +function UserRow({ + user, + draft, + onDraftChange, + savingPubkey, + onSaveUsername, + onCancelUsername, + hostname, + copiedPubkey, + onCopyNpub, + callerRank, + isSuperAdmin, + savingRole, + onRoleChange, +}: UserRowProps) { + const { profile, loading: profileLoading } = useNostrProfile(user.pubkey); + const stored = user.username ?? ""; + const usernameDirty = draft.trim().toLowerCase() !== stored.toLowerCase(); + const fullNpub = hexToNpub(user.pubkey); + const nostrDisplay = profile?.name || profile?.displayName; + + return ( +
+
+ +
+
+

+ {profileLoading ? ( + + ) : nostrDisplay ? ( + nostrDisplay + ) : ( + No Nostr name + )} +

+ +
+
+

+ NIP-05 username +

+

+ Reserved names from the site blocklist can be assigned here (users cannot claim them on the dashboard). +

+
+ onDraftChange(user.pubkey, e.target.value)} + disabled={savingPubkey === user.pubkey} + placeholder="local-part" + className="bg-surface-container-highest text-on-surface rounded-lg px-3 py-2 text-sm font-mono min-w-[8rem] max-w-full flex-1 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-50" + /> + + @{hostname || "…"} + + + +
+ {draft.trim() && ( +

+ {draft.trim().toLowerCase()}@{hostname || "…"} +

+ )} +
+
+ {user.isSuperAdmin ? ( + + + SuperAdmin + + ) : ( + (() => { + const targetRole = normalizeRole(user.role); + const targetRank = ROLE_RANK[targetRole]; + const canModify = callerRank > targetRank; + return ( + + ); + })() + )} + {user.createdAt && ( + + Joined {formatDate(user.createdAt)} + + )} +
+
+
+
+ ); } export default function UsersPage() { + const { user: currentUser, isSuperAdmin } = useAuth(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); - const [promotePubkey, setPromotePubkey] = useState(""); - const [promoting, setPromoting] = useState(false); + const [savingRole, setSavingRole] = useState(null); const [hostname, setHostname] = useState(""); const [usernameDrafts, setUsernameDrafts] = useState>({}); const [savingPubkey, setSavingPubkey] = useState(null); - const [nostrByPubkey, setNostrByPubkey] = useState>({}); - const [nostrLoading, setNostrLoading] = useState(false); const [copiedPubkey, setCopiedPubkey] = useState(null); const loadUsers = async () => { @@ -65,32 +233,6 @@ export default function UsersPage() { setHostname(typeof window !== "undefined" ? window.location.hostname : ""); }, []); - useEffect(() => { - if (users.length === 0) { - setNostrByPubkey({}); - setNostrLoading(false); - return; - } - let cancelled = false; - setNostrLoading(true); - setNostrByPubkey({}); - (async () => { - const entries = await Promise.all( - users.map(async (u: { pubkey: string }) => { - const profile = await fetchNostrProfile(u.pubkey); - return [u.pubkey, profile] as const; - }) - ); - if (!cancelled) { - setNostrByPubkey(Object.fromEntries(entries)); - setNostrLoading(false); - } - })(); - return () => { - cancelled = true; - }; - }, [users]); - const handleCopyNpub = async (pubkey: string) => { const full = hexToNpub(pubkey); if (!full) return; @@ -103,39 +245,20 @@ export default function UsersPage() { } }; - const handlePromote = async () => { - if (!promotePubkey.trim()) return; - setPromoting(true); + const callerRank = isSuperAdmin + ? ROLE_RANK.superadmin + : ROLE_RANK[normalizeRole(currentUser?.role)]; + + const handleRoleChange = async (pubkey: string, role: string) => { + setSavingRole(pubkey); setError(""); try { - await api.promoteUser(promotePubkey); - setPromotePubkey(""); + await api.setUserRole(pubkey, role === "guest" ? null : role); await loadUsers(); } catch (err: any) { setError(err.message); } finally { - setPromoting(false); - } - }; - - const handleDemote = async (pubkey: string) => { - if (!confirm("Demote this user to regular user?")) return; - setError(""); - try { - await api.demoteUser(pubkey); - await loadUsers(); - } catch (err: any) { - setError(err.message); - } - }; - - const handlePromoteUser = async (pubkey: string) => { - setError(""); - try { - await api.promoteUser(pubkey); - await loadUsers(); - } catch (err: any) { - setError(err.message); + setSavingRole(null); } }; @@ -159,6 +282,10 @@ export default function UsersPage() { setUsernameDrafts((prev) => ({ ...prev, [pubkey]: stored ?? "" })); }; + const handleDraftChange = (pubkey: string, value: string) => { + setUsernameDrafts((prev) => ({ ...prev, [pubkey]: value })); + }; + if (loading) { return (
@@ -173,178 +300,28 @@ export default function UsersPage() { {error &&

{error}

} -
-

Promote User

-
- setPromotePubkey(e.target.value)} - className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1" - /> - -
-
-
{users.length === 0 ? (

No users found.

) : ( - users.map((user) => { - const draft = usernameDrafts[user.pubkey] ?? ""; - const stored = user.username ?? ""; - const usernameDirty = draft.trim().toLowerCase() !== stored.toLowerCase(); - const profile = nostrByPubkey[user.pubkey]; - const fullNpub = hexToNpub(user.pubkey); - const nostrDisplay = profile?.name || profile?.displayName; - return ( -
( + -
-
- {nostrLoading ? ( - - ) : profile?.picture ? ( - {nostrDisplay - ) : ( - - {profileInitials(profile, fullNpub)} - - )} -
-
-
-

- {nostrLoading ? ( - - ) : nostrDisplay ? ( - nostrDisplay - ) : ( - No Nostr name - )} -

- -
-
-

- NIP-05 username -

-

- Reserved names from the site blocklist can be assigned here (users cannot claim them on the dashboard). -

-
- - setUsernameDrafts((prev) => ({ ...prev, [user.pubkey]: e.target.value })) - } - disabled={savingPubkey === user.pubkey} - placeholder="local-part" - className="bg-surface-container-highest text-on-surface rounded-lg px-3 py-2 text-sm font-mono min-w-[8rem] max-w-full flex-1 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-50" - /> - - @{hostname || "…"} - - - -
- {draft.trim() && ( -

- {draft.trim().toLowerCase()}@{hostname || "…"} -

- )} -
-
- - {user.role} - - {user.createdAt && ( - - Joined {formatDate(user.createdAt)} - - )} -
-
-
- {user.role !== "ADMIN" && ( -
- {user.role !== "MODERATOR" && ( - - )} - {user.role === "MODERATOR" && ( - - )} -
- )} -
- ); - }) + user={user} + draft={usernameDrafts[user.pubkey] ?? ""} + onDraftChange={handleDraftChange} + savingPubkey={savingPubkey} + onSaveUsername={handleSaveUsername} + onCancelUsername={handleCancelUsername} + hostname={hostname} + copiedPubkey={copiedPubkey} + onCopyNpub={handleCopyNpub} + callerRank={callerRank} + isSuperAdmin={isSuperAdmin} + savingRole={savingRole} + onRoleChange={handleRoleChange} + /> + )) )}
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index bf4eb4e..9200201 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -38,11 +38,11 @@ export default function LoginPage() { const [bunkerInput, setBunkerInput] = useState(""); useEffect(() => { - if (!loading && user) redirectByRole(user.role); + if (!loading && user) redirectFor(user); }, [user, loading]); - function redirectByRole(role: string) { - if (role === "ADMIN" || role === "MODERATOR") { + function redirectFor(u: { isSuperAdmin?: boolean; permissions?: string[] }) { + if (u.isSuperAdmin || (u.permissions?.length ?? 0) > 0) { router.push("/admin/overview"); } else { router.push("/dashboard"); @@ -78,7 +78,7 @@ export default function LoginPage() { setLoggingIn(true); const loggedInUser = await loginWithConnectedSigner(signer); await signer.close().catch(() => {}); - redirectByRole(loggedInUser.role); + redirectFor(loggedInUser); } catch (err: any) { if (controller.signal.aborted) return; setError(err.message || "Connection failed"); @@ -104,7 +104,7 @@ export default function LoginPage() { setLoggingIn(true); try { const loggedInUser = await login(); - redirectByRole(loggedInUser.role); + redirectFor(loggedInUser); } catch (err: any) { setError(err.message || "Login failed"); } finally { @@ -118,7 +118,7 @@ export default function LoginPage() { setLoggingIn(true); try { const loggedInUser = await loginWithBunker(bunkerInput.trim()); - redirectByRole(loggedInUser.role); + redirectFor(loggedInUser); } catch (err: any) { setError(err.message || "Connection failed"); } finally { diff --git a/frontend/components/admin/AdminSidebar.tsx b/frontend/components/admin/AdminSidebar.tsx index 31166cd..6dc6a84 100644 --- a/frontend/components/admin/AdminSidebar.tsx +++ b/frontend/components/admin/AdminSidebar.tsx @@ -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() {

{shortPubkey}

- {user.role} + {user.isSuperAdmin ? "SuperAdmin" : user.role}