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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }[] };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user