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:
@@ -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');
|
||||
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user