From d2936cd10bc29c11383e9982726549303b2fee10 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 2 Sep 2026 19:19:03 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20server=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 9 ++ server/routers/external.ts | 6 + server/routers/user/adminListUsers.ts | 4 +- .../routers/user/adminPromoteServerAdmin.ts | 116 +++++++++++++++++ server/routers/user/index.ts | 1 + src/app/admin/users/page.tsx | 3 + src/components/AdminUsersTable.tsx | 122 +++++++++++++++++- .../command-palette/CommandPalette.tsx | 4 +- 8 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 server/routers/user/adminPromoteServerAdmin.ts diff --git a/messages/en-US.json b/messages/en-US.json index 5199aee71..6c1f1fcf4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1424,6 +1424,15 @@ "logoutError": "Error logging out", "signingAs": "Signed in as", "serverAdmin": "Server Admin", + "promoteServerAdmin": "Promote to Server admin", + "promoteServerAdminTitle": "Promote to Server Admin", + "promoteServerAdminQuestion": "Are you sure you want to promote {selectedUser} to server admin?", + "promoteServerAdminMessage": "Server admins have full access to every organization, user, and setting on this instance.", + "promoteServerAdminWarning": "You cannot demote a server admin from this page.", + "promoteServerAdminConfirm": "Promote to server admin", + "promoteServerAdminSuccess": "User promoted", + "promoteServerAdminSuccessDescription": "{selectedUser} is now a server admin.", + "promoteServerAdminError": "Failed to promote user", "managedSelfhosted": "Managed Self-Hosted", "otpEnable": "Enable Two-factor", "otpDisable": "Disable Two-factor", diff --git a/server/routers/external.ts b/server/routers/external.ts index d2935ff91..de0e68fe0 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1378,6 +1378,12 @@ if (build !== "saas") { user.adminGeneratePasswordResetCode ); + authenticated.post( + "/user/:userId/promote-server-admin", + verifyUserIsServerAdmin, + user.adminPromoteServerAdmin + ); + authenticated.delete( "/user/:userId", verifyUserIsServerAdmin, diff --git a/server/routers/user/adminListUsers.ts b/server/routers/user/adminListUsers.ts index f3c08f25b..0a5bad705 100644 --- a/server/routers/user/adminListUsers.ts +++ b/server/routers/user/adminListUsers.ts @@ -4,7 +4,7 @@ import { db, idp, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { and, asc, desc, eq, like, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -196,7 +196,7 @@ export async function adminListUsers( } } - const conditions = [eq(users.serverAdmin, false)]; + const conditions: Array | undefined> = []; if (query) { const q = "%" + query.toLowerCase() + "%"; diff --git a/server/routers/user/adminPromoteServerAdmin.ts b/server/routers/user/adminPromoteServerAdmin.ts new file mode 100644 index 000000000..6c077fca6 --- /dev/null +++ b/server/routers/user/adminPromoteServerAdmin.ts @@ -0,0 +1,116 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, users } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; + +const promoteServerAdminParamsSchema = z.strictObject({ + userId: z.string() +}); + +export type AdminPromoteServerAdminResponse = { + userId: string; + serverAdmin: boolean; +}; + +const AdminPromoteServerAdminResponseDataSchema = z.object({ + userId: z.string(), + serverAdmin: z.boolean() +}); + +registry.registerPath({ + method: "post", + path: "/user/{userId}/promote-server-admin", + description: "Promote a user to server admin (server admin).", + tags: [OpenAPITags.User], + request: { + params: promoteServerAdminParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: createApiResponseSchema( + AdminPromoteServerAdminResponseDataSchema + ) + } + } + } + } +}); + +export async function adminPromoteServerAdmin( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = promoteServerAdminParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId } = parsedParams.data; + + const [existingUser] = await db + .select({ + userId: users.userId, + serverAdmin: users.serverAdmin + }) + .from(users) + .where(eq(users.userId, userId)) + .limit(1); + + if (!existingUser) { + return next(createHttpError(HttpCode.NOT_FOUND, "User not found")); + } + + if (existingUser.serverAdmin) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User is already a server admin" + ) + ); + } + + logger.info( + `Promoting user ${userId} to server admin (by ${req.user?.userId})` + ); + + await db + .update(users) + .set({ serverAdmin: true }) + .where(eq(users.userId, userId)); + + return response(res, { + data: { + userId: existingUser.userId, + serverAdmin: true + }, + success: true, + error: false, + message: "User promoted to server admin successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 690a013f6..732794eaf 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -11,6 +11,7 @@ export * from "./adminListUsers"; export * from "./adminRemoveUser"; export * from "./adminGetUser"; export * from "./adminGeneratePasswordResetCode"; +export * from "./adminPromoteServerAdmin"; export * from "./listInvitations"; export * from "./removeInvitation"; export * from "./createOrgUser"; diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 0cfaaf3b0..cb06373ab 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -81,6 +81,9 @@ export default async function UsersPage(props: AdminUsersPageProps) { }; }); + console.log({ + userRows + }); return ( <> (null); const [isGeneratingCode, setIsGeneratingCode] = useState(false); + const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false); + const [promoting, setPromoting] = useState(null); + const user = useUserContext(); const [isRefreshing, startTransition] = useTransition(); const { @@ -184,6 +196,37 @@ export default function UsersTable({ } }; + const promoteToServerAdmin = async (user: GlobalUserRow) => { + try { + await api.post(`/user/${user.id}/promote-server-admin`); + + toast({ + title: t("promoteServerAdminSuccess"), + description: t("promoteServerAdminSuccessDescription", { + selectedUser: getUserDisplayName({ + email: user.email, + name: user.name, + username: user.username + }) + }) + }); + + startTransition(() => { + router.refresh(); + }); + } catch (e) { + console.error(t("promoteServerAdminError"), e); + toast({ + variant: "destructive", + title: t("promoteServerAdminError"), + description: formatAxiosError(e, t("promoteServerAdminError")) + }); + } finally { + setIsPromoteModalOpen(false); + setPromoting(null); + } + }; + function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); filter({ @@ -235,7 +278,35 @@ export default function UsersTable({ ); - } + }, + cell: ({ row }) => ( + + {row.original.username}{" "} + {row.original.id === user.user.userId && ( + <> + + · + {" "} + you + + )} + {row.original.serverAdmin && ( + <> + + + + + + + {t("serverAdmin")} + + + + {/* {t("serverAdmin")} */} + + )} + + ) }, { accessorKey: "email", @@ -369,11 +440,22 @@ export default function UsersTable({ {t("generatePasswordResetCode")} )} + {!r.serverAdmin && ( + { + setPromoting(r); + setIsPromoteModalOpen(true); + }} + > + {t("promoteServerAdmin")} + + )} { setSelected(r); setIsDeleteModalOpen(true); }} + className="text-red-400" > {t("delete")} @@ -435,6 +517,42 @@ export default function UsersTable({ /> )} + {promoting && ( + { + setIsPromoteModalOpen(val); + if (!val) { + setPromoting(null); + } + }} + dialog={ +
+

+ {t("promoteServerAdminQuestion", { + selectedUser: getUserDisplayName({ + email: promoting.email, + name: promoting.name, + username: promoting.username + }) + })} +

+ +

{t("promoteServerAdminMessage")}

+
+ } + buttonText={t("promoteServerAdminConfirm")} + onConfirm={async () => promoteToServerAdmin(promoting)} + string={getUserDisplayName({ + email: promoting.email, + name: promoting.name, + username: promoting.username + })} + warningText={t("promoteServerAdminWarning")} + title={t("promoteServerAdminTitle")} + /> + )} + - {t("commandPaletteNoResults")} - + {t("commandPaletteNoResults")} + {!isActionMode && navigationGroups.map((group, groupIndex) => (