v3: added a roles/permissions system, added user management tab on user profile
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
This commit is contained in:
@@ -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 {
|
||||
|
||||
22
api/db/migrations/0004_user_roles.ts
Normal file
22
api/db/migrations/0004_user_roles.ts
Normal file
@@ -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';`,
|
||||
);
|
||||
}
|
||||
}
|
||||
13
api/db/migrations/0005_drop_is_admin.ts
Normal file
13
api/db/migrations/0005_drop_is_admin.ts
Normal file
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
27
api/lib/permissions.ts
Normal file
27
api/lib/permissions.ts
Normal file
@@ -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<Role, readonly Permission[]> = {
|
||||
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);
|
||||
}
|
||||
@@ -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<R> = RouteParams<R>,
|
||||
> = RouterContext<R, P, AuthState>;
|
||||
|
||||
export async function authMiddleware<R extends string>(
|
||||
ctx: AuthContext<R>,
|
||||
next: Next,
|
||||
): Promise<void> {
|
||||
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<R extends string>(
|
||||
ctx: AuthContext<R>,
|
||||
next: Next,
|
||||
): Promise<void> {
|
||||
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 <R extends string>(
|
||||
ctx: AuthContext<R>,
|
||||
next: Next,
|
||||
): Promise<void> => {
|
||||
if (!ctx.state.user || !can(ctx.state.user, permission)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
"Insufficient permissions",
|
||||
);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Comment> = { 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<null> = { success: true, data: null };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 `/~/<tab>` 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<ProfileTab>(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 (
|
||||
<PageShell>
|
||||
@@ -871,6 +884,9 @@ export function UserPublicProfile() {
|
||||
...(isOwnProfile
|
||||
? [{ key: "settings" as const, label: <Trans>Settings</Trans> }]
|
||||
: []),
|
||||
...(!isOwnProfile && canManageUsers
|
||||
? [{ key: "manage" as const, label: <Trans>Manage</Trans> }]
|
||||
: []),
|
||||
]}
|
||||
activeTab={tab}
|
||||
onChange={(key) => setTab(key)}
|
||||
@@ -1223,6 +1239,21 @@ export function UserPublicProfile() {
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "manage" && !isOwnProfile && canManageUsers && (
|
||||
<section className="profile-section">
|
||||
<h2 className="profile-section-title">
|
||||
<Trans>User management</Trans>
|
||||
</h2>
|
||||
<div className="profile-appearance-grid">
|
||||
<RoleSwitcher
|
||||
userId={profileUser.id}
|
||||
currentRole={profileUser.role}
|
||||
onSaved={handleRoleSaved}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1483,6 +1514,72 @@ function FollowedUserCard({ user }: { user: PublicUser }) {
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [
|
||||
{ value: "user", label: <Trans>User</Trans> },
|
||||
{ value: "moderator", label: <Trans>Moderator</Trans> },
|
||||
{ value: "admin", label: <Trans>Admin</Trans> },
|
||||
];
|
||||
|
||||
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<string | null>(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<RawPublicUser>(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 (
|
||||
<>
|
||||
<div className="profile-appearance-row">
|
||||
<span className="profile-appearance-label">
|
||||
<Trans>Role</Trans>
|
||||
</span>
|
||||
<div className="visibility-toggle">
|
||||
{ROLE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={currentRole === opt.value ? "active" : ""}
|
||||
disabled={saving}
|
||||
onClick={() => changeRole(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error && <ErrorCard title={t`Failed to update role`} message={error} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmailEditorProps {
|
||||
initialEmail: string;
|
||||
onCancel: () => void;
|
||||
|
||||
24
src/utils/permissions.ts
Normal file
24
src/utils/permissions.ts
Normal file
@@ -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<Role, readonly Permission[]> = {
|
||||
admin: ALL_PERMISSIONS,
|
||||
moderator: ["dump:moderate", "comment:moderate"],
|
||||
user: [],
|
||||
};
|
||||
|
||||
export function can(
|
||||
user: Pick<PublicUser, "role"> | null | undefined,
|
||||
permission: Permission,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
return ROLE_PERMISSIONS[user.role].includes(permission);
|
||||
}
|
||||
Reference in New Issue
Block a user