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
+5 -2
View File
@@ -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
+41
View File
@@ -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.
+44 -5
View File
@@ -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('<key>')` 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
@@ -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');
+12 -1
View File
@@ -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
+13
View File
@@ -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.');
}
+2 -2
View File
@@ -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 {
+40 -6
View File
@@ -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;
+4 -4
View File
@@ -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({
+6 -6
View File
@@ -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 }[] };
+4 -4
View File
@@ -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({
+6 -6
View File
@@ -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({
+7 -7
View File
@@ -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({
+4 -4
View File
@@ -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({
+4 -4
View File
@@ -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({
+4 -4
View File
@@ -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 } });
+6 -6
View File
@@ -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({
+128
View File
@@ -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<string, string[]> = {};
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;
+3 -3
View File
@@ -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;
+3 -3
View File
@@ -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;
+71 -35
View File
@@ -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;
+97
View File
@@ -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<EffectiveRole, number> = {
superadmin: 4,
admin: 3,
moderator: 2,
writer: 1,
guest: 0,
};
export const ALL_PERMISSION_KEYS: ReadonlySet<PermissionKey> = 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<AssignableRole, PermissionKey[]> = {
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'],
};
+2
View File
@@ -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) => {
+17 -5
View File
@@ -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<void> => {
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();
};
}
+63 -20
View File
@@ -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<string>;
}
// 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<string> {
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<ResolvedAccess> {
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)),
};
},
};
+8 -4
View File
@@ -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;
}
+5 -1
View File
@@ -86,7 +86,11 @@ export default function OverviewPage() {
<StatCard icon={Calendar} label="Total Meetups" value={meetups.length} />
<StatCard icon={FileText} label="Blog Posts" value={posts.length} />
<StatCard icon={Tag} label="Categories" value={categories.length} />
<StatCard icon={User} label="Your Role" value={user.role} />
<StatCard
icon={User}
label="Your Role"
value={user.isSuperAdmin ? "SuperAdmin" : user.role}
/>
</div>
{upcomingMeetup && (
+3 -3
View File
@@ -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("");
+230
View File
@@ -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<string, Record<string, boolean>>;
const ROLE_LABELS: Record<string, string> = {
admin: "Admin",
moderator: "Moderator",
writer: "Writer",
};
function buildMatrix(
roles: string[],
perms: PermissionDef[],
granted: Record<string, string[]>
): 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<PermissionDef[]>([]);
const [roles, setRoles] = useState<string[]>([]);
const [matrix, setMatrix] = useState<Matrix>({});
const [original, setOriginal] = useState<Matrix>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [savingRole, setSavingRole] = useState<string | null>(null);
const [notice, setNotice] = useState("");
const load = async () => {
setLoading(true);
try {
const [registry, current] = await Promise.all([
api.getPermissionRegistry(),
api.getRolePermissions(),
]);
const granted: Record<string, string[]> = {};
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<string, PermissionDef[]> = {};
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 (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">
You do not have permission to manage roles.
</div>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading roles...</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-on-surface">Roles and Permissions</h1>
<p className="text-on-surface/60 text-sm mt-1">
Toggle what each role can do. SuperAdmin always has every permission and
cannot be changed.
</p>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{notice && <p className="text-primary text-sm">{notice}</p>}
<div className="bg-surface-container-low rounded-xl overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="text-left">
<th className="sticky left-0 bg-surface-container-low p-4 font-semibold text-on-surface/70">
Permission
</th>
{roles.map((role) => (
<th key={role} className="p-4 text-center font-semibold text-on-surface/70">
{ROLE_LABELS[role] ?? role}
</th>
))}
<th className="p-4 text-center font-semibold text-on-surface/70">
<span className="inline-flex items-center gap-1">
<Lock size={12} />
SuperAdmin
</span>
</th>
</tr>
</thead>
<tbody>
{groups.map(({ group, perms }) => (
<Fragment key={group}>
<tr>
<td
colSpan={roles.length + 2}
className="bg-surface-container px-4 py-2 text-xs font-bold uppercase tracking-wide text-on-surface/50"
>
{group}
</td>
</tr>
{perms.map((perm) => (
<tr key={perm.key} className="border-b border-surface-container-high/40">
<td className="sticky left-0 bg-surface-container-low p-4">
<div className="text-on-surface">{perm.label}</div>
<div className="text-on-surface/40 text-xs font-mono">{perm.key}</div>
</td>
{roles.map((role) => (
<td key={role} className="p-4 text-center">
<input
type="checkbox"
checked={!!matrix[role]?.[perm.key]}
onChange={() => toggle(role, perm.key)}
className="h-4 w-4 accent-primary cursor-pointer"
aria-label={`${ROLE_LABELS[role] ?? role}: ${perm.label}`}
/>
</td>
))}
<td className="p-4 text-center text-primary/70">
<Check size={16} className="inline" aria-label="Always granted" />
</td>
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
<div className="flex flex-wrap gap-3">
{roles.map((role) => {
const dirty = dirtyRoles.includes(role);
return (
<button
key={role}
onClick={() => saveRole(role)}
disabled={!dirty || savingRole === role}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={16} />
{savingRole === role
? "Saving..."
: `Save ${ROLE_LABELS[role] ?? role}${dirty ? " *" : ""}`}
</button>
);
})}
</div>
</div>
);
}
+212 -235
View File
@@ -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<string, number> = {
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 (
<div className="bg-surface-container-low rounded-xl p-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1 min-w-0 flex gap-4 items-start">
<NostrAvatar pubkey={user.pubkey} size={56} fallbackText={fullNpub} />
<div className="flex-1 min-w-0 space-y-3">
<div>
<p className="text-on-surface font-semibold text-base truncate">
{profileLoading ? (
<span className="text-on-surface/40 font-normal"></span>
) : nostrDisplay ? (
nostrDisplay
) : (
<span className="text-on-surface/50 font-normal">No Nostr name</span>
)}
</p>
<button
type="button"
onClick={() => onCopyNpub(user.pubkey)}
className="mt-1 text-left font-mono text-sm text-on-surface/80 hover:text-primary transition-colors cursor-pointer break-all w-full"
title={fullNpub || "Copy npub"}
>
{copiedPubkey === user.pubkey
? "Copied!"
: fullNpub
? shortenNpub(fullNpub)
: `${user.pubkey?.slice(0, 12)}...${user.pubkey?.slice(-8)}`}
</button>
</div>
<div>
<p className="text-xs font-semibold text-on-surface/50 mb-1 uppercase tracking-wide">
NIP-05 username
</p>
<p className="text-on-surface-variant text-xs mb-2">
Reserved names from the site blocklist can be assigned here (users cannot claim them on the dashboard).
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={draft}
onChange={(e) => 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"
/>
<span className="text-on-surface/50 text-sm font-mono shrink-0">
@{hostname || "…"}
</span>
<button
type="button"
onClick={() => onSaveUsername(user.pubkey, user.username)}
disabled={savingPubkey === user.pubkey || !draft.trim() || !usernameDirty}
className="px-3 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
{savingPubkey === user.pubkey ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => onCancelUsername(user.pubkey, user.username)}
disabled={savingPubkey === user.pubkey || !usernameDirty}
className="px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 text-sm hover:text-on-surface transition-colors disabled:opacity-50 whitespace-nowrap"
>
Cancel
</button>
</div>
{draft.trim() && (
<p className="text-on-surface-variant text-xs font-mono mt-2">
{draft.trim().toLowerCase()}@{hostname || "…"}
</p>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
{user.isSuperAdmin ? (
<span className="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-bold bg-primary-container/20 text-primary">
<Lock size={12} />
SuperAdmin
</span>
) : (
(() => {
const targetRole = normalizeRole(user.role);
const targetRank = ROLE_RANK[targetRole];
const canModify = callerRank > targetRank;
return (
<label className="flex items-center gap-2">
<span className="text-xs font-semibold text-on-surface/50 uppercase tracking-wide">
Role
</span>
<select
value={targetRole}
disabled={!canModify || savingRole === user.pubkey}
onChange={(e) => onRoleChange(user.pubkey, e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-50"
>
{ROLE_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
disabled={!isSuperAdmin && ROLE_RANK[opt.value] >= callerRank}
>
{opt.label}
</option>
))}
</select>
</label>
);
})()
)}
{user.createdAt && (
<span className="text-on-surface/40 text-xs">
Joined {formatDate(user.createdAt)}
</span>
)}
</div>
</div>
</div>
</div>
);
}
export default function UsersPage() {
const { user: currentUser, isSuperAdmin } = useAuth();
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [promotePubkey, setPromotePubkey] = useState("");
const [promoting, setPromoting] = useState(false);
const [savingRole, setSavingRole] = useState<string | null>(null);
const [hostname, setHostname] = useState("");
const [usernameDrafts, setUsernameDrafts] = useState<Record<string, string>>({});
const [savingPubkey, setSavingPubkey] = useState<string | null>(null);
const [nostrByPubkey, setNostrByPubkey] = useState<Record<string, NostrProfile>>({});
const [nostrLoading, setNostrLoading] = useState(false);
const [copiedPubkey, setCopiedPubkey] = useState<string | null>(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 (
<div className="flex items-center justify-center min-h-[60vh]">
@@ -173,178 +300,28 @@ export default function UsersPage() {
{error && <p className="text-error text-sm">{error}</p>}
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-sm font-semibold text-on-surface/70 mb-3">Promote User</h2>
<div className="flex gap-3">
<input
placeholder="Pubkey (hex)"
value={promotePubkey}
onChange={(e) => 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"
/>
<button
onClick={handlePromote}
disabled={promoting || !promotePubkey.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
<UserPlus size={16} />
{promoting ? "Promoting..." : "Promote"}
</button>
</div>
</div>
<div className="space-y-3">
{users.length === 0 ? (
<p className="text-on-surface/50 text-sm">No users found.</p>
) : (
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 (
<div
users.map((user) => (
<UserRow
key={user.pubkey || user.id}
className="bg-surface-container-low rounded-xl p-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div className="flex-1 min-w-0 flex gap-4 items-start">
<div className="shrink-0 w-14 h-14 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden text-on-surface">
{nostrLoading ? (
<span className="text-on-surface/40 text-xs"></span>
) : profile?.picture ? (
<Image
src={profile.picture}
alt={nostrDisplay ? `Avatar: ${nostrDisplay}` : "Nostr profile picture"}
width={56}
height={56}
className="object-cover w-full h-full"
unoptimized
/>
) : (
<span className="font-semibold text-sm" aria-hidden>
{profileInitials(profile, fullNpub)}
</span>
)}
</div>
<div className="flex-1 min-w-0 space-y-3">
<div>
<p className="text-on-surface font-semibold text-base truncate">
{nostrLoading ? (
<span className="text-on-surface/40 font-normal"></span>
) : nostrDisplay ? (
nostrDisplay
) : (
<span className="text-on-surface/50 font-normal">No Nostr name</span>
)}
</p>
<button
type="button"
onClick={() => handleCopyNpub(user.pubkey)}
className="mt-1 text-left font-mono text-sm text-on-surface/80 hover:text-primary transition-colors cursor-pointer break-all w-full"
title={fullNpub || "Copy npub"}
>
{copiedPubkey === user.pubkey
? "Copied!"
: fullNpub
? shortenNpub(fullNpub)
: `${user.pubkey?.slice(0, 12)}...${user.pubkey?.slice(-8)}`}
</button>
</div>
<div>
<p className="text-xs font-semibold text-on-surface/50 mb-1 uppercase tracking-wide">
NIP-05 username
</p>
<p className="text-on-surface-variant text-xs mb-2">
Reserved names from the site blocklist can be assigned here (users cannot claim them on the dashboard).
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={draft}
onChange={(e) =>
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"
/>
<span className="text-on-surface/50 text-sm font-mono shrink-0">
@{hostname || "…"}
</span>
<button
type="button"
onClick={() => handleSaveUsername(user.pubkey, user.username)}
disabled={
savingPubkey === user.pubkey ||
!draft.trim() ||
!usernameDirty
}
className="px-3 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
{savingPubkey === user.pubkey ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => handleCancelUsername(user.pubkey, user.username)}
disabled={savingPubkey === user.pubkey || !usernameDirty}
className="px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 text-sm hover:text-on-surface transition-colors disabled:opacity-50 whitespace-nowrap"
>
Cancel
</button>
</div>
{draft.trim() && (
<p className="text-on-surface-variant text-xs font-mono mt-2">
{draft.trim().toLowerCase()}@{hostname || "…"}
</p>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
<span
className={cn(
"rounded-full px-3 py-1 text-xs font-bold",
user.role === "ADMIN"
? "bg-primary-container/20 text-primary"
: user.role === "MODERATOR"
? "bg-secondary-container text-on-secondary-container"
: "bg-surface-container-highest text-on-surface/50"
)}
>
{user.role}
</span>
{user.createdAt && (
<span className="text-on-surface/40 text-xs">
Joined {formatDate(user.createdAt)}
</span>
)}
</div>
</div>
</div>
{user.role !== "ADMIN" && (
<div className="flex items-center gap-2">
{user.role !== "MODERATOR" && (
<button
onClick={() => handlePromoteUser(user.pubkey)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-primary text-sm transition-colors"
>
<ShieldCheck size={14} />
Promote
</button>
)}
{user.role === "MODERATOR" && (
<button
onClick={() => handleDemote(user.pubkey)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-error text-sm transition-colors"
>
<ShieldOff size={14} />
Demote
</button>
)}
</div>
)}
</div>
);
})
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}
/>
))
)}
</div>
</div>
+6 -6
View File
@@ -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 {
+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);
+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 };
}
+36 -5
View File
@@ -24,10 +24,27 @@ export const api = {
body: JSON.stringify({ pubkey }),
}),
verify: (pubkey: string, signedEvent: any) =>
request<{ token: string; user: { pubkey: string; role: string; username?: string } }>("/auth/verify", {
request<{
token: string;
user: {
pubkey: string;
role: string;
isSuperAdmin?: boolean;
permissions?: string[];
username?: string;
};
}>("/auth/verify", {
method: "POST",
body: JSON.stringify({ pubkey, signedEvent }),
}),
getMe: () =>
request<{
pubkey: string;
role: string;
isSuperAdmin: boolean;
permissions: string[];
username?: string;
}>("/auth/me"),
// Posts
getPosts: (params?: { category?: string; page?: number; limit?: number; all?: boolean }) => {
@@ -93,16 +110,30 @@ export const api = {
// Users
getUsers: () => request<any[]>("/users"),
promoteUser: (pubkey: string) =>
request<any>("/users/promote", { method: "POST", body: JSON.stringify({ pubkey }) }),
demoteUser: (pubkey: string) =>
request<any>("/users/demote", { method: "POST", body: JSON.stringify({ pubkey }) }),
setUserRole: (pubkey: string, role: string | null) =>
request<any>(`/users/${encodeURIComponent(pubkey)}/role`, {
method: "PUT",
body: JSON.stringify({ role }),
}),
updateUserUsername: (pubkey: string, username: string) =>
request<any>(`/users/${encodeURIComponent(pubkey)}`, {
method: "PATCH",
body: JSON.stringify({ username }),
}),
// Roles and permissions
getPermissionRegistry: () =>
request<{ permissions: { key: string; label: string; group: string }[]; roles: string[] }>(
"/admin/permissions"
),
getRolePermissions: () =>
request<{ roles: { role: string; permissions: string[] }[] }>("/admin/roles"),
updateRolePermissions: (role: string, permissions: string[]) =>
request<{ role: string; permissions: string[] }>(
`/admin/roles/${encodeURIComponent(role)}/permissions`,
{ method: "PUT", body: JSON.stringify({ permissions }) }
),
// Categories
getCategories: () => request<any[]>("/categories"),
createCategory: (data: { name: string; slug: string }) =>
+188 -11
View File
@@ -1,4 +1,29 @@
import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure";
import { nip19 } from "nostr-tools";
// Relays return events keyed by hex pubkeys and only accept hex in `authors`
// filters. Pubkeys may be stored/passed as npub (or nprofile), so normalize.
export function toHexPubkey(pubkey: string | null | undefined): string | null {
if (!pubkey) return null;
const trimmed = pubkey.trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === "npub") return decoded.data as string;
if (decoded.type === "nprofile") return (decoded.data as { pubkey: string }).pubkey;
} catch {
// fall through
}
return null;
}
// Relays that specialize in (or broadly aggregate) kind:0 profile metadata.
// Included alongside the site relays so profiles are found even when a user's
// metadata was never published to the site's configured relay set.
const PROFILE_METADATA_RELAYS = [
"wss://purplepag.es",
"wss://relay.nostr.band",
];
declare global {
interface Window {
@@ -205,11 +230,15 @@ export async function fetchNostrProfile(
pubkey: string,
relayUrls?: string[]
): Promise<NostrProfile> {
const hex = toHexPubkey(pubkey);
if (!hex) return {};
const { SimplePool } = await import("nostr-tools/pool");
const allRelays = new Set<string>(relayUrls || await getSiteRelays());
PROFILE_METADATA_RELAYS.forEach((url) => allRelays.add(url));
try {
const nip65 = await fetchNip65RelayList(pubkey);
const nip65 = await fetchNip65RelayList(hex);
nip65.write.forEach((url) => allRelays.add(url));
} catch {}
@@ -219,19 +248,11 @@ export async function fetchNostrProfile(
try {
const event = await pool.get(urls, {
kinds: [0],
authors: [pubkey],
authors: [hex],
});
if (!event?.content) return {};
const meta = JSON.parse(event.content);
return {
name: meta.name || meta.display_name,
displayName: meta.display_name,
picture: meta.picture,
about: meta.about,
nip05: meta.nip05,
};
return parseProfileContent(event.content);
} catch {
return {};
} finally {
@@ -239,6 +260,162 @@ export async function fetchNostrProfile(
}
}
function parseProfileContent(content: string): NostrProfile {
const meta = JSON.parse(content);
return {
name: meta.name || meta.display_name,
displayName: meta.display_name,
picture: meta.picture,
about: meta.about,
nip05: meta.nip05,
};
}
const _profileCache = new Map<string, { profile: NostrProfile; fetchedAt: number; empty: boolean }>();
const PROFILE_TTL = 5 * 60 * 1000; // 5 minutes for resolved profiles
const PROFILE_EMPTY_TTL = 30 * 1000; // retry misses sooner
function isProfileEmpty(p: NostrProfile): boolean {
return !p.name && !p.displayName && !p.picture && !p.about && !p.nip05;
}
function readProfileCache(pubkey: string, now: number): NostrProfile | null {
const cached = _profileCache.get(pubkey);
if (!cached) return null;
const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL;
return now - cached.fetchedAt < ttl ? cached.profile : null;
}
function writeProfileCache(pubkey: string, profile: NostrProfile, now: number): void {
_profileCache.set(pubkey, { profile, fetchedAt: now, empty: isProfileEmpty(profile) });
}
// Batched profile fetch. Resolves kind:0 metadata for many pubkeys using a
// single SimplePool and one query, instead of opening a pool (plus a NIP-65
// lookup pool) per pubkey. Querying many profiles individually opens dozens of
// simultaneous websocket connections to the same relays, which get
// throttled/dropped and return empty results. Pubkeys are normalized to hex
// (relays reject npub/nprofile in `authors`).
export async function fetchNostrProfiles(
pubkeys: string[],
relayUrls?: string[]
): Promise<Record<string, NostrProfile>> {
const result: Record<string, NostrProfile> = {};
const now = Date.now();
// Map each requested key to its hex form. Skip cached and un-decodable keys.
const hexByKey = new Map<string, string>();
for (const pk of pubkeys) {
const cached = readProfileCache(pk, now);
if (cached) {
result[pk] = cached;
continue;
}
const hex = toHexPubkey(pk);
if (!hex) {
result[pk] = {};
continue;
}
hexByKey.set(pk, hex);
}
if (hexByKey.size === 0) return result;
const { SimplePool } = await import("nostr-tools/pool");
const base = relayUrls && relayUrls.length > 0 ? relayUrls : await getSiteRelays();
const urls = [...new Set([...base, ...PROFILE_METADATA_RELAYS])];
const pool = new SimplePool();
try {
const authors = [...new Set(hexByKey.values())];
const events = await pool.querySync(
urls,
{ kinds: [0], authors },
{ maxWait: 6000 }
);
// Keep only the most recent kind:0 event per hex author.
const latest = new Map<string, { created_at: number; content: string }>();
for (const event of events) {
const prev = latest.get(event.pubkey);
if (!prev || event.created_at > prev.created_at) {
latest.set(event.pubkey, { created_at: event.created_at, content: event.content });
}
}
for (const [key, hex] of hexByKey) {
const ev = latest.get(hex);
let profile: NostrProfile = {};
if (ev?.content) {
try {
profile = parseProfileContent(ev.content);
} catch {
profile = {};
}
}
result[key] = profile;
writeProfileCache(key, profile, now);
}
return result;
} catch {
for (const key of hexByKey.keys()) {
if (!(key in result)) result[key] = {};
}
return result;
} finally {
pool.close(urls);
}
}
// DataLoader-style batching for single-pubkey requests. Component instances
// (e.g. <NostrAvatar />) each ask for one pubkey; this coalesces all requests
// made within a short window into a single batched relay query.
let _batchQueue = new Set<string>();
let _batchResolvers = new Map<string, Array<(p: NostrProfile) => void>>();
let _batchTimer: ReturnType<typeof setTimeout> | null = null;
const BATCH_WINDOW_MS = 60;
async function flushProfileBatch(): Promise<void> {
const pubkeys = [..._batchQueue];
const resolvers = _batchResolvers;
_batchQueue = new Set();
_batchResolvers = new Map();
_batchTimer = null;
let profiles: Record<string, NostrProfile> = {};
try {
profiles = await fetchNostrProfiles(pubkeys);
} catch {
profiles = {};
}
for (const pk of pubkeys) {
const profile = profiles[pk] ?? {};
resolvers.get(pk)?.forEach((resolve) => resolve(profile));
}
}
// Resolve a single pubkey's profile, batching concurrent calls and reusing the
// shared profile cache.
export function loadNostrProfile(pubkey: string): Promise<NostrProfile> {
const cached = readProfileCache(pubkey, Date.now());
if (cached) {
return Promise.resolve(cached);
}
return new Promise((resolve) => {
const existing = _batchResolvers.get(pubkey);
if (existing) {
existing.push(resolve);
} else {
_batchResolvers.set(pubkey, [resolve]);
}
_batchQueue.add(pubkey);
if (_batchTimer === null) {
_batchTimer = setTimeout(() => void flushProfileBatch(), BATCH_WINDOW_MS);
}
});
}
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
const { SimplePool } = await import("nostr-tools/pool");
const siteRelays = await getSiteRelays();