diff --git a/api/db/migrate.ts b/api/db/migrate.ts index 3cd35ba..69fa3fa 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -2,6 +2,8 @@ import type { DatabaseSync } from "node:sqlite"; import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts"; import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts"; import { up as up0003DumpBacklinks } from "./migrations/0003_dump_backlinks.ts"; +import { up as up0004UserRoles } from "./migrations/0004_user_roles.ts"; +import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts"; interface Migration { name: string; @@ -15,6 +17,8 @@ const MIGRATIONS: Migration[] = [ { name: "0001_comment_likes", up: up0001CommentLikes }, { name: "0002_dump_thumbnail", up: up0002DumpThumbnail }, { name: "0003_dump_backlinks", up: up0003DumpBacklinks }, + { name: "0004_user_roles", up: up0004UserRoles }, + { name: "0005_drop_is_admin", up: up0005DropIsAdmin }, ]; export function runMigrations(db: DatabaseSync): void { diff --git a/api/db/migrations/0004_user_roles.ts b/api/db/migrations/0004_user_roles.ts new file mode 100644 index 0000000..9563662 --- /dev/null +++ b/api/db/migrations/0004_user_roles.ts @@ -0,0 +1,22 @@ +import type { DatabaseSync } from "node:sqlite"; + +// Idempotent: safe to run against a fresh db (already created from schema.sql +// with the `role` column) or an existing one that predates it. +export function up(db: DatabaseSync): void { + const userCols = db.prepare(`PRAGMA table_info(users);`).all() as { + name: string; + }[]; + if (!userCols.some((c) => c.name === "role")) { + db.exec( + `ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';`, + ); + } + + // Backfill existing admins to role 'admin'. Guarded because the legacy + // is_admin column is absent on fresh databases (and dropped by 0005). + if (userCols.some((c) => c.name === "is_admin")) { + db.exec( + `UPDATE users SET role = 'admin' WHERE is_admin = 1 AND role != 'admin';`, + ); + } +} diff --git a/api/db/migrations/0005_drop_is_admin.ts b/api/db/migrations/0005_drop_is_admin.ts new file mode 100644 index 0000000..aab2423 --- /dev/null +++ b/api/db/migrations/0005_drop_is_admin.ts @@ -0,0 +1,13 @@ +import type { DatabaseSync } from "node:sqlite"; + +// Drops the legacy is_admin column now that `role` is the single source of +// truth. Idempotent: skips when the column is already gone (fresh databases +// created from schema.sql never had it). Must run after 0004 has backfilled. +export function up(db: DatabaseSync): void { + const userCols = db.prepare(`PRAGMA table_info(users);`).all() as { + name: string; + }[]; + if (userCols.some((c) => c.name === "is_admin")) { + db.exec(`ALTER TABLE users DROP COLUMN is_admin;`); + } +} diff --git a/api/db/schema.sql b/api/db/schema.sql index cfe6896..38ef6b0 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -22,7 +22,7 @@ CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, + role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL, updated_at TEXT, avatar_mime TEXT, diff --git a/api/lib/permissions.ts b/api/lib/permissions.ts new file mode 100644 index 0000000..cdf06f1 --- /dev/null +++ b/api/lib/permissions.ts @@ -0,0 +1,27 @@ +import { type AuthPayload, type Role } from "../model/interfaces.ts"; + +// Named capabilities checked at enforcement points. Extend this union as new +// gated capabilities are added (e.g. "invite:manage"). +export type Permission = "dump:moderate" | "comment:moderate" | "user:manage"; + +const ALL_PERMISSIONS: readonly Permission[] = [ + "dump:moderate", + "comment:moderate", + "user:manage", +]; + +// Roles are fixed in code. `admin` is a superset of every permission (current +// and future); `moderator` gets the content-moderation permissions only. +export const ROLE_PERMISSIONS: Record = { + admin: ALL_PERMISSIONS, + moderator: ["dump:moderate", "comment:moderate"], + user: [], +}; + +export function hasPermission(role: Role, permission: Permission): boolean { + return ROLE_PERMISSIONS[role].includes(permission); +} + +export function can(payload: AuthPayload, permission: Permission): boolean { + return hasPermission(payload.role, permission); +} diff --git a/api/middleware/auth.ts b/api/middleware/auth.ts index 0371d4a..bf82192 100644 --- a/api/middleware/auth.ts +++ b/api/middleware/auth.ts @@ -1,4 +1,9 @@ -import { Context, Next, State } from "@oak/oak"; +import { + type Next, + type RouteParams, + type RouterContext, + type State, +} from "@oak/oak"; import { verifyJWT } from "../lib/jwt.ts"; import { APIErrorCode, @@ -6,16 +11,24 @@ import { type AuthPayload, } from "../model/interfaces.ts"; import { getUserById } from "../services/user-service.ts"; - -export interface AuthContext extends Context { - state: AuthState; -} +import { can, type Permission } from "../lib/permissions.ts"; export interface AuthState extends State { user: AuthPayload; } -export async function authMiddleware(ctx: AuthContext, next: Next) { +// A router context whose state is guaranteed to carry the authenticated user +// (populated by authMiddleware). Generic over the route path so `ctx.params` +// stays typed per-route — e.g. `AuthContext<"/:userId/role">`. +export type AuthContext< + R extends string = string, + P extends RouteParams = RouteParams, +> = RouterContext; + +export async function authMiddleware( + ctx: AuthContext, + next: Next, +): Promise { const authHeader = ctx.request.headers.get("Authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) { @@ -44,8 +57,11 @@ export async function authMiddleware(ctx: AuthContext, next: Next) { await next(); } -export async function adminOnlyMiddleware(ctx: AuthContext, next: Next) { - if (!ctx.state.user?.isAdmin) { +export async function adminOnlyMiddleware( + ctx: AuthContext, + next: Next, +): Promise { + if (ctx.state.user.role !== "admin") { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, @@ -55,3 +71,21 @@ export async function adminOnlyMiddleware(ctx: AuthContext, next: Next) { await next(); } + +// Gates a route on a named permission. Must run after authMiddleware. Generic +// over the route path so it composes with path-typed route handlers. +export function requirePermission(permission: Permission) { + return async ( + ctx: AuthContext, + next: Next, + ): Promise => { + if (!ctx.state.user || !can(ctx.state.user, permission)) { + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 403, + "Insufficient permissions", + ); + } + await next(); + }; +} diff --git a/api/model/db.ts b/api/model/db.ts index 5571ecf..52fa089 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -3,10 +3,12 @@ import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; import { type Comment, Dump, + isRole, type Notification, type NotificationType, type Playlist, type RichContent, + type Role, type User, } from "./interfaces.ts"; import { @@ -53,7 +55,7 @@ if (userCount.count === 0) { const hash = scryptSync("admin", salt, 64).toString("hex"); const passwordHash = `${hash}.${salt}`; db.prepare( - `INSERT INTO users (id, username, password_hash, is_admin, created_at, email) VALUES (?, 'admin', ?, 1, datetime('now'), 'admin@localhost')`, + `INSERT INTO users (id, username, password_hash, role, created_at, email) VALUES (?, 'admin', ?, 'admin', datetime('now'), 'admin@localhost')`, ).run(crypto.randomUUID(), passwordHash); console.log("Created default admin user (username: admin, password: admin)"); } @@ -101,7 +103,7 @@ export interface UserRow { id: string; username: string; password_hash: string; - is_admin: number; + role: Role; created_at: string; updated_at: string | null; avatar_mime: string | null; @@ -153,7 +155,7 @@ export function isUserRow(obj: unknown): obj is UserRow { "id" in obj && typeof obj.id === "string" && "username" in obj && typeof obj.username === "string" && "password_hash" in obj && typeof obj.password_hash === "string" && - "is_admin" in obj && typeof obj.is_admin === "number" && + "role" in obj && isRole(obj.role) && "created_at" in obj && typeof obj.created_at === "string" && "updated_at" in obj && (typeof obj.updated_at === "string" || obj.updated_at === null) && @@ -221,7 +223,7 @@ export function userRowToApi(row: UserRow): User { id: row.id, username: row.username, passwordHash: row.password_hash, - isAdmin: Boolean(row.is_admin), + role: row.role, createdAt: new Date(row.created_at), updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, avatarMime: row.avatar_mime ?? undefined, @@ -238,7 +240,7 @@ export function userApiToRow(user: User): UserRow { id: user.id, username: user.username, password_hash: user.passwordHash, - is_admin: user.isAdmin ? 1 : 0, + role: user.role, created_at: user.createdAt.toISOString(), updated_at: user.updatedAt?.toISOString() ?? null, avatar_mime: user.avatarMime ?? null, diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index 1bb50e8..cc567ce 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -39,11 +39,19 @@ export interface Dump { * Authentication */ +export type Role = "user" | "moderator" | "admin"; + +export const ROLES: readonly Role[] = ["user", "moderator", "admin"]; + +export function isRole(value: unknown): value is Role { + return typeof value === "string" && (ROLES as readonly string[]).includes(value); +} + export interface User { id: string; username: string; passwordHash: string; - isAdmin: boolean; + role: Role; createdAt: Date; updatedAt?: Date; avatarMime?: string; @@ -67,7 +75,7 @@ export interface RegisterUserRequest { export interface UpdateUserRequest { username?: string; password?: string; - isAdmin?: boolean; + role?: Role; description?: string | null; email?: string; } @@ -128,7 +136,7 @@ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { return false; } } - if ("isAdmin" in o && typeof o.isAdmin !== "boolean") return false; + if ("role" in o && !isRole(o.role)) return false; if ( "description" in o && typeof o.description !== "string" && o.description !== null @@ -152,7 +160,7 @@ export interface AuthResponse { export interface AuthPayload { userId: string; username: string; - isAdmin: boolean; + role: Role; exp: number; } @@ -161,7 +169,7 @@ export function isAuthPayload(obj: unknown): obj is AuthPayload { typeof obj === "object" && "userId" in obj && typeof obj.userId === "string" && "username" in obj && typeof obj.username === "string" && - "isAdmin" in obj && typeof obj.isAdmin === "boolean" && + "role" in obj && isRole(obj.role) && "exp" in obj && typeof obj.exp === "number"; } diff --git a/api/routes/comments.ts b/api/routes/comments.ts index 87af813..88a3311 100644 --- a/api/routes/comments.ts +++ b/api/routes/comments.ts @@ -8,6 +8,7 @@ import { isUpdateCommentRequest, } from "../model/interfaces.ts"; import { authMiddleware } from "../middleware/auth.ts"; +import { can } from "../lib/permissions.ts"; import { parseOptionalAuth } from "../lib/auth.ts"; import { parsePagination } from "../lib/pagination.ts"; import { @@ -66,7 +67,7 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => { // PATCH /api/comments/:commentId — auth required router.patch("/comments/:commentId", authMiddleware, async (ctx) => { const userId = ctx.state.user.userId; - const isAdmin = ctx.state.user.isAdmin ?? false; + const canModerate = can(ctx.state.user, "comment:moderate"); const body = await ctx.request.body.json(); if (!isUpdateCommentRequest(body)) { throw new APIException( @@ -79,7 +80,7 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => { ctx.params.commentId, body.body, userId, - isAdmin, + canModerate, ); if (!isPrivate) broadcastCommentUpdated(comment); const responseBody: APIResponse = { success: true, data: comment }; @@ -110,11 +111,11 @@ router.get("/comments/:commentId/likers", async (ctx) => { // DELETE /api/comments/:commentId — auth required router.delete("/comments/:commentId", authMiddleware, (ctx) => { const userId = ctx.state.user.userId; - const isAdmin = ctx.state.user.isAdmin ?? false; + const canModerate = can(ctx.state.user, "comment:moderate"); const { dumpId, isPrivate } = deleteComment( ctx.params.commentId, userId, - isAdmin, + canModerate, ); if (!isPrivate) broadcastCommentDeleted(ctx.params.commentId, dumpId); const responseBody: APIResponse = { success: true, data: null }; diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index d9e2fb7..b0b3579 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -11,6 +11,7 @@ import { } from "../model/interfaces.ts"; import { authMiddleware } from "../middleware/auth.ts"; +import { can } from "../lib/permissions.ts"; import { parseOptionalAuth } from "../lib/auth.ts"; import { parsePagination } from "../lib/pagination.ts"; import { validateImageUpload } from "../lib/upload.ts"; @@ -126,10 +127,10 @@ router.get("/", async (ctx) => { router.put("/:dumpId/file", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; - const userId = ctx.state.user?.userId; + const userId = ctx.state.user.userId; const dump = getDump(dumpId, userId); - if (userId !== dump.userId && !ctx.state.user?.isAdmin) { + if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, @@ -162,7 +163,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => { router.put("/:dumpId", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; - const userId = ctx.state.user?.userId; + const userId = ctx.state.user.userId; const body = await ctx.request.body.json(); if (!isUpdateDumpRequest(body)) { @@ -175,7 +176,7 @@ router.put("/:dumpId", authMiddleware, async (ctx) => { const dump = getDump(dumpId, userId); - if (userId !== dump.userId && !ctx.state.user?.isAdmin) { + if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, @@ -190,10 +191,10 @@ router.put("/:dumpId", authMiddleware, async (ctx) => { router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; - const userId = ctx.state.user?.userId; + const userId = ctx.state.user.userId; const dump = getDump(dumpId, userId); - if (userId !== dump.userId && !ctx.state.user?.isAdmin) { + if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, @@ -232,10 +233,10 @@ router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => { router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; - const userId = ctx.state.user?.userId; + const userId = ctx.state.user.userId; const dump = getDump(dumpId, userId); - if (userId !== dump.userId && !ctx.state.user?.isAdmin) { + if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, @@ -250,10 +251,10 @@ router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => { router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; - const userId = ctx.state.user?.userId; + const userId = ctx.state.user.userId; const dump = getDump(dumpId, userId); - if (userId !== dump.userId && !ctx.state.user?.isAdmin) { + if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, @@ -268,10 +269,10 @@ router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => { router.delete("/:dumpId", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; - const userId = ctx.state.user?.userId; + const userId = ctx.state.user.userId; const dump = getDump(dumpId, userId); - if (userId !== dump.userId && !ctx.state.user?.isAdmin) { + if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, diff --git a/api/routes/users.ts b/api/routes/users.ts index e4a0166..82e84a1 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -4,13 +4,18 @@ import { APIErrorCode, APIException, isLoginUserRequest, + isRole, isUpdateUserRequest, type PaginatedData, validateRegisterUserRequest, } from "../model/interfaces.ts"; import { createJWT, verifyPassword } from "../lib/jwt.ts"; -import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; +import { + type AuthContext, + authMiddleware, + requirePermission, +} from "../middleware/auth.ts"; import { parseOptionalAuth } from "../lib/auth.ts"; import { parsePagination } from "../lib/pagination.ts"; import { @@ -86,7 +91,7 @@ router.post("/register", async (ctx) => { const authToken = await createJWT({ userId: user.id, username: user.username, - isAdmin: user.isAdmin, + role: user.role, }); const { passwordHash: _, ...publicUser } = user; @@ -124,7 +129,7 @@ router.post("/login", async (ctx) => { const token = await createJWT({ userId: user.id, username: user.username, - isAdmin: user.isAdmin, + role: user.role, }); const { passwordHash: _, ...publicUser } = user; @@ -250,12 +255,42 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => { "Invalid request", ); } - const updated = await updateUser(ctx.state.user.userId, body); + // Self-service profile updates must never set the role. Role assignment is + // done out-of-band (DB/seed); stripping it here prevents self-promotion. + const { role: _role, ...safeBody } = body; + const updated = await updateUser(ctx.state.user.userId, safeBody); const { passwordHash: _, email: _email, ...publicUser } = updated; broadcastUserUpdated(publicUser); ctx.response.body = { success: true, data: publicUser }; }); +// Set another user's role. Requires the user:manage permission. +router.patch( + "/:userId/role", + authMiddleware, + requirePermission("user:manage"), + async (ctx: AuthContext<"/:userId/role">) => { + const body = await ctx.request.body.json(); + const role: unknown = body?.role; + if (!isRole(role)) { + throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "Invalid role"); + } + // Disallow changing your own role: prevents an admin from accidentally + // locking themselves out, and self-promotion is meaningless anyway. + if (ctx.params.userId === ctx.state.user.userId) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Cannot change your own role", + ); + } + const updated = await updateUser(ctx.params.userId, { role }); + const { passwordHash: _, email: _email, ...publicUser } = updated; + broadcastUserUpdated(publicUser); + ctx.response.body = { success: true, data: publicUser }; + }, +); + // User search for @mention autocomplete router.get("/search", (ctx) => { const q = (ctx.request.url.searchParams.get("q") ?? "").trim(); diff --git a/api/services/comment-like-service.ts b/api/services/comment-like-service.ts index bc612d4..27a211c 100644 --- a/api/services/comment-like-service.ts +++ b/api/services/comment-like-service.ts @@ -81,7 +81,7 @@ export function getCommentLikers( ).get(commentId) as { count: number } | undefined; const rawRows = db.prepare( - `SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, + `SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, i.username as invited_by_username FROM users u diff --git a/api/services/comment-service.ts b/api/services/comment-service.ts index ef75d74..a97c0c1 100644 --- a/api/services/comment-service.ts +++ b/api/services/comment-service.ts @@ -91,7 +91,7 @@ export function updateComment( commentId: string, body: string, requestingUserId: string, - isAdmin: boolean, + canModerate: boolean, ): { comment: Comment; dumpId: string; isPrivate: boolean } { const row = db.prepare( `SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`, @@ -107,7 +107,7 @@ export function updateComment( "Cannot edit a deleted comment", ); } - if (existing.userId !== requestingUserId && !isAdmin) { + if (existing.userId !== requestingUserId && !canModerate) { throw new APIException( APIErrorCode.UNAUTHORIZED, 401, @@ -143,7 +143,7 @@ export function updateComment( export function deleteComment( commentId: string, requestingUserId: string, - isAdmin: boolean, + canModerate: boolean, ): { dumpId: string; isPrivate: boolean } { const row = db.prepare( `SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`, @@ -152,7 +152,7 @@ export function deleteComment( throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found"); } const comment = fetchComment(commentId); - if (comment.userId !== requestingUserId && !isAdmin) { + if (comment.userId !== requestingUserId && !canModerate) { throw new APIException( APIErrorCode.UNAUTHORIZED, 401, diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index 38fab09..2889902 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -269,7 +269,7 @@ export function getFollowedUsersByUser( ).get(userId) as { count: number } | undefined; const rawRows = db.prepare( - `SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, + `SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, i.username as invited_by_username FROM users u diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 1599741..40addc4 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -12,7 +12,7 @@ import { hashPassword } from "../lib/jwt.ts"; import { linkAttachments } from "./attachment-service.ts"; const USER_SELECT = - `SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, + `SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, i.username as invited_by_username FROM users u LEFT JOIN users i ON i.id = u.invited_by`; @@ -39,13 +39,13 @@ export async function createUser( const passwordHash = await hashPassword(request.password); db.prepare( - `INSERT INTO users (id, username, password_hash, is_admin, created_at, invited_by, email) + `INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email) VALUES (?, ?, ?, ?, ?, ?, ?);`, ).run( userId, request.username, passwordHash, - 0, + "user", createdAt.toISOString(), inviterId, request.email, @@ -55,7 +55,7 @@ export async function createUser( id: userId, username: request.username, passwordHash, - isAdmin: false, + role: "user", createdAt, email: request.email, }; @@ -155,11 +155,11 @@ export async function updateUser( const updatedUserRow = userApiToRow(updatedUser); const userResult = db.prepare( - `UPDATE users SET username = ?, password_hash = ?, is_admin = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`, + `UPDATE users SET username = ?, password_hash = ?, role = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`, ).run( updatedUserRow.username, updatedUserRow.password_hash, - updatedUserRow.is_admin, + updatedUserRow.role, updatedUserRow.description, updatedUserRow.email, now.toISOString(), diff --git a/api/services/vote-service.ts b/api/services/vote-service.ts index 7b5ab1a..dcaa756 100644 --- a/api/services/vote-service.ts +++ b/api/services/vote-service.ts @@ -81,7 +81,7 @@ export function getDumpVoters( ).get(dumpId) as { count: number } | undefined; const rawRows = db.prepare( - `SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, + `SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, i.username as invited_by_username FROM users u diff --git a/src/components/CommentThread.tsx b/src/components/CommentThread.tsx index dcb2273..352d203 100644 --- a/src/components/CommentThread.tsx +++ b/src/components/CommentThread.tsx @@ -4,6 +4,7 @@ import { Controller } from "react-hook-form"; import { t } from "@lingui/core/macro"; import { Plural, Trans } from "@lingui/react/macro"; import { API_URL, VALIDATION } from "../config/api.ts"; +import { can } from "../utils/permissions.ts"; import type { Comment, CreateCommentRequest, @@ -234,10 +235,11 @@ function CommentNode({ setEditOpen(false); } + const canModerate = can(currentUser, "comment:moderate"); const canDelete = !comment.deleted && !!currentUser && - (currentUser.id === comment.userId || currentUser.isAdmin); + (currentUser.id === comment.userId || canModerate); const canEdit = !comment.deleted && !!currentUser && - (currentUser.id === comment.userId || currentUser.isAdmin); + (currentUser.id === comment.userId || canModerate); if (comment.deleted) { return ( diff --git a/src/contexts/AuthProvider.tsx b/src/contexts/AuthProvider.tsx index f67c156..90a0311 100644 --- a/src/contexts/AuthProvider.tsx +++ b/src/contexts/AuthProvider.tsx @@ -30,6 +30,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { localStorage.removeItem("authResponse"); return null; } + // Sessions persisted before roles existed have no `role`; the server also + // rejects their tokens, but discard them here so the UI never renders with + // a roleless user. + if (!parsed.user.role) { + localStorage.removeItem("authResponse"); + return null; + } return parsed; }); diff --git a/src/model.ts b/src/model.ts index 403c830..1eeb5b4 100644 --- a/src/model.ts +++ b/src/model.ts @@ -84,10 +84,12 @@ export function hydrateDump(raw: unknown): Dump { * Users */ +export type Role = "user" | "moderator" | "admin"; + export interface PublicUser { id: string; username: string; - isAdmin: boolean; + role: Role; createdAt: Date; updatedAt?: Date; avatarMime?: string; diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index 2fbe22b..40a6a7e 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -3,6 +3,7 @@ import { Link, useLocation, useNavigate, useParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; +import { can } from "../utils/permissions.ts"; import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx"; import { API_URL, VALIDATION } from "../config/api.ts"; @@ -255,7 +256,8 @@ export function Dump() { } const { dump } = dumpState; - const canEdit = !!user && (dump.userId === user.id || user.isAdmin === true); + const canEdit = !!user && + (dump.userId === user.id || can(user, "dump:moderate")); const handleTitleSave = async () => { const trimmed = titleDraft.trim(); diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index be3e9f1..144b72c 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -16,6 +16,7 @@ import type { InviteTreeEntry, PaginatedData, PublicUser, + Role, } from "../model.ts"; import { deserializeAuthResponse, @@ -51,6 +52,7 @@ import { DumpCreateModal } from "../components/DumpCreateModal.tsx"; import { FollowUserButton } from "../components/FollowButton.tsx"; import { ErrorCard } from "../components/ErrorCard.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; +import { can } from "../utils/permissions.ts"; import { TextEditor } from "../components/TextEditor.tsx"; import { Markdown } from "../components/Markdown.tsx"; import { ChangePasswordModal } from "../components/ChangePasswordModal.tsx"; @@ -162,6 +164,7 @@ const PROFILE_TABS = [ "followed", "invitees", "settings", + "manage", ] as const; type ProfileTab = (typeof PROFILE_TABS)[number]; @@ -212,10 +215,14 @@ export function UserPublicProfile() { const isOwnProfile = me?.id === profileUserId; // Active tab is driven by the `/~/` URL path (linkable / refresh-safe). - // `settings` is only valid on your own profile; otherwise it falls back to "dumps". - const availableTabs: ProfileTab[] = isOwnProfile - ? [...PROFILE_TABS] - : PROFILE_TABS.filter((t) => t !== "settings"); + // `settings` is own-profile only; `manage` requires the user:manage permission + // on someone else's profile. Invalid tabs fall back to "dumps". + const canManageUsers = can(me, "user:manage"); + const availableTabs: ProfileTab[] = PROFILE_TABS.filter((t) => { + if (t === "settings") return isOwnProfile; + if (t === "manage") return !isOwnProfile && canManageUsers; + return true; + }); const [tab, setTab] = useTabParam(availableTabs, "dumps"); const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => { @@ -695,6 +702,12 @@ export function UserPublicProfile() { setDescEditing(false); }; + const handleRoleSaved = (role: Role) => { + setState((s) => + s.status === "loaded" ? { ...s, user: { ...s.user, role } } : s + ); + }; + if (state.status === "loading") { return ( @@ -871,6 +884,9 @@ export function UserPublicProfile() { ...(isOwnProfile ? [{ key: "settings" as const, label: Settings }] : []), + ...(!isOwnProfile && canManageUsers + ? [{ key: "manage" as const, label: Manage }] + : []), ]} activeTab={tab} onChange={(key) => setTab(key)} @@ -1223,6 +1239,21 @@ export function UserPublicProfile() { )} + + {tab === "manage" && !isOwnProfile && canManageUsers && ( +
+

+ User management +

+
+ +
+
+ )}
); } @@ -1483,6 +1514,72 @@ function FollowedUserCard({ user }: { user: PublicUser }) { ); } +const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [ + { value: "user", label: User }, + { value: "moderator", label: Moderator }, + { value: "admin", label: Admin }, +]; + +interface RoleSwitcherProps { + userId: string; + currentRole: Role; + onSaved: (role: Role) => void; +} + +// Segmented role control (matches the dump-visibility toggle style), gated +// behind the user:manage permission by its caller. +function RoleSwitcher({ userId, currentRole, onSaved }: RoleSwitcherProps) { + const { authFetch } = useAuth(); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function changeRole(role: Role) { + if (role === currentRole || saving) return; + setSaving(true); + setError(null); + try { + const res = await authFetch(`${API_URL}/api/users/${userId}/role`, { + method: "PATCH", + body: JSON.stringify({ role }), + }); + const body = parseAPIResponse(await res.json()); + if (!body.success) { + setError(body.error.message); + return; + } + onSaved(deserializePublicUser(body.data).role); + } catch (err) { + setError(friendlyFetchError(err)); + } finally { + setSaving(false); + } + } + + return ( + <> +
+ + Role + +
+ {ROLE_OPTIONS.map((opt) => ( + + ))} +
+
+ {error && } + + ); +} + interface EmailEditorProps { initialEmail: string; onCancel: () => void; diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts new file mode 100644 index 0000000..d075e18 --- /dev/null +++ b/src/utils/permissions.ts @@ -0,0 +1,24 @@ +import type { PublicUser, Role } from "../model.ts"; + +// Mirror of api/lib/permissions.ts. Keep the two in sync. +export type Permission = "dump:moderate" | "comment:moderate" | "user:manage"; + +const ALL_PERMISSIONS: readonly Permission[] = [ + "dump:moderate", + "comment:moderate", + "user:manage", +]; + +const ROLE_PERMISSIONS: Record = { + admin: ALL_PERMISSIONS, + moderator: ["dump:moderate", "comment:moderate"], + user: [], +}; + +export function can( + user: Pick | null | undefined, + permission: Permission, +): boolean { + if (!user) return false; + return ROLE_PERMISSIONS[user.role].includes(permission); +}