allow editing self and owner user roles

This commit is contained in:
miloschwartz
2026-05-08 17:48:26 -07:00
parent 88d8414eb8
commit 9fb677e952
10 changed files with 153 additions and 80 deletions

View File

@@ -523,6 +523,12 @@
"userMessageOrgRemove": "Once removed, this user will no longer have access to the organization. You can always re-invite them later, but they will need to accept the invitation again.", "userMessageOrgRemove": "Once removed, this user will no longer have access to the organization. You can always re-invite them later, but they will need to accept the invitation again.",
"userRemoveOrgConfirm": "Confirm Remove User", "userRemoveOrgConfirm": "Confirm Remove User",
"userRemoveOrg": "Remove User from Organization", "userRemoveOrg": "Remove User from Organization",
"userQuestionOrgRemoveSelf": "Are you sure you want to remove yourself from this organization?",
"userMessageOrgRemoveSelf": "You will lose access immediately. An administrator can invite you again later, but you will need to accept a new invitation.",
"userRemoveOrgConfirmSelf": "Confirm Remove Myself",
"userRemoveOrgSelf": "Remove yourself from the organization",
"userRemoveOrgSelfWarning": "You will lose access to this organization immediately.",
"userRemoveOrgConfirmPhraseSelf": "REMOVE MYSELF FROM ORG",
"users": "Users", "users": "Users",
"accessRoleMember": "Member", "accessRoleMember": "Member",
"accessRoleOwner": "Owner", "accessRoleOwner": "Owner",
@@ -531,6 +537,11 @@
"emailInvalid": "Invalid email address", "emailInvalid": "Invalid email address",
"inviteValidityDuration": "Please select a duration", "inviteValidityDuration": "Please select a duration",
"accessRoleSelectPlease": "Please select a role", "accessRoleSelectPlease": "Please select a role",
"removeOwnAdminRoleConfirmTitle": "Remove your administrator access?",
"removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.",
"removeOwnAdminRoleConfirmButton": "Remove My Administrator Access",
"removeOwnAdminRoleConfirmPhrase": "REMOVE MY ADMIN ACCESS",
"ownerMustRetainAdminRole": "The organization owner must keep at least one administrator role.",
"usernameRequired": "Username is required", "usernameRequired": "Username is required",
"idpSelectPlease": "Please select an identity provider", "idpSelectPlease": "Please select an identity provider",
"idpGenericOidc": "Generic OAuth2/OIDC provider.", "idpGenericOidc": "Generic OAuth2/OIDC provider.",

View File

@@ -98,15 +98,6 @@ export async function addUserRole(
); );
} }
if (existingUser[0].isOwner) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Cannot change the role of the owner of the organization"
)
);
}
const roleExists = await db const roleExists = await db
.select() .select()
.from(roles) .from(roles)

View File

@@ -98,11 +98,11 @@ export async function removeUserRole(
); );
} }
if (existingUser.isOwner) { if (existingUser.isOwner && role.isAdmin === true) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Cannot change the roles of the owner of the organization" "Cannot remove the administrator role from the organization owner"
) )
); );
} }

View File

@@ -87,17 +87,8 @@ export async function setUserOrgRoles(
); );
} }
if (existingUser.isOwner) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Cannot change the roles of the owner of the organization"
)
);
}
const orgRoles = await db const orgRoles = await db
.select({ roleId: roles.roleId }) .select({ roleId: roles.roleId, isAdmin: roles.isAdmin })
.from(roles) .from(roles)
.where( .where(
and( and(
@@ -115,6 +106,18 @@ export async function setUserOrgRoles(
); );
} }
if (existingUser.isOwner) {
const hasAdminRole = orgRoles.some((r) => r.isAdmin === true);
if (!hasAdminRole) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"The organization owner must retain an administrator role"
)
);
}
}
let orgClientsToRebuild: Client[] = []; let orgClientsToRebuild: Client[] = [];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx await trx

View File

@@ -88,11 +88,11 @@ export async function addUserRoleLegacy(
); );
} }
if (existingUser.isOwner) { if (existingUser.isOwner && role.isAdmin !== true) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Cannot change the role of the owner of the organization" "The organization owner must retain an administrator role"
) )
); );
} }

View File

@@ -47,10 +47,7 @@ export async function queryUser(orgId: string, userId: string) {
.from(userOrgRoles) .from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where( .where(
and( and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId))
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
); );
const isAdmin = roleRows.some((r) => r.isAdmin); const isAdmin = roleRows.some((r) => r.isAdmin);
@@ -61,7 +58,8 @@ export async function queryUser(orgId: string, userId: string) {
roleIds: roleRows.map((r) => r.roleId), roleIds: roleRows.map((r) => r.roleId),
roles: roleRows.map((r) => ({ roles: roleRows.map((r) => ({
roleId: r.roleId, roleId: r.roleId,
name: r.roleName ?? "" name: r.roleName ?? "",
isAdmin: r.isAdmin === true
})) }))
}; };
} }

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import IdpTypeBadge from "@app/components/IdpTypeBadge"; import IdpTypeBadge from "@app/components/IdpTypeBadge";
import OrgRolesTagField from "@app/components/OrgRolesTagField"; import OrgRolesTagField from "@app/components/OrgRolesTagField";
import { import {
@@ -25,6 +26,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useUserContext } from "@app/hooks/useUserContext";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -32,7 +34,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useActionState, useEffect } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -42,13 +44,15 @@ const accessControlsFormSchema = z.object({
roles: z.array( roles: z.array(
z.object({ z.object({
id: z.string(), id: z.string(),
text: z.string() text: z.string(),
isAdmin: z.boolean().optional()
}) })
) )
}); });
export default function AccessControlsPage() { export default function AccessControlsPage() {
const { orgUser: user, updateOrgUser } = userOrgUserContext(); const { orgUser: user, updateOrgUser } = userOrgUserContext();
const { user: sessionUser } = useUserContext();
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
@@ -72,7 +76,8 @@ export default function AccessControlsPage() {
autoProvisioned: user.autoProvisioned || false, autoProvisioned: user.autoProvisioned || false,
roles: (user.roles ?? []).map((r) => ({ roles: (user.roles ?? []).map((r) => ({
id: r.roleId.toString(), id: r.roleId.toString(),
text: r.name text: r.name,
isAdmin: r.isAdmin === true
})) }))
} }
}); });
@@ -84,7 +89,8 @@ export default function AccessControlsPage() {
"roles", "roles",
(user.roles ?? []).map((r) => ({ (user.roles ?? []).map((r) => ({
id: r.roleId.toString(), id: r.roleId.toString(),
text: r.name text: r.name,
isAdmin: r.isAdmin === true
})) }))
); );
form.setValue("autoProvisioned", user.autoProvisioned || false); form.setValue("autoProvisioned", user.autoProvisioned || false);
@@ -95,11 +101,11 @@ export default function AccessControlsPage() {
? t("singleRolePerUserPlanNotice") ? t("singleRolePerUserPlanNotice")
: t("singleRolePerUserEditionNotice"); : t("singleRolePerUserEditionNotice");
const [, action, isSubmitting] = useActionState(onSubmit, null); const [isSaving, setIsSaving] = useState(false);
async function onSubmit() { const [confirmRemoveOwnAdminOpen, setConfirmRemoveOwnAdminOpen] =
const isValid = await form.trigger(); useState(false);
if (!isValid) return;
async function executeSave() {
const values = form.getValues(); const values = form.getValues();
if (values.roles.length === 0) { if (values.roles.length === 0) {
@@ -111,6 +117,7 @@ export default function AccessControlsPage() {
return; return;
} }
setIsSaving(true);
try { try {
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const updateRoleRequest = supportsMultipleRolesPerUser const updateRoleRequest = supportsMultipleRolesPerUser
@@ -130,7 +137,8 @@ export default function AccessControlsPage() {
roleIds, roleIds,
roles: values.roles.map((r) => ({ roles: values.roles.map((r) => ({
roleId: parseInt(r.id, 10), roleId: parseInt(r.id, 10),
name: r.text name: r.text,
isAdmin: r.isAdmin === true
})), })),
autoProvisioned: values.autoProvisioned autoProvisioned: values.autoProvisioned
}); });
@@ -149,11 +157,61 @@ export default function AccessControlsPage() {
t("accessRoleErrorAddDescription") t("accessRoleErrorAddDescription")
) )
}); });
} finally {
setIsSaving(false);
} }
} }
async function handleAccessControlsSubmit(e: React.FormEvent) {
e.preventDefault();
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
if (values.roles.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: t("accessRoleSelectPlease")
});
return;
}
const willHaveAdminRole = values.roles.some(
(r) => r.isAdmin === true
);
const isRemovingOwnAdmin =
sessionUser.userId === user.userId &&
user.isAdmin &&
!willHaveAdminRole;
if (isRemovingOwnAdmin) {
setConfirmRemoveOwnAdminOpen(true);
return;
}
await executeSave();
}
return ( return (
<SettingsContainer> <SettingsContainer>
<ConfirmDeleteDialog
open={confirmRemoveOwnAdminOpen}
setOpen={setConfirmRemoveOwnAdminOpen}
title={t("removeOwnAdminRoleConfirmTitle")}
dialog={
<div className="space-y-2">
<p>{t("removeOwnAdminRoleConfirmDescription")}</p>
</div>
}
buttonText={t("removeOwnAdminRoleConfirmButton")}
string={t("removeOwnAdminRoleConfirmPhrase")}
onConfirm={executeSave}
/>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -168,7 +226,7 @@ export default function AccessControlsPage() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
action={action} onSubmit={(e) => void handleAccessControlsSubmit(e)}
className="space-y-4" className="space-y-4"
id="access-controls-form" id="access-controls-form"
> >
@@ -237,8 +295,8 @@ export default function AccessControlsPage() {
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <Button
type="submit" type="submit"
loading={isSubmitting} loading={isSaving}
disabled={isSubmitting} disabled={isSaving}
form="access-controls-form" form="access-controls-form"
> >
{t("accessControlsSubmit")} {t("accessControlsSubmit")}

View File

@@ -99,6 +99,14 @@ export default function UsersTable({
]; ];
}, [searchParams.toString()]); }, [searchParams.toString()]);
const isRemovingSelf = useMemo(() => {
if (!selectedUser || !user) return false;
return (
`${selectedUser.username}-${selectedUser.idpId}` ===
`${user.username}-${user.idpId}`
);
}, [selectedUser, user]);
function handleFilterChange( function handleFilterChange(
column: string, column: string,
value: string | undefined | null value: string | undefined | null
@@ -223,10 +231,7 @@ export default function UsersTable({
header: () => <span className="p-3"></span>, header: () => <span className="p-3"></span>,
cell: ({ row }) => { cell: ({ row }) => {
const userRow = row.original; const userRow = row.original;
const isCurrentUser = const canRemoveFromOrg = !userRow.isOwner;
`${userRow.username}-${userRow.idpId}` ===
`${user?.username}-${user?.idpId}`;
const isDisabled = userRow.isOwner || isCurrentUser;
return ( return (
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<div> <div>
@@ -235,7 +240,6 @@ export default function UsersTable({
<Button <Button
variant="ghost" variant="ghost"
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
disabled={isDisabled}
> >
<span className="sr-only"> <span className="sr-only">
{t("openMenu")} {t("openMenu")}
@@ -247,16 +251,12 @@ export default function UsersTable({
<Link <Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`} href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full" className="block w-full"
aria-disabled={isDisabled}
onClick={(e) =>
isDisabled && e.preventDefault()
}
> >
<DropdownMenuItem disabled={isDisabled}> <DropdownMenuItem>
{t("accessUserManage")} {t("accessUserManage")}
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
{!isDisabled && ( {canRemoveFromOrg && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@@ -271,25 +271,14 @@ export default function UsersTable({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{isDisabled ? ( <Link
<Button href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
variant={"outline"} >
className="ml-2" <Button variant={"outline"} className="ml-2">
disabled
>
{t("manage")} {t("manage")}
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
) : ( </Link>
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button variant={"outline"} className="ml-2">
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
)}
</div> </div>
); );
} }
@@ -359,22 +348,45 @@ export default function UsersTable({
}} }}
dialog={ dialog={
<div className="space-y-2"> <div className="space-y-2">
<p>{t("userQuestionOrgRemove")}</p> <p>
<p>{t("userMessageOrgRemove")}</p> {t(
isRemovingSelf
? "userQuestionOrgRemoveSelf"
: "userQuestionOrgRemove"
)}
</p>
<p>
{t(
isRemovingSelf
? "userMessageOrgRemoveSelf"
: "userMessageOrgRemove"
)}
</p>
</div> </div>
} }
buttonText={t("userRemoveOrgConfirm")} buttonText={t(
isRemovingSelf
? "userRemoveOrgConfirmSelf"
: "userRemoveOrgConfirm"
)}
warningText={
isRemovingSelf ? t("userRemoveOrgSelfWarning") : undefined
}
onConfirm={async () => startTransition(removeUser)} onConfirm={async () => startTransition(removeUser)}
string={ string={
selectedUser isRemovingSelf
? getUserDisplayName({ ? t("userRemoveOrgConfirmPhraseSelf")
email: selectedUser.email, : selectedUser
name: selectedUser.name, ? getUserDisplayName({
username: selectedUser.username email: selectedUser.email,
}) name: selectedUser.name,
: "" username: selectedUser.username
})
: ""
} }
title={t("userRemoveOrg")} title={t(
isRemovingSelf ? "userRemoveOrgSelf" : "userRemoveOrg"
)}
/> />
<ControlledDataTable <ControlledDataTable

View File

@@ -11,7 +11,7 @@ import { cn } from "@app/lib/cn";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
export type TagValue = { text: string; id: string }; export type TagValue = { text: string; id: string; isAdmin?: boolean };
export type MultiSelectTagsProps<T extends TagValue> = { export type MultiSelectTagsProps<T extends TagValue> = {
emptyPlaceholder?: string; emptyPlaceholder?: string;

View File

@@ -6,7 +6,7 @@ import { useDebounce } from "use-debounce";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input"; import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
export type SelectedRole = { id: string; text: string }; export type SelectedRole = { id: string; text: string; isAdmin?: boolean };
export type RolesSelectorProps = { export type RolesSelectorProps = {
orgId: string; orgId: string;