From d2936cd10bc29c11383e9982726549303b2fee10 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 2 Sep 2026 19:19:03 +0200 Subject: [PATCH 1/4] =?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) => ( From 1ad613f40302a8c86f9a15670d7c245a750e8936 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 3 Sep 2026 22:40:36 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=9A=A7=20toggle=20server=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 11 ++- server/routers/external.ts | 4 +- ...eServerAdmin.ts => adminSetServerAdmin.ts} | 68 ++++++++++----- server/routers/user/index.ts | 2 +- src/components/AdminUsersTable.tsx | 87 ++++++++++++++++--- 5 files changed, 136 insertions(+), 36 deletions(-) rename server/routers/user/{adminPromoteServerAdmin.ts => adminSetServerAdmin.ts} (55%) diff --git a/messages/en-US.json b/messages/en-US.json index 6c1f1fcf4..d3496760b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1428,11 +1428,20 @@ "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.", + "promoteServerAdminWarning": "This can be undone at any time by demoting the user from this page.", "promoteServerAdminConfirm": "Promote to server admin", "promoteServerAdminSuccess": "User promoted", "promoteServerAdminSuccessDescription": "{selectedUser} is now a server admin.", "promoteServerAdminError": "Failed to promote user", + "demoteServerAdmin": "Demote from Server admin", + "demoteServerAdminTitle": "Demote from Server Admin", + "demoteServerAdminQuestion": "Are you sure you want to demote {selectedUser} from server admin?", + "demoteServerAdminMessage": "{selectedUser} will lose full access to every organization, user, and setting on this instance.", + "demoteServerAdminWarning": "This can be undone at any time by promoting the user from this page.", + "demoteServerAdminConfirm": "Demote from server admin", + "demoteServerAdminSuccess": "User demoted", + "demoteServerAdminSuccessDescription": "{selectedUser} is no longer a server admin.", + "demoteServerAdminError": "Failed to demote 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 de0e68fe0..8e009bb0d 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1379,9 +1379,9 @@ if (build !== "saas") { ); authenticated.post( - "/user/:userId/promote-server-admin", + "/user/:userId/server-admin", verifyUserIsServerAdmin, - user.adminPromoteServerAdmin + user.adminSetServerAdmin ); authenticated.delete( diff --git a/server/routers/user/adminPromoteServerAdmin.ts b/server/routers/user/adminSetServerAdmin.ts similarity index 55% rename from server/routers/user/adminPromoteServerAdmin.ts rename to server/routers/user/adminSetServerAdmin.ts index 6c077fca6..1cc90a084 100644 --- a/server/routers/user/adminPromoteServerAdmin.ts +++ b/server/routers/user/adminSetServerAdmin.ts @@ -10,27 +10,38 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; -const promoteServerAdminParamsSchema = z.strictObject({ +const setServerAdminParamsSchema = z.strictObject({ userId: z.string() }); -export type AdminPromoteServerAdminResponse = { +const setServerAdminBodySchema = z.strictObject({ + serverAdmin: z.boolean() +}); + +export type AdminSetServerAdminResponse = { userId: string; serverAdmin: boolean; }; -const AdminPromoteServerAdminResponseDataSchema = z.object({ +const AdminSetServerAdminResponseDataSchema = 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).", + path: "/user/{userId}/server-admin", + description: "Promote or demote a user's server admin status (server admin).", tags: [OpenAPITags.User], request: { - params: promoteServerAdminParamsSchema + params: setServerAdminParamsSchema, + body: { + content: { + "application/json": { + schema: setServerAdminBodySchema + } + } + } }, responses: { 200: { @@ -38,7 +49,7 @@ registry.registerPath({ content: { "application/json": { schema: createApiResponseSchema( - AdminPromoteServerAdminResponseDataSchema + AdminSetServerAdminResponseDataSchema ) } } @@ -46,13 +57,13 @@ registry.registerPath({ } }); -export async function adminPromoteServerAdmin( +export async function adminSetServerAdmin( req: Request, res: Response, next: NextFunction ): Promise { try { - const parsedParams = promoteServerAdminParamsSchema.safeParse( + const parsedParams = setServerAdminParamsSchema.safeParse( req.params ); if (!parsedParams.success) { @@ -64,7 +75,18 @@ export async function adminPromoteServerAdmin( ); } + const parsedBody = setServerAdminBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + const { userId } = parsedParams.data; + const { serverAdmin } = parsedBody.data; const [existingUser] = await db .select({ @@ -79,32 +101,36 @@ export async function adminPromoteServerAdmin( return next(createHttpError(HttpCode.NOT_FOUND, "User not found")); } - if (existingUser.serverAdmin) { + if (!serverAdmin && req.user?.userId === userId) { return next( createHttpError( HttpCode.BAD_REQUEST, - "User is already a server admin" + "You cannot remove your own server admin status" ) ); } - logger.info( - `Promoting user ${userId} to server admin (by ${req.user?.userId})` - ); + if (existingUser.serverAdmin !== serverAdmin) { + logger.info( + `${serverAdmin ? "Promoting" : "Demoting"} user ${userId} ${serverAdmin ? "to" : "from"} server admin (by ${req.user?.userId})` + ); - await db - .update(users) - .set({ serverAdmin: true }) - .where(eq(users.userId, userId)); + await db + .update(users) + .set({ serverAdmin }) + .where(eq(users.userId, userId)); + } - return response(res, { + return response(res, { data: { userId: existingUser.userId, - serverAdmin: true + serverAdmin }, success: true, error: false, - message: "User promoted to server admin successfully", + message: serverAdmin + ? "User promoted to server admin successfully" + : "User demoted from server admin successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 732794eaf..50db85832 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -11,7 +11,7 @@ export * from "./adminListUsers"; export * from "./adminRemoveUser"; export * from "./adminGetUser"; export * from "./adminGeneratePasswordResetCode"; -export * from "./adminPromoteServerAdmin"; +export * from "./adminSetServerAdmin"; export * from "./listInvitations"; export * from "./removeInvitation"; export * from "./createOrgUser"; diff --git a/src/components/AdminUsersTable.tsx b/src/components/AdminUsersTable.tsx index ce4cff8aa..fc1192212 100644 --- a/src/components/AdminUsersTable.tsx +++ b/src/components/AdminUsersTable.tsx @@ -101,6 +101,8 @@ export default function UsersTable({ const [isGeneratingCode, setIsGeneratingCode] = useState(false); const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false); const [promoting, setPromoting] = useState(null); + const [isDemoteModalOpen, setIsDemoteModalOpen] = useState(false); + const [demoting, setDemoting] = useState(null); const user = useUserContext(); const [isRefreshing, startTransition] = useTransition(); @@ -196,17 +198,32 @@ export default function UsersTable({ } }; - const promoteToServerAdmin = async (user: GlobalUserRow) => { + const setServerAdmin = async ( + targetUser: GlobalUserRow, + serverAdmin: boolean + ) => { + const successTitleKey = serverAdmin + ? "promoteServerAdminSuccess" + : "demoteServerAdminSuccess"; + const successDescriptionKey = serverAdmin + ? "promoteServerAdminSuccessDescription" + : "demoteServerAdminSuccessDescription"; + const errorKey = serverAdmin + ? "promoteServerAdminError" + : "demoteServerAdminError"; + try { - await api.post(`/user/${user.id}/promote-server-admin`); + await api.post(`/user/${targetUser.id}/server-admin`, { + serverAdmin + }); toast({ - title: t("promoteServerAdminSuccess"), - description: t("promoteServerAdminSuccessDescription", { + title: t(successTitleKey), + description: t(successDescriptionKey, { selectedUser: getUserDisplayName({ - email: user.email, - name: user.name, - username: user.username + email: targetUser.email, + name: targetUser.name, + username: targetUser.username }) }) }); @@ -215,15 +232,17 @@ export default function UsersTable({ router.refresh(); }); } catch (e) { - console.error(t("promoteServerAdminError"), e); + console.error(t(errorKey), e); toast({ variant: "destructive", - title: t("promoteServerAdminError"), - description: formatAxiosError(e, t("promoteServerAdminError")) + title: t(errorKey), + description: formatAxiosError(e, t(errorKey)) }); } finally { setIsPromoteModalOpen(false); setPromoting(null); + setIsDemoteModalOpen(false); + setDemoting(null); } }; @@ -450,6 +469,16 @@ export default function UsersTable({ {t("promoteServerAdmin")} )} + {r.serverAdmin && r.id !== user.user.userId && ( + { + setDemoting(r); + setIsDemoteModalOpen(true); + }} + > + {t("demoteServerAdmin")} + + )} { setSelected(r); @@ -542,7 +571,7 @@ export default function UsersTable({ } buttonText={t("promoteServerAdminConfirm")} - onConfirm={async () => promoteToServerAdmin(promoting)} + onConfirm={async () => setServerAdmin(promoting, true)} string={getUserDisplayName({ email: promoting.email, name: promoting.name, @@ -553,6 +582,42 @@ export default function UsersTable({ /> )} + {demoting && ( + { + setIsDemoteModalOpen(val); + if (!val) { + setDemoting(null); + } + }} + dialog={ +
+

+ {t("demoteServerAdminQuestion", { + selectedUser: getUserDisplayName({ + email: demoting.email, + name: demoting.name, + username: demoting.username + }) + })} +

+ +

{t("demoteServerAdminMessage")}

+
+ } + buttonText={t("demoteServerAdminConfirm")} + onConfirm={async () => setServerAdmin(demoting, false)} + string={getUserDisplayName({ + email: demoting.email, + name: demoting.name, + username: demoting.username + })} + warningText={t("demoteServerAdminWarning")} + title={t("demoteServerAdminTitle")} + /> + )} + Date: Fri, 4 Sep 2026 20:40:16 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=92=AC=20update=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AdminUsersTable.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/AdminUsersTable.tsx b/src/components/AdminUsersTable.tsx index fc1192212..8bb141487 100644 --- a/src/components/AdminUsersTable.tsx +++ b/src/components/AdminUsersTable.tsx @@ -603,7 +603,15 @@ export default function UsersTable({ })}

-

{t("demoteServerAdminMessage")}

+

+ {t("demoteServerAdminMessage", { + selectedUser: getUserDisplayName({ + email: demoting.email, + name: demoting.name, + username: demoting.username + }) + })} +

} buttonText={t("demoteServerAdminConfirm")} From 0be7628a002b35f721f7424f85af4ca8f66fa847 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 4 Sep 2026 23:11:29 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/users/page.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index cb06373ab..0cfaaf3b0 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -81,9 +81,6 @@ export default async function UsersPage(props: AdminUsersPageProps) { }; }); - console.log({ - userRows - }); return ( <>