mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat(admin): manage user roles and delete users, with an instance owner
Admins can now make members admins and delete members from the Users page. The owner, the first admin, can also demote and delete other admins; nobody can demote or delete the owner, or themselves. A migration adds the owner flag and gives it to each existing instance's longest-standing admin, and the first sign-up of a fresh instance takes an advisory lock so concurrent sign-ups can't make two owners. Deleting a user removes their models with every file, their tag mosaics and storage folder, then the account. Roles are now read from the database on each request, so a demotion or deletion takes effect at once. Both actions are logged under the acting admin.
This commit is contained in:
1 parent
eefbde1bb5
commit
9d41c4dee3
17 files changed
+639
-19
No files matched your search
@@ -0,0 +1,6 @@
|
||||
-- The owner: the first admin, who alone may demote or delete other admins.
|
||||
ALTER TABLE "User" ADD COLUMN "isOwner" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Existing instances: the admin who has been there longest.
|
||||
UPDATE "User" SET "isOwner" = true
|
||||
WHERE id = (SELECT id FROM "User" WHERE role = 'ADMIN' ORDER BY "createdAt" ASC, id ASC LIMIT 1);
|
||||
@@ -25,6 +25,8 @@ model User {
|
||||
passwordHash String
|
||||
displayName String
|
||||
role Role @default(MEMBER)
|
||||
// The first admin: may demote and delete other admins, and can be neither demoted nor deleted.
|
||||
isOwner Boolean @default(false)
|
||||
// Defaults true so existing accounts aren't locked out; registration sets false when SMTP is on.
|
||||
emailVerified Boolean @default(true)
|
||||
emailVerificationToken String? @unique
|
||||
|
||||
+9
-1
@@ -5,6 +5,7 @@ import { AUTH_ALGO, AUTH_SECRET } from "./config";
|
||||
import { getAuthTokenTtl } from "./services/settingsService";
|
||||
import { findApiTokenUser, isApiToken } from "./services/apiTokenService";
|
||||
import { asyncHandler } from "./utils/asyncHandler";
|
||||
import { prisma } from "./db";
|
||||
|
||||
export type TokenPayload = { sub: string; role: Role };
|
||||
|
||||
@@ -71,8 +72,15 @@ export const requireAuth = asyncHandler(async (req, res, next) => {
|
||||
res.status(401).json({ detail: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
// Read afresh rather than trusted from the token, so a demotion or a deleted account takes effect at once
|
||||
// instead of when the token expires.
|
||||
const user = await prisma.user.findUnique({ where: { id: payload.sub }, select: { role: true } });
|
||||
if (!user) {
|
||||
res.status(401).json({ detail: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
req.userId = payload.sub;
|
||||
req.userRole = payload.role;
|
||||
req.userRole = user.role;
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
+39
-11
@@ -7,6 +7,7 @@ import { z } from "zod";
|
||||
import { parseBody } from "../utils/validate";
|
||||
import { deleteAllPrintsForUser, getStorageUsage, listLogs, listUsersWithPrintCounts } from "../services/adminService";
|
||||
import { createLog } from "../services/auditLog";
|
||||
import { deleteUser, permissionsOver, setUserRole } from "../services/userAdminService";
|
||||
import { INVITATION_TTL_DAYS, inviteUser } from "../services/invitationService";
|
||||
import { listJobItems } from "../services/importJobService";
|
||||
import {
|
||||
@@ -37,19 +38,28 @@ router.use(requireAdmin);
|
||||
|
||||
router.get(
|
||||
"/users",
|
||||
asyncHandler(async (_req, res) => {
|
||||
asyncHandler(async (req, res) => {
|
||||
const users = await listUsersWithPrintCounts();
|
||||
const actor = users.find((u) => u.id === req.userId) ?? { id: req.userId!, isOwner: false };
|
||||
res.json(
|
||||
users.map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
display_name: u.displayName,
|
||||
role: u.role,
|
||||
print_count: u.printCount,
|
||||
collection_count: u.collectionCount,
|
||||
makerworld_connected: u.makerworldConnected,
|
||||
created_at: u.createdAt,
|
||||
})),
|
||||
users.map((u) => {
|
||||
// What the viewing admin may do to this user; the endpoints below enforce the same rules.
|
||||
const allowed = permissionsOver(actor, u);
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
display_name: u.displayName,
|
||||
role: u.role,
|
||||
is_owner: u.isOwner,
|
||||
can_promote: allowed.canPromote,
|
||||
can_demote: allowed.canDemote,
|
||||
can_delete: allowed.canDelete,
|
||||
print_count: u.printCount,
|
||||
collection_count: u.collectionCount,
|
||||
makerworld_connected: u.makerworldConnected,
|
||||
created_at: u.createdAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -110,6 +120,24 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
const roleSchema = z.object({ role: z.enum(["ADMIN", "MEMBER"]) });
|
||||
|
||||
router.patch(
|
||||
"/users/:id/role",
|
||||
asyncHandler(async (req, res) => {
|
||||
await setUserRole(req.userId!, req.params.id, parseBody(roleSchema, req.body).role);
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
// Irreversible: the account, its models and every file of them. The UI handles the confirmation.
|
||||
router.delete(
|
||||
"/users/:id",
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json(await deleteUser(req.userId!, req.params.id));
|
||||
}),
|
||||
);
|
||||
|
||||
// Irreversible; the UI handles the confirmation.
|
||||
router.post(
|
||||
"/users/:id/delete-all-prints",
|
||||
|
||||
@@ -17,6 +17,9 @@ import { generateApiToken, getApiToken, revokeApiToken } from "../services/apiTo
|
||||
import { toUserOut } from "../dto";
|
||||
import type { Prisma, Role } from "@prisma/client";
|
||||
|
||||
// Any number, as long as only the first sign-up takes it.
|
||||
const BOOTSTRAP_LOCK = 74_210_302;
|
||||
|
||||
const router = Router();
|
||||
|
||||
const PASSWORD_HASH_COST = 12;
|
||||
@@ -77,12 +80,26 @@ router.post(
|
||||
|
||||
const user = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
if (bootstrapping) {
|
||||
// Two first sign-ups at once would both have counted no admin above, and both become the owner,
|
||||
// whom nobody can then demote. Taken in turn, only the first finds no admin here.
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${BOOTSTRAP_LOCK})`;
|
||||
if ((await tx.user.count({ where: { role: "ADMIN" } })) > 0) {
|
||||
// Not a member instead: this request skipped the registrations-closed check as the first account.
|
||||
throw new HttpError(
|
||||
409,
|
||||
"This instance has just been set up. Sign in, or ask its admin for an invitation.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
displayName: body.displayName,
|
||||
role,
|
||||
// The bootstrap admin owns the instance.
|
||||
isOwner: bootstrapping,
|
||||
emailVerified: !smtpConfigured,
|
||||
emailVerificationToken: verification?.token ?? null,
|
||||
emailVerificationExpires: verification?.expires ?? null,
|
||||
|
||||
@@ -14,6 +14,7 @@ export type UserWithPrintCount = {
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: "ADMIN" | "MEMBER";
|
||||
isOwner: boolean;
|
||||
printCount: number;
|
||||
collectionCount: number;
|
||||
makerworldConnected: boolean;
|
||||
@@ -28,6 +29,7 @@ export async function listUsersWithPrintCounts(): Promise<UserWithPrintCount[]>
|
||||
email: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
isOwner: true,
|
||||
createdAt: true,
|
||||
makerworldCookie: true,
|
||||
_count: { select: { prints: true, collections: true } },
|
||||
@@ -38,6 +40,7 @@ export async function listUsersWithPrintCounts(): Promise<UserWithPrintCount[]>
|
||||
email: u.email,
|
||||
displayName: u.displayName,
|
||||
role: u.role,
|
||||
isOwner: u.isOwner,
|
||||
createdAt: u.createdAt,
|
||||
printCount: u._count.prints,
|
||||
collectionCount: u._count.collections,
|
||||
|
||||
@@ -26,7 +26,9 @@ export type LogAction =
|
||||
| "collection_sync_disabled"
|
||||
| "ai_categorization_run"
|
||||
| "ai_description_run"
|
||||
| "tag_deleted";
|
||||
| "tag_deleted"
|
||||
| "user_role_changed"
|
||||
| "user_deleted";
|
||||
|
||||
/** Fire-and-forget: a logging failure must never break the action it records. */
|
||||
export async function createLog(params: {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Role } from "@prisma/client";
|
||||
import { STORAGE } from "../config";
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { deleteAllPrintsForUser } from "./adminService";
|
||||
import { createLog } from "./auditLog";
|
||||
import { removeUserTagMosaics } from "./tagMosaicService";
|
||||
|
||||
// Any admin manages members; only the owner (the first admin) manages other admins. Nobody demotes or
|
||||
// deletes the owner, or themselves, so the instance always keeps an admin who can.
|
||||
|
||||
type Actor = { id: string; isOwner: boolean };
|
||||
type Target = { id: string; role: Role; isOwner: boolean };
|
||||
|
||||
export type UserPermissions = { canPromote: boolean; canDemote: boolean; canDelete: boolean };
|
||||
|
||||
export function permissionsOver(actor: Actor, target: Target): UserPermissions {
|
||||
const untouchable = target.isOwner || target.id === actor.id;
|
||||
const outranked = target.role === "ADMIN" && !actor.isOwner;
|
||||
return {
|
||||
canPromote: !untouchable && target.role === "MEMBER",
|
||||
canDemote: !untouchable && !outranked && target.role === "ADMIN",
|
||||
canDelete: !untouchable && !outranked,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadActor(actorId: string): Promise<Actor> {
|
||||
const actor = await prisma.user.findUnique({ where: { id: actorId }, select: { id: true, isOwner: true } });
|
||||
if (!actor) throw new HttpError(401, "Unauthorized");
|
||||
return actor;
|
||||
}
|
||||
|
||||
async function loadTarget(targetId: string) {
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id: targetId },
|
||||
select: { id: true, role: true, isOwner: true, email: true, displayName: true },
|
||||
});
|
||||
if (!target) throw new HttpError(404, "User not found");
|
||||
return target;
|
||||
}
|
||||
|
||||
export async function setUserRole(actorId: string, targetId: string, role: Role): Promise<void> {
|
||||
const [actor, target] = await Promise.all([loadActor(actorId), loadTarget(targetId)]);
|
||||
if (target.role === role) return;
|
||||
const allowed = permissionsOver(actor, target);
|
||||
if (role === "ADMIN" ? !allowed.canPromote : !allowed.canDemote) {
|
||||
throw new HttpError(403, "You can't change this user's role");
|
||||
}
|
||||
await prisma.user.update({ where: { id: target.id }, data: { role } });
|
||||
void createLog({
|
||||
userId: actor.id,
|
||||
action: "user_role_changed",
|
||||
targetId: target.id,
|
||||
details: { email: target.email, role },
|
||||
});
|
||||
}
|
||||
|
||||
/** Everything of the user's: their models and every file of them, their own folders, then the account, whose
|
||||
* collections, bookmarks, categories and the rest go with it. */
|
||||
export async function deleteUser(actorId: string, targetId: string): Promise<{ models: number }> {
|
||||
const [actor, target] = await Promise.all([loadActor(actorId), loadTarget(targetId)]);
|
||||
if (!permissionsOver(actor, target).canDelete) throw new HttpError(403, "You can't delete this user");
|
||||
const models = await deleteAllPrintsForUser(target.id);
|
||||
await removeUserTagMosaics(target.id);
|
||||
// Model files live under u-<id>; anything left there (a file the database lost track of) goes too.
|
||||
await fs.rm(path.join(STORAGE, `u-${target.id}`), { recursive: true, force: true });
|
||||
await prisma.user.delete({ where: { id: target.id } });
|
||||
// Logged as the admin's: the user's own log entries went with them.
|
||||
void createLog({
|
||||
userId: actor.id,
|
||||
action: "user_deleted",
|
||||
targetId: target.id,
|
||||
details: { email: target.email, name: target.displayName, models },
|
||||
});
|
||||
return { models };
|
||||
}
|
||||
@@ -30,7 +30,6 @@ beforeAll(async () => {
|
||||
);
|
||||
}
|
||||
await prisma.user.update({ where: { id: adminRegister.body.user.id }, data: { role: "ADMIN" } });
|
||||
// Tokens embed the role at issue time, so a promoted account needs a fresh login to pick it up.
|
||||
const adminLogin = await request(app).post("/api/login").send({ email: adminEmail, password: "password123" });
|
||||
adminToken = adminLogin.body.token;
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
import { STORAGE, TAG_MOSAICS } from "../src/config";
|
||||
import { prisma } from "../src/db";
|
||||
|
||||
const app = createApp();
|
||||
const STL =
|
||||
"solid t\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid t\n";
|
||||
|
||||
type Account = { id: string; token: string };
|
||||
let seq = 0;
|
||||
|
||||
async function account(kind: "owner" | "admin" | "member"): Promise<Account> {
|
||||
const res = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: kind, email: `roles-${kind}-${Date.now()}-${seq++}@example.com`, password: "password123" });
|
||||
const id = res.body.user.id as string;
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { role: kind === "member" ? "MEMBER" : "ADMIN", isOwner: kind === "owner" },
|
||||
});
|
||||
return { id, token: res.body.token as string };
|
||||
}
|
||||
|
||||
const as = (who: Account) => ({ Authorization: `Bearer ${who.token}` });
|
||||
const setRole = (who: Account, target: Account, role: "ADMIN" | "MEMBER") =>
|
||||
request(app).patch(`/api/admin/users/${target.id}/role`).set(as(who)).send({ role });
|
||||
const remove = (who: Account, target: Account) => request(app).delete(`/api/admin/users/${target.id}`).set(as(who));
|
||||
const roleOf = async (target: Account) => (await prisma.user.findUnique({ where: { id: target.id } }))?.role;
|
||||
|
||||
let owner: Account;
|
||||
let admin: Account;
|
||||
let member: Account;
|
||||
|
||||
beforeEach(async () => {
|
||||
[owner, admin, member] = await Promise.all([account("owner"), account("admin"), account("member")]);
|
||||
});
|
||||
|
||||
describe("roles", () => {
|
||||
it("let any admin make a member an admin, but only the owner take it away", async () => {
|
||||
expect((await setRole(admin, member, "ADMIN")).status).toBe(200);
|
||||
expect(await roleOf(member)).toBe("ADMIN");
|
||||
|
||||
expect((await setRole(admin, member, "MEMBER")).status).toBe(403);
|
||||
expect((await setRole(owner, member, "MEMBER")).status).toBe(200);
|
||||
expect(await roleOf(member)).toBe("MEMBER");
|
||||
});
|
||||
|
||||
it("never let anyone demote the owner or themselves", async () => {
|
||||
const other = await account("owner");
|
||||
expect((await setRole(other, owner, "MEMBER")).status).toBe(403);
|
||||
expect((await setRole(owner, owner, "MEMBER")).status).toBe(403);
|
||||
expect((await setRole(admin, admin, "MEMBER")).status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuse members", async () => {
|
||||
const other = await account("member");
|
||||
expect((await setRole(member, other, "ADMIN")).status).toBe(403);
|
||||
expect((await remove(member, other)).status).toBe(403);
|
||||
});
|
||||
|
||||
it("take effect at once, without waiting for the session to expire", async () => {
|
||||
expect((await request(app).get("/api/admin/users").set(as(admin))).status).toBe(200);
|
||||
await setRole(owner, admin, "MEMBER");
|
||||
expect((await request(app).get("/api/admin/users").set(as(admin))).status).toBe(403);
|
||||
});
|
||||
|
||||
it("tell each admin what they may do to whom", async () => {
|
||||
const list = async (who: Account) =>
|
||||
new Map(
|
||||
((await request(app).get("/api/admin/users").set(as(who))).body as { id: string }[]).map((u) => [u.id, u]),
|
||||
);
|
||||
const byAdmin = await list(admin);
|
||||
expect(byAdmin.get(member.id)).toMatchObject({ can_promote: true, can_demote: false, can_delete: true });
|
||||
expect(byAdmin.get(owner.id)).toMatchObject({
|
||||
is_owner: true,
|
||||
can_promote: false,
|
||||
can_demote: false,
|
||||
can_delete: false,
|
||||
});
|
||||
expect(byAdmin.get(admin.id)).toMatchObject({ can_demote: false, can_delete: false });
|
||||
const byOwner = await list(owner);
|
||||
expect(byOwner.get(admin.id)).toMatchObject({ can_promote: false, can_demote: true, can_delete: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleting a user", () => {
|
||||
it("is for members by any admin, and for admins only by the owner; never the owner or yourself", async () => {
|
||||
expect((await remove(admin, owner)).status).toBe(403);
|
||||
expect((await remove(admin, admin)).status).toBe(403);
|
||||
const otherAdmin = await account("admin");
|
||||
expect((await remove(admin, otherAdmin)).status).toBe(403);
|
||||
expect((await remove(owner, otherAdmin)).status).toBe(200);
|
||||
expect((await remove(admin, member)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("removes the account, its models and every file, and logs it as the admin's", async () => {
|
||||
const upload = await request(app).post("/api/upload").set(as(member)).attach("files", Buffer.from(STL), "part.stl");
|
||||
expect(upload.status).toBe(200);
|
||||
const userFiles = path.join(STORAGE, `u-${member.id}`);
|
||||
const mosaics = path.join(TAG_MOSAICS, `u-${member.id}`);
|
||||
fs.mkdirSync(path.join(userFiles, "leftover"), { recursive: true });
|
||||
fs.mkdirSync(mosaics, { recursive: true });
|
||||
fs.writeFileSync(path.join(mosaics, "tag.webp"), "x");
|
||||
|
||||
const res = await remove(admin, member);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.models).toBe(1);
|
||||
expect(await prisma.user.findUnique({ where: { id: member.id } })).toBeNull();
|
||||
expect(await prisma.print.count({ where: { userId: member.id } })).toBe(0);
|
||||
expect(fs.existsSync(userFiles)).toBe(false);
|
||||
expect(fs.existsSync(mosaics)).toBe(false);
|
||||
// The deleted user's session no longer works.
|
||||
expect((await request(app).get("/api/prints").set(as(member))).status).toBe(401);
|
||||
// Logged once the response is out; give it a moment.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const log = await prisma.log.findFirst({
|
||||
where: { userId: admin.id, action: "user_deleted", targetId: member.id },
|
||||
});
|
||||
expect(log?.details).toMatchObject({ models: 1 });
|
||||
});
|
||||
|
||||
it("404s for a user that isn't there", async () => {
|
||||
expect((await request(app).delete("/api/admin/users/nobody").set(as(owner))).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the first sign-up", () => {
|
||||
it("makes exactly one owner, however many arrive at once", async () => {
|
||||
// A fresh instance, for the length of this test: nobody is an admin yet.
|
||||
const admins = await prisma.user.findMany({ where: { role: "ADMIN" }, select: { id: true, isOwner: true } });
|
||||
await prisma.user.updateMany({ where: { role: "ADMIN" }, data: { role: "MEMBER", isOwner: false } });
|
||||
const created: string[] = [];
|
||||
try {
|
||||
const stamp = Date.now();
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 5 }, (_, i) =>
|
||||
request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: `First ${i}`, email: `first-${stamp}-${i}@example.com`, password: "password123" }),
|
||||
),
|
||||
);
|
||||
for (const res of results) if (res.body.user) created.push(res.body.user.id);
|
||||
expect(results.map((res) => res.status).toSorted()).toEqual([200, 409, 409, 409, 409]);
|
||||
const winners = await prisma.user.findMany({ where: { id: { in: created } } });
|
||||
expect(winners.map((user) => [user.role, user.isOwner])).toEqual([["ADMIN", true]]);
|
||||
} finally {
|
||||
await prisma.user.deleteMany({ where: { id: { in: created } } });
|
||||
for (const previous of admins) {
|
||||
await prisma.user.update({ where: { id: previous.id }, data: { role: "ADMIN", isOwner: previous.isOwner } });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,12 @@ export type AdminUser = {
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: "ADMIN" | "MEMBER";
|
||||
/** The first admin: may manage other admins, and can't be demoted or deleted. */
|
||||
is_owner: boolean;
|
||||
/** What the viewing admin may do to this user; the server enforces the same. */
|
||||
can_promote: boolean;
|
||||
can_demote: boolean;
|
||||
can_delete: boolean;
|
||||
print_count: number;
|
||||
collection_count: number;
|
||||
makerworld_connected: boolean;
|
||||
@@ -38,7 +44,9 @@ export type LogAction =
|
||||
| "collection_sync_disabled"
|
||||
| "ai_categorization_run"
|
||||
| "ai_description_run"
|
||||
| "tag_deleted";
|
||||
| "tag_deleted"
|
||||
| "user_role_changed"
|
||||
| "user_deleted";
|
||||
|
||||
export type LogEntry = {
|
||||
id: string;
|
||||
@@ -139,6 +147,27 @@ export const adminApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
setUserRole: async (userId: string, role: AdminUser["role"]): Promise<void> => {
|
||||
const res = await fetch(`${apiBase()}/admin/users/${encodeURIComponent(userId)}/role`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to change the role"));
|
||||
},
|
||||
|
||||
/** The account, its models and every file of them. */
|
||||
deleteUser: async (userId: string): Promise<{ models: number }> => {
|
||||
const res = await fetch(`${apiBase()}/admin/users/${encodeURIComponent(userId)}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to delete the user"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getAuthorLinking: async (): Promise<AuthorLinkingStatus> => {
|
||||
const res = await fetch(`${apiBase()}/admin/triggers/link-authors`, { headers: authHeaders() });
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
|
||||
@@ -893,6 +893,26 @@
|
||||
"roleMember": "Member",
|
||||
"makerworldConnected": "Connected",
|
||||
"makerworldNotConnected": "Not connected",
|
||||
"columnActions": "Actions",
|
||||
"owner": "Owner",
|
||||
"promote": "Make admin",
|
||||
"demote": "Remove admin",
|
||||
"delete": "Delete user",
|
||||
"promoteTitle": "Make {{name}} an admin?",
|
||||
"promoteMessage": "Admins can change instance settings, see every user's logs and manage members. Only the owner can later take this back.",
|
||||
"demoteTitle": "Remove {{name}}'s admin role?",
|
||||
"demoteMessage": "They keep their account and models, as a member, and lose access to administration at once.",
|
||||
"promoted": "{{email}} is now an admin",
|
||||
"demoted": "{{email}} is now a member",
|
||||
"deleteTitle": "Delete {{name}}?",
|
||||
"deleteWarning_one": "This deletes the account, its {{count}} model with every file, and its collections, categories and bookmarks. It can't be undone.",
|
||||
"deleteWarning_other": "This deletes the account, its {{count}} models with every file, and its collections, categories and bookmarks. It can't be undone.",
|
||||
"deleteTypeEmail": "Type {{email}} to confirm.",
|
||||
"deleteTypeEmailLabel": "The user's email",
|
||||
"deleteConfirm": "Delete user",
|
||||
"deleted_one": "{{email}} deleted, with {{count}} model",
|
||||
"deleted_other": "{{email}} deleted, with {{count}} models",
|
||||
"deleteFailed": "Failed to delete the user.",
|
||||
"loadFailed": "Unable to load users."
|
||||
},
|
||||
"logs": {
|
||||
@@ -932,6 +952,8 @@
|
||||
"collection_sync_disabled": "Collection sync turned off",
|
||||
"authors_linked": "Linked missing authors",
|
||||
"descriptions_refetched": "Refetched model descriptions",
|
||||
"user_role_changed": "User role changed",
|
||||
"user_deleted": "User deleted",
|
||||
"tag_deleted": "Tag deleted",
|
||||
"ai_categorization_run": "AI categorization run",
|
||||
"ai_description_run": "AI description improvement run"
|
||||
@@ -940,6 +962,10 @@
|
||||
"descriptionsRefetched": "{{email}} — {{updated}} of {{total}} description(s) changed",
|
||||
"tagDeleted_one": "{{name}} — removed from {{count}} model",
|
||||
"tagDeleted_other": "{{name}} — removed from {{count}} models",
|
||||
"userPromoted": "{{email}} made an admin",
|
||||
"userDemoted": "{{email}} made a member",
|
||||
"userDeleted_one": "{{email}} — with {{count}} model",
|
||||
"userDeleted_other": "{{email}} — with {{count}} models",
|
||||
"aiRunSummary": "{{done}} of {{total}} checked — {{suggested}} suggested, {{applied}} applied, {{failed}} failed",
|
||||
"aiRunStopped": "(stopped)"
|
||||
},
|
||||
|
||||
@@ -901,6 +901,30 @@
|
||||
"roleMember": "Narys",
|
||||
"makerworldConnected": "Prijungta",
|
||||
"makerworldNotConnected": "Neprijungta",
|
||||
"columnActions": "Veiksmai",
|
||||
"owner": "Savininkas",
|
||||
"promote": "Padaryti administratoriumi",
|
||||
"demote": "Atimti administratoriaus teises",
|
||||
"delete": "Ištrinti naudotoją",
|
||||
"promoteTitle": "Padaryti {{name}} administratoriumi?",
|
||||
"promoteMessage": "Administratoriai gali keisti sistemos nustatymus, matyti visų naudotojų žurnalus ir tvarkyti narius. Vėliau šias teises atimti gali tik savininkas.",
|
||||
"demoteTitle": "Atimti {{name}} administratoriaus teises?",
|
||||
"demoteMessage": "Paskyra ir modeliai lieka, kaip nario, o prieiga prie administravimo dingsta iš karto.",
|
||||
"promoted": "{{email}} dabar administratorius",
|
||||
"demoted": "{{email}} dabar narys",
|
||||
"deleteTitle": "Ištrinti {{name}}?",
|
||||
"deleteWarning_one": "Bus ištrinta paskyra, jos {{count}} modelis su visais failais, kolekcijos, kategorijos ir žymės. To atšaukti negalima.",
|
||||
"deleteWarning_few": "Bus ištrinta paskyra, jos {{count}} modeliai su visais failais, kolekcijos, kategorijos ir žymės. To atšaukti negalima.",
|
||||
"deleteWarning_many": "Bus ištrinta paskyra, jos {{count}} modelio su visais failais, kolekcijos, kategorijos ir žymės. To atšaukti negalima.",
|
||||
"deleteWarning_other": "Bus ištrinta paskyra, jos {{count}} modelių su visais failais, kolekcijos, kategorijos ir žymės. To atšaukti negalima.",
|
||||
"deleteTypeEmail": "Įveskite {{email}}, kad patvirtintumėte.",
|
||||
"deleteTypeEmailLabel": "Naudotojo el. paštas",
|
||||
"deleteConfirm": "Ištrinti naudotoją",
|
||||
"deleted_one": "{{email}} ištrintas su {{count}} modeliu",
|
||||
"deleted_few": "{{email}} ištrintas su {{count}} modeliais",
|
||||
"deleted_many": "{{email}} ištrintas su {{count}} modelio",
|
||||
"deleted_other": "{{email}} ištrintas su {{count}} modelių",
|
||||
"deleteFailed": "Nepavyko ištrinti naudotojo.",
|
||||
"loadFailed": "Nepavyko įkelti naudotojų."
|
||||
},
|
||||
"logs": {
|
||||
@@ -940,6 +964,8 @@
|
||||
"collection_sync_disabled": "Išjungtas kolekcijos sinchronizavimas",
|
||||
"authors_linked": "Susieti trūkstami autoriai",
|
||||
"descriptions_refetched": "Iš naujo gauti modelių aprašymai",
|
||||
"user_role_changed": "Pakeista naudotojo rolė",
|
||||
"user_deleted": "Naudotojas ištrintas",
|
||||
"tag_deleted": "Žyma ištrinta",
|
||||
"ai_categorization_run": "AI kategorizavimo paleidimas",
|
||||
"ai_description_run": "AI aprašymų tobulinimo paleidimas"
|
||||
@@ -950,6 +976,12 @@
|
||||
"tagDeleted_few": "{{name}} — pašalinta iš {{count}} modelių",
|
||||
"tagDeleted_many": "{{name}} — pašalinta iš {{count}} modelio",
|
||||
"tagDeleted_other": "{{name}} — pašalinta iš {{count}} modelių",
|
||||
"userPromoted": "{{email}} padarytas administratoriumi",
|
||||
"userDemoted": "{{email}} padarytas nariu",
|
||||
"userDeleted_one": "{{email}} — su {{count}} modeliu",
|
||||
"userDeleted_few": "{{email}} — su {{count}} modeliais",
|
||||
"userDeleted_many": "{{email}} — su {{count}} modelio",
|
||||
"userDeleted_other": "{{email}} — su {{count}} modelių",
|
||||
"aiRunSummary": "Patikrinta {{done}} iš {{total}} — pasiūlyta {{suggested}}, pritaikyta {{applied}}, nepavyko {{failed}}",
|
||||
"aiRunStopped": "(sustabdyta)"
|
||||
},
|
||||
|
||||
@@ -53,6 +53,8 @@ const ACTION_COLORS: Record<LogAction, ActionColor> = {
|
||||
ai_categorization_run: "info",
|
||||
ai_description_run: "info",
|
||||
tag_deleted: "error",
|
||||
user_role_changed: "warning",
|
||||
user_deleted: "error",
|
||||
};
|
||||
|
||||
function isoDateOnly(d: Date): string {
|
||||
@@ -140,6 +142,15 @@ export default function LogsPage({ onUnauthorized }: Props) {
|
||||
const failed = typeof d.failed === "number" ? d.failed : 0;
|
||||
return t("adminSettings.logs.importSummary", { label, imported, failed });
|
||||
}
|
||||
case "user_role_changed":
|
||||
return t(d.role === "ADMIN" ? "adminSettings.logs.userPromoted" : "adminSettings.logs.userDemoted", {
|
||||
email: typeof d.email === "string" ? d.email : "",
|
||||
});
|
||||
case "user_deleted":
|
||||
return t("adminSettings.logs.userDeleted", {
|
||||
email: typeof d.email === "string" ? d.email : "",
|
||||
count: typeof d.models === "number" ? d.models : 0,
|
||||
});
|
||||
case "tag_deleted":
|
||||
return t("adminSettings.logs.tagDeleted", {
|
||||
name: typeof d.name === "string" ? d.name : "",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { adminApi, type AdminUser } from "../../api/admin";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
|
||||
type Props = {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
onDeleted: () => void;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
/** Can't be undone, so the admin types the user's email to confirm. */
|
||||
export default function DeleteUserDialog({ user, onClose, onDeleted, onUnauthorized }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const showToast = useToast();
|
||||
const [typed, setTyped] = useState("");
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const confirmed = typed.trim().toLowerCase() === user.email.toLowerCase();
|
||||
|
||||
const remove = async () => {
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { models } = await adminApi.deleteUser(user.id);
|
||||
showToast({ message: t("adminSettings.users.deleted", { email: user.email, count: models }) });
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else setError(err instanceof Error ? err.message : t("adminSettings.users.deleteFailed"));
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={deleting ? undefined : onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t("adminSettings.users.deleteTitle", { name: user.display_name })}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{t("adminSettings.users.deleteWarning", { count: user.print_count })}
|
||||
</Alert>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{t("adminSettings.users.deleteTypeEmail", { email: user.email })}
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={typed}
|
||||
placeholder={user.email}
|
||||
onChange={(event) => setTyped(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && confirmed && !deleting) void remove();
|
||||
}}
|
||||
disabled={deleting}
|
||||
slotProps={{ htmlInput: { "aria-label": t("adminSettings.users.deleteTypeEmailLabel") } }}
|
||||
/>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={deleting}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={() => void remove()}
|
||||
disabled={!confirmed || deleting}
|
||||
startIcon={deleting ? <CircularProgress size={16} color="inherit" /> : undefined}
|
||||
>
|
||||
{t("adminSettings.users.deleteConfirm")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import AdminPanelSettingsOutlinedIcon from "@mui/icons-material/AdminPanelSettingsOutlined";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import PersonOutlineIcon from "@mui/icons-material/PersonOutline";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { adminApi, type AdminUser } from "../../api/admin";
|
||||
import { useConfirm } from "../../components/ConfirmProvider";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
import DeleteUserDialog from "./DeleteUserDialog";
|
||||
|
||||
type Props = {
|
||||
user: AdminUser;
|
||||
/** After a change, so the list shows it. */
|
||||
onChanged: () => void;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
/** Only what the viewing admin may do to this user, as the server says. */
|
||||
export default function UserActionsMenu({ user, onChanged, onUnauthorized }: Props) {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const confirm = useConfirm();
|
||||
const showToast = useToast();
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
if (!user.can_promote && !user.can_demote && !user.can_delete) return null;
|
||||
|
||||
const changeRole = async (role: AdminUser["role"]) => {
|
||||
setAnchor(null);
|
||||
const promote = role === "ADMIN";
|
||||
const ok = await confirm({
|
||||
title: t(promote ? "adminSettings.users.promoteTitle" : "adminSettings.users.demoteTitle", {
|
||||
name: user.display_name,
|
||||
}),
|
||||
message: t(promote ? "adminSettings.users.promoteMessage" : "adminSettings.users.demoteMessage"),
|
||||
confirmLabel: t(promote ? "adminSettings.users.promote" : "adminSettings.users.demote"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await adminApi.setUserRole(user.id, role);
|
||||
showToast({
|
||||
message: t(promote ? "adminSettings.users.promoted" : "adminSettings.users.demoted", { email: user.email }),
|
||||
});
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else showToast({ message: err instanceof Error ? err.message : String(err), severity: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={t("common:moreActions")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={t("common:moreActions")}
|
||||
onClick={(event) => setAnchor(event.currentTarget)}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu anchorEl={anchor} open={Boolean(anchor)} onClose={() => setAnchor(null)}>
|
||||
{user.can_promote && (
|
||||
<MenuItem onClick={() => void changeRole("ADMIN")}>
|
||||
<ListItemIcon>
|
||||
<AdminPanelSettingsOutlinedIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("adminSettings.users.promote")}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{user.can_demote && (
|
||||
<MenuItem onClick={() => void changeRole("MEMBER")}>
|
||||
<ListItemIcon>
|
||||
<PersonOutlineIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("adminSettings.users.demote")}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{user.can_delete && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAnchor(null);
|
||||
setDeleting(true);
|
||||
}}
|
||||
sx={{ color: "error.main" }}
|
||||
>
|
||||
<ListItemIcon sx={{ color: "inherit" }}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("adminSettings.users.delete")}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
{deleting && (
|
||||
<DeleteUserDialog
|
||||
user={user}
|
||||
onClose={() => setDeleting(false)}
|
||||
onDeleted={() => {
|
||||
setDeleting(false);
|
||||
onChanged();
|
||||
}}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,23 +13,28 @@ import TableRow from "@mui/material/TableRow";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { adminApi, type AdminUser } from "../../api/admin";
|
||||
import RegistrationsPanel from "./RegistrationsPanel";
|
||||
import UserActionsMenu from "./UserActionsMenu";
|
||||
import { TableRowSkeletons } from "../../components/ListSkeleton";
|
||||
|
||||
type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
/** Read-only roster of every account, under the registration switch and invitations. */
|
||||
/** Every account, under the registration switch and invitations; admins promote, demote and delete from each row's menu. */
|
||||
export default function UsersPage({ onUnauthorized }: Props) {
|
||||
const { t, i18n } = useTranslation(["app", "common"]);
|
||||
const [users, setUsers] = React.useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [reloads, setReloads] = React.useState(0);
|
||||
const reload = React.useCallback(() => setReloads((count) => count + 1), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
// Only the first load shows skeletons; a reload after a change keeps the table in place.
|
||||
if (reloads === 0) setLoading(true);
|
||||
try {
|
||||
const result = await adminApi.listUsers();
|
||||
if (!cancelled) setUsers(result);
|
||||
@@ -44,7 +49,7 @@ export default function UsersPage({ onUnauthorized }: Props) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [onUnauthorized, t]);
|
||||
}, [onUnauthorized, t, reloads]);
|
||||
|
||||
const formatDate = (iso: string) => new Date(iso).toLocaleDateString(i18n.language);
|
||||
|
||||
@@ -70,11 +75,12 @@ export default function UsersPage({ onUnauthorized }: Props) {
|
||||
<TableCell align="right">{t("adminSettings.users.columnModels")}</TableCell>
|
||||
<TableCell align="right">{t("adminSettings.users.columnCollections")}</TableCell>
|
||||
<TableCell>{t("adminSettings.users.columnMakerworld")}</TableCell>
|
||||
<TableCell padding="checkbox" aria-label={t("adminSettings.users.columnActions")} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRowSkeletons rows={5} columns={7} />
|
||||
<TableRowSkeletons rows={5} columns={8} />
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<TableRow key={u.id} hover>
|
||||
@@ -90,6 +96,15 @@ export default function UsersPage({ onUnauthorized }: Props) {
|
||||
color={u.role === "ADMIN" ? "primary" : "default"}
|
||||
variant={u.role === "ADMIN" ? "filled" : "outlined"}
|
||||
/>
|
||||
{u.is_owner && (
|
||||
<Chip
|
||||
label={t("adminSettings.users.owner")}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ ml: 0.75 }}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">{u.print_count}</TableCell>
|
||||
<TableCell align="right">{u.collection_count}</TableCell>
|
||||
@@ -105,6 +120,9 @@ export default function UsersPage({ onUnauthorized }: Props) {
|
||||
variant={u.makerworld_connected ? "filled" : "outlined"}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell padding="checkbox">
|
||||
<UserActionsMenu user={u} onChanged={reload} onUnauthorized={onUnauthorized} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user