🚧 toggle server admin

This commit is contained in:
Fred KISSIE
2026-09-03 22:40:36 +02:00
parent d2936cd10b
commit 1ad613f403
5 changed files with 136 additions and 36 deletions
+10 -1
View File
@@ -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",
+2 -2
View File
@@ -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(
@@ -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<any> {
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<AdminPromoteServerAdminResponse>(res, {
return response<AdminSetServerAdminResponse>(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) {
+1 -1
View File
@@ -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";
+76 -11
View File
@@ -101,6 +101,8 @@ export default function UsersTable({
const [isGeneratingCode, setIsGeneratingCode] = useState(false);
const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false);
const [promoting, setPromoting] = useState<GlobalUserRow | null>(null);
const [isDemoteModalOpen, setIsDemoteModalOpen] = useState(false);
const [demoting, setDemoting] = useState<GlobalUserRow | null>(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")}
</DropdownMenuItem>
)}
{r.serverAdmin && r.id !== user.user.userId && (
<DropdownMenuItem
onClick={() => {
setDemoting(r);
setIsDemoteModalOpen(true);
}}
>
{t("demoteServerAdmin")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setSelected(r);
@@ -542,7 +571,7 @@ export default function UsersTable({
</div>
}
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 && (
<ConfirmDeleteDialog
open={isDemoteModalOpen}
setOpen={(val) => {
setIsDemoteModalOpen(val);
if (!val) {
setDemoting(null);
}
}}
dialog={
<div className="space-y-2">
<p>
{t("demoteServerAdminQuestion", {
selectedUser: getUserDisplayName({
email: demoting.email,
name: demoting.name,
username: demoting.username
})
})}
</p>
<p>{t("demoteServerAdminMessage")}</p>
</div>
}
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")}
/>
)}
<ControlledDataTable
columns={columns}
rows={users}