diff --git a/messages/en-US.json b/messages/en-US.json index 09c440c2e..c6c8f6820 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1426,6 +1426,24 @@ "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": "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 67197d1ca..33d1fea6c 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1384,6 +1384,12 @@ if (build !== "saas") { user.adminGeneratePasswordResetCode ); + authenticated.post( + "/user/:userId/server-admin", + verifyUserIsServerAdmin, + user.adminSetServerAdmin + ); + 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/adminSetServerAdmin.ts b/server/routers/user/adminSetServerAdmin.ts new file mode 100644 index 000000000..1cc90a084 --- /dev/null +++ b/server/routers/user/adminSetServerAdmin.ts @@ -0,0 +1,142 @@ +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 setServerAdminParamsSchema = z.strictObject({ + userId: z.string() +}); + +const setServerAdminBodySchema = z.strictObject({ + serverAdmin: z.boolean() +}); + +export type AdminSetServerAdminResponse = { + userId: string; + serverAdmin: boolean; +}; + +const AdminSetServerAdminResponseDataSchema = z.object({ + userId: z.string(), + serverAdmin: z.boolean() +}); + +registry.registerPath({ + method: "post", + path: "/user/{userId}/server-admin", + description: "Promote or demote a user's server admin status (server admin).", + tags: [OpenAPITags.User], + request: { + params: setServerAdminParamsSchema, + body: { + content: { + "application/json": { + schema: setServerAdminBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: createApiResponseSchema( + AdminSetServerAdminResponseDataSchema + ) + } + } + } + } +}); + +export async function adminSetServerAdmin( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = setServerAdminParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + 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({ + 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 (!serverAdmin && req.user?.userId === userId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "You cannot remove your own server admin status" + ) + ); + } + + 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 }) + .where(eq(users.userId, userId)); + } + + return response(res, { + data: { + userId: existingUser.userId, + serverAdmin + }, + success: true, + error: false, + message: serverAdmin + ? "User promoted to server admin successfully" + : "User demoted from 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..50db85832 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 "./adminSetServerAdmin"; export * from "./listInvitations"; export * from "./removeInvitation"; export * from "./createOrgUser"; diff --git a/src/components/AdminUsersTable.tsx b/src/components/AdminUsersTable.tsx index eabb6b468..8bb141487 100644 --- a/src/components/AdminUsersTable.tsx +++ b/src/components/AdminUsersTable.tsx @@ -19,7 +19,8 @@ import { ArrowRight, ArrowUp10Icon, ChevronsUpDownIcon, - MoreHorizontal + MoreHorizontal, + ShieldUserIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; @@ -43,6 +44,14 @@ import { CredenzaClose } from "@app/components/Credenza"; import CopyToClipboard from "@app/components/CopyToClipboard"; +import { Badge } from "./ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "./ui/tooltip"; +import { useUserContext } from "@app/hooks/useUserContext"; export type GlobalUserRow = { id: string; @@ -90,6 +99,11 @@ export default function UsersTable({ const [passwordResetCodeData, setPasswordResetCodeData] = useState(null); 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(); const { @@ -184,6 +198,54 @@ export default function UsersTable({ } }; + 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/${targetUser.id}/server-admin`, { + serverAdmin + }); + + toast({ + title: t(successTitleKey), + description: t(successDescriptionKey, { + selectedUser: getUserDisplayName({ + email: targetUser.email, + name: targetUser.name, + username: targetUser.username + }) + }) + }); + + startTransition(() => { + router.refresh(); + }); + } catch (e) { + console.error(t(errorKey), e); + toast({ + variant: "destructive", + title: t(errorKey), + description: formatAxiosError(e, t(errorKey)) + }); + } finally { + setIsPromoteModalOpen(false); + setPromoting(null); + setIsDemoteModalOpen(false); + setDemoting(null); + } + }; + function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); filter({ @@ -235,7 +297,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 +459,32 @@ export default function UsersTable({ {t("generatePasswordResetCode")} )} + {!r.serverAdmin && ( + { + setPromoting(r); + setIsPromoteModalOpen(true); + }} + > + {t("promoteServerAdmin")} + + )} + {r.serverAdmin && r.id !== user.user.userId && ( + { + setDemoting(r); + setIsDemoteModalOpen(true); + }} + > + {t("demoteServerAdmin")} + + )} { setSelected(r); setIsDeleteModalOpen(true); }} + className="text-red-400" > {t("delete")} @@ -435,6 +546,86 @@ 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 () => setServerAdmin(promoting, true)} + string={getUserDisplayName({ + email: promoting.email, + name: promoting.name, + username: promoting.username + })} + warningText={t("promoteServerAdminWarning")} + title={t("promoteServerAdminTitle")} + /> + )} + + {demoting && ( + { + setIsDemoteModalOpen(val); + if (!val) { + setDemoting(null); + } + }} + dialog={ +
+

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

+ +

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

+
+ } + 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")} + /> + )} + - {t("commandPaletteNoResults")} - + {t("commandPaletteNoResults")} + {!isActionMode && navigationGroups.map((group, groupIndex) => (