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")} + /> + )} +