🚧 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", "promoteServerAdminTitle": "Promote to Server Admin",
"promoteServerAdminQuestion": "Are you sure you want to promote {selectedUser} 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.", "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", "promoteServerAdminConfirm": "Promote to server admin",
"promoteServerAdminSuccess": "User promoted", "promoteServerAdminSuccess": "User promoted",
"promoteServerAdminSuccessDescription": "{selectedUser} is now a server admin.", "promoteServerAdminSuccessDescription": "{selectedUser} is now a server admin.",
"promoteServerAdminError": "Failed to promote user", "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", "managedSelfhosted": "Managed Self-Hosted",
"otpEnable": "Enable Two-factor", "otpEnable": "Enable Two-factor",
"otpDisable": "Disable Two-factor", "otpDisable": "Disable Two-factor",
+2 -2
View File
@@ -1379,9 +1379,9 @@ if (build !== "saas") {
); );
authenticated.post( authenticated.post(
"/user/:userId/promote-server-admin", "/user/:userId/server-admin",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
user.adminPromoteServerAdmin user.adminSetServerAdmin
); );
authenticated.delete( authenticated.delete(
@@ -10,27 +10,38 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
const promoteServerAdminParamsSchema = z.strictObject({ const setServerAdminParamsSchema = z.strictObject({
userId: z.string() userId: z.string()
}); });
export type AdminPromoteServerAdminResponse = { const setServerAdminBodySchema = z.strictObject({
serverAdmin: z.boolean()
});
export type AdminSetServerAdminResponse = {
userId: string; userId: string;
serverAdmin: boolean; serverAdmin: boolean;
}; };
const AdminPromoteServerAdminResponseDataSchema = z.object({ const AdminSetServerAdminResponseDataSchema = z.object({
userId: z.string(), userId: z.string(),
serverAdmin: z.boolean() serverAdmin: z.boolean()
}); });
registry.registerPath({ registry.registerPath({
method: "post", method: "post",
path: "/user/{userId}/promote-server-admin", path: "/user/{userId}/server-admin",
description: "Promote a user to server admin (server admin).", description: "Promote or demote a user's server admin status (server admin).",
tags: [OpenAPITags.User], tags: [OpenAPITags.User],
request: { request: {
params: promoteServerAdminParamsSchema params: setServerAdminParamsSchema,
body: {
content: {
"application/json": {
schema: setServerAdminBodySchema
}
}
}
}, },
responses: { responses: {
200: { 200: {
@@ -38,7 +49,7 @@ registry.registerPath({
content: { content: {
"application/json": { "application/json": {
schema: createApiResponseSchema( schema: createApiResponseSchema(
AdminPromoteServerAdminResponseDataSchema AdminSetServerAdminResponseDataSchema
) )
} }
} }
@@ -46,13 +57,13 @@ registry.registerPath({
} }
}); });
export async function adminPromoteServerAdmin( export async function adminSetServerAdmin(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
const parsedParams = promoteServerAdminParamsSchema.safeParse( const parsedParams = setServerAdminParamsSchema.safeParse(
req.params req.params
); );
if (!parsedParams.success) { 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 { userId } = parsedParams.data;
const { serverAdmin } = parsedBody.data;
const [existingUser] = await db const [existingUser] = await db
.select({ .select({
@@ -79,32 +101,36 @@ export async function adminPromoteServerAdmin(
return next(createHttpError(HttpCode.NOT_FOUND, "User not found")); return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
} }
if (existingUser.serverAdmin) { if (!serverAdmin && req.user?.userId === userId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
"User is already a server admin" "You cannot remove your own server admin status"
) )
); );
} }
logger.info( if (existingUser.serverAdmin !== serverAdmin) {
`Promoting user ${userId} to server admin (by ${req.user?.userId})` logger.info(
); `${serverAdmin ? "Promoting" : "Demoting"} user ${userId} ${serverAdmin ? "to" : "from"} server admin (by ${req.user?.userId})`
);
await db await db
.update(users) .update(users)
.set({ serverAdmin: true }) .set({ serverAdmin })
.where(eq(users.userId, userId)); .where(eq(users.userId, userId));
}
return response<AdminPromoteServerAdminResponse>(res, { return response<AdminSetServerAdminResponse>(res, {
data: { data: {
userId: existingUser.userId, userId: existingUser.userId,
serverAdmin: true serverAdmin
}, },
success: true, success: true,
error: false, 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 status: HttpCode.OK
}); });
} catch (error) { } catch (error) {
+1 -1
View File
@@ -11,7 +11,7 @@ export * from "./adminListUsers";
export * from "./adminRemoveUser"; export * from "./adminRemoveUser";
export * from "./adminGetUser"; export * from "./adminGetUser";
export * from "./adminGeneratePasswordResetCode"; export * from "./adminGeneratePasswordResetCode";
export * from "./adminPromoteServerAdmin"; export * from "./adminSetServerAdmin";
export * from "./listInvitations"; export * from "./listInvitations";
export * from "./removeInvitation"; export * from "./removeInvitation";
export * from "./createOrgUser"; export * from "./createOrgUser";
+76 -11
View File
@@ -101,6 +101,8 @@ export default function UsersTable({
const [isGeneratingCode, setIsGeneratingCode] = useState(false); const [isGeneratingCode, setIsGeneratingCode] = useState(false);
const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false); const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false);
const [promoting, setPromoting] = useState<GlobalUserRow | null>(null); const [promoting, setPromoting] = useState<GlobalUserRow | null>(null);
const [isDemoteModalOpen, setIsDemoteModalOpen] = useState(false);
const [demoting, setDemoting] = useState<GlobalUserRow | null>(null);
const user = useUserContext(); const user = useUserContext();
const [isRefreshing, startTransition] = useTransition(); 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 { try {
await api.post(`/user/${user.id}/promote-server-admin`); await api.post(`/user/${targetUser.id}/server-admin`, {
serverAdmin
});
toast({ toast({
title: t("promoteServerAdminSuccess"), title: t(successTitleKey),
description: t("promoteServerAdminSuccessDescription", { description: t(successDescriptionKey, {
selectedUser: getUserDisplayName({ selectedUser: getUserDisplayName({
email: user.email, email: targetUser.email,
name: user.name, name: targetUser.name,
username: user.username username: targetUser.username
}) })
}) })
}); });
@@ -215,15 +232,17 @@ export default function UsersTable({
router.refresh(); router.refresh();
}); });
} catch (e) { } catch (e) {
console.error(t("promoteServerAdminError"), e); console.error(t(errorKey), e);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("promoteServerAdminError"), title: t(errorKey),
description: formatAxiosError(e, t("promoteServerAdminError")) description: formatAxiosError(e, t(errorKey))
}); });
} finally { } finally {
setIsPromoteModalOpen(false); setIsPromoteModalOpen(false);
setPromoting(null); setPromoting(null);
setIsDemoteModalOpen(false);
setDemoting(null);
} }
}; };
@@ -450,6 +469,16 @@ export default function UsersTable({
{t("promoteServerAdmin")} {t("promoteServerAdmin")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{r.serverAdmin && r.id !== user.user.userId && (
<DropdownMenuItem
onClick={() => {
setDemoting(r);
setIsDemoteModalOpen(true);
}}
>
{t("demoteServerAdmin")}
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setSelected(r); setSelected(r);
@@ -542,7 +571,7 @@ export default function UsersTable({
</div> </div>
} }
buttonText={t("promoteServerAdminConfirm")} buttonText={t("promoteServerAdminConfirm")}
onConfirm={async () => promoteToServerAdmin(promoting)} onConfirm={async () => setServerAdmin(promoting, true)}
string={getUserDisplayName({ string={getUserDisplayName({
email: promoting.email, email: promoting.email,
name: promoting.name, 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 <ControlledDataTable
columns={columns} columns={columns}
rows={users} rows={users}