Compare commits

...

2 Commits

Author SHA1 Message Date
Fred KISSIE 1ad613f403 🚧 toggle server admin 2026-09-03 22:40:36 +02:00
Fred KISSIE d2936cd10b 🚧 server admin 2026-09-02 19:19:03 +02:00
8 changed files with 359 additions and 6 deletions
+18
View File
@@ -1424,6 +1424,24 @@
"logoutError": "Error logging out", "logoutError": "Error logging out",
"signingAs": "Signed in as", "signingAs": "Signed in as",
"serverAdmin": "Server Admin", "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", "managedSelfhosted": "Managed Self-Hosted",
"otpEnable": "Enable Two-factor", "otpEnable": "Enable Two-factor",
"otpDisable": "Disable Two-factor", "otpDisable": "Disable Two-factor",
+6
View File
@@ -1378,6 +1378,12 @@ if (build !== "saas") {
user.adminGeneratePasswordResetCode user.adminGeneratePasswordResetCode
); );
authenticated.post(
"/user/:userId/server-admin",
verifyUserIsServerAdmin,
user.adminSetServerAdmin
);
authenticated.delete( authenticated.delete(
"/user/:userId", "/user/:userId",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
+2 -2
View File
@@ -4,7 +4,7 @@ import { db, idp, users } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; 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 logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -196,7 +196,7 @@ export async function adminListUsers(
} }
} }
const conditions = [eq(users.serverAdmin, false)]; const conditions: Array<SQL<unknown> | undefined> = [];
if (query) { if (query) {
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
+142
View File
@@ -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<any> {
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<AdminSetServerAdminResponse>(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")
);
}
}
+1
View File
@@ -11,6 +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 "./adminSetServerAdmin";
export * from "./listInvitations"; export * from "./listInvitations";
export * from "./removeInvitation"; export * from "./removeInvitation";
export * from "./createOrgUser"; export * from "./createOrgUser";
+3
View File
@@ -81,6 +81,9 @@ export default async function UsersPage(props: AdminUsersPageProps) {
}; };
}); });
console.log({
userRows
});
return ( return (
<> <>
<SettingsSectionTitle <SettingsSectionTitle
+185 -2
View File
@@ -19,7 +19,8 @@ import {
ArrowRight, ArrowRight,
ArrowUp10Icon, ArrowUp10Icon,
ChevronsUpDownIcon, ChevronsUpDownIcon,
MoreHorizontal MoreHorizontal,
ShieldUserIcon
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -43,6 +44,14 @@ import {
CredenzaClose CredenzaClose
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import CopyToClipboard from "@app/components/CopyToClipboard"; 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 = { export type GlobalUserRow = {
id: string; id: string;
@@ -90,6 +99,11 @@ export default function UsersTable({
const [passwordResetCodeData, setPasswordResetCodeData] = const [passwordResetCodeData, setPasswordResetCodeData] =
useState<AdminGeneratePasswordResetCodeResponse | null>(null); useState<AdminGeneratePasswordResetCodeResponse | null>(null);
const [isGeneratingCode, setIsGeneratingCode] = useState(false); 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(); const [isRefreshing, startTransition] = useTransition();
const { 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) { function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams); const newSearch = getNextSortOrder(column, searchParams);
filter({ filter({
@@ -235,7 +297,35 @@ export default function UsersTable({
<Icon className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
} },
cell: ({ row }) => (
<span className="inline-flex gap-1 items-center">
{row.original.username}{" "}
{row.original.id === user.user.userId && (
<>
<span className="text-muted-foreground">
&middot;
</span>{" "}
<span className="text-primary">you</span>
</>
)}
{row.original.serverAdmin && (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<ShieldUserIcon className="text-primary size-5 flex-none" />
</TooltipTrigger>
<TooltipContent>
{t("serverAdmin")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* <Badge>{t("serverAdmin")}</Badge> */}
</>
)}
</span>
)
}, },
{ {
accessorKey: "email", accessorKey: "email",
@@ -369,11 +459,32 @@ export default function UsersTable({
{t("generatePasswordResetCode")} {t("generatePasswordResetCode")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{!r.serverAdmin && (
<DropdownMenuItem
onClick={() => {
setPromoting(r);
setIsPromoteModalOpen(true);
}}
>
{t("promoteServerAdmin")}
</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);
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
}} }}
className="text-red-400"
> >
{t("delete")} {t("delete")}
</DropdownMenuItem> </DropdownMenuItem>
@@ -435,6 +546,78 @@ export default function UsersTable({
/> />
)} )}
{promoting && (
<ConfirmDeleteDialog
open={isPromoteModalOpen}
setOpen={(val) => {
setIsPromoteModalOpen(val);
if (!val) {
setPromoting(null);
}
}}
dialog={
<div className="space-y-2">
<p>
{t("promoteServerAdminQuestion", {
selectedUser: getUserDisplayName({
email: promoting.email,
name: promoting.name,
username: promoting.username
})
})}
</p>
<p>{t("promoteServerAdminMessage")}</p>
</div>
}
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 && (
<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}
@@ -155,13 +155,13 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
} }
/> />
<CommandList className="max-h-118 min-h-0 h-(--cmdk-list-height) scroll-pb-4 scroll-pt-2 transition-[height] duration-250 ease-in-out"> <CommandList className="max-h-118 min-h-0 h-(--cmdk-list-height) scroll-pb-4 scroll-pt-2 transition-[height] duration-250 ease-in-out">
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
<CommandGroup <CommandGroup
heading={t("commandActionModeInfo")} heading={t("commandActionModeInfo")}
className="[&_[cmdk-group-heading]]:text-sm" className="[&_[cmdk-group-heading]]:text-sm"
/> />
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
{!isActionMode && {!isActionMode &&
navigationGroups.map((group, groupIndex) => ( navigationGroups.map((group, groupIndex) => (
<React.Fragment key={group.heading}> <React.Fragment key={group.heading}>