diff --git a/backend/prisma/migrations/20261015150000_user_owner/migration.sql b/backend/prisma/migrations/20261015150000_user_owner/migration.sql new file mode 100644 index 0000000..229fa99 --- /dev/null +++ b/backend/prisma/migrations/20261015150000_user_owner/migration.sql @@ -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); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 42268a4..d59a77f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/auth.ts b/backend/src/auth.ts index bcc9ba9..8afc11d 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -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(); }); diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 9501e48..f0fc701 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -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", diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 143b17a..000844b 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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, diff --git a/backend/src/services/adminService.ts b/backend/src/services/adminService.ts index d36cad6..a7072b8 100644 --- a/backend/src/services/adminService.ts +++ b/backend/src/services/adminService.ts @@ -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 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 email: u.email, displayName: u.displayName, role: u.role, + isOwner: u.isOwner, createdAt: u.createdAt, printCount: u._count.prints, collectionCount: u._count.collections, diff --git a/backend/src/services/auditLog.ts b/backend/src/services/auditLog.ts index c23da33..419b542 100644 --- a/backend/src/services/auditLog.ts +++ b/backend/src/services/auditLog.ts @@ -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: { diff --git a/backend/src/services/userAdminService.ts b/backend/src/services/userAdminService.ts new file mode 100644 index 0000000..93c7f62 --- /dev/null +++ b/backend/src/services/userAdminService.ts @@ -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 { + 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 { + 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-; 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 }; +} diff --git a/backend/tests/admin.test.ts b/backend/tests/admin.test.ts index 3165267..167b72f 100644 --- a/backend/tests/admin.test.ts +++ b/backend/tests/admin.test.ts @@ -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; diff --git a/backend/tests/userAdmin.test.ts b/backend/tests/userAdmin.test.ts new file mode 100644 index 0000000..90de7c5 --- /dev/null +++ b/backend/tests/userAdmin.test.ts @@ -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 { + 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 } }); + } + } + }); +}); diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 4e433e4..974f52d 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -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 => { + 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 => { const res = await fetch(`${apiBase()}/admin/triggers/link-authors`, { headers: authHeaders() }); if (res.status === 401) throw new UnauthorizedError(); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e815e42..2b89fae 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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)" }, diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 45733fd..e603b71 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -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)" }, diff --git a/frontend/src/pages/LogsPage/index.tsx b/frontend/src/pages/LogsPage/index.tsx index ecc7c5a..aa172b1 100644 --- a/frontend/src/pages/LogsPage/index.tsx +++ b/frontend/src/pages/LogsPage/index.tsx @@ -53,6 +53,8 @@ const ACTION_COLORS: Record = { 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 : "", diff --git a/frontend/src/pages/UsersPage/DeleteUserDialog.tsx b/frontend/src/pages/UsersPage/DeleteUserDialog.tsx new file mode 100644 index 0000000..3fd7e32 --- /dev/null +++ b/frontend/src/pages/UsersPage/DeleteUserDialog.tsx @@ -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(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 ( + + {t("adminSettings.users.deleteTitle", { name: user.display_name })} + + + {t("adminSettings.users.deleteWarning", { count: user.print_count })} + + + {t("adminSettings.users.deleteTypeEmail", { email: user.email })} + + 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 && ( + + {error} + + )} + + + + + + + ); +} diff --git a/frontend/src/pages/UsersPage/UserActionsMenu.tsx b/frontend/src/pages/UsersPage/UserActionsMenu.tsx new file mode 100644 index 0000000..7d69e0e --- /dev/null +++ b/frontend/src/pages/UsersPage/UserActionsMenu.tsx @@ -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(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 ( + <> + + setAnchor(event.currentTarget)} + > + + + + setAnchor(null)}> + {user.can_promote && ( + void changeRole("ADMIN")}> + + + + {t("adminSettings.users.promote")} + + )} + {user.can_demote && ( + void changeRole("MEMBER")}> + + + + {t("adminSettings.users.demote")} + + )} + {user.can_delete && ( + { + setAnchor(null); + setDeleting(true); + }} + sx={{ color: "error.main" }} + > + + + + {t("adminSettings.users.delete")} + + )} + + {deleting && ( + setDeleting(false)} + onDeleted={() => { + setDeleting(false); + onChanged(); + }} + onUnauthorized={onUnauthorized} + /> + )} + + ); +} diff --git a/frontend/src/pages/UsersPage/index.tsx b/frontend/src/pages/UsersPage/index.tsx index b415783..ab611a6 100644 --- a/frontend/src/pages/UsersPage/index.tsx +++ b/frontend/src/pages/UsersPage/index.tsx @@ -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([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(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) { {t("adminSettings.users.columnModels")} {t("adminSettings.users.columnCollections")} {t("adminSettings.users.columnMakerworld")} + {loading ? ( - + ) : ( users.map((u) => ( @@ -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 && ( + + )} {u.print_count} {u.collection_count} @@ -105,6 +120,9 @@ export default function UsersPage({ onUnauthorized }: Props) { variant={u.makerworld_connected ? "filled" : "outlined"} /> + + + )) )}