diff --git a/messages/en-US.json b/messages/en-US.json index 9a23043d5..9995d3af5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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.", "userRemoveOrgConfirm": "Confirm Remove User", "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", "accessRoleMember": "Member", "accessRoleOwner": "Owner", @@ -531,6 +537,11 @@ "emailInvalid": "Invalid email address", "inviteValidityDuration": "Please select a duration", "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", "idpSelectPlease": "Please select an identity provider", "idpGenericOidc": "Generic OAuth2/OIDC provider.", diff --git a/server/private/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts index 90fa79ee3..1789ca9c4 100644 --- a/server/private/routers/user/addUserRole.ts +++ b/server/private/routers/user/addUserRole.ts @@ -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 .select() .from(roles) diff --git a/server/private/routers/user/removeUserRole.ts b/server/private/routers/user/removeUserRole.ts index 1a7b763d4..7cd805240 100644 --- a/server/private/routers/user/removeUserRole.ts +++ b/server/private/routers/user/removeUserRole.ts @@ -98,11 +98,11 @@ export async function removeUserRole( ); } - if (existingUser.isOwner) { + if (existingUser.isOwner && role.isAdmin === true) { return next( createHttpError( HttpCode.FORBIDDEN, - "Cannot change the roles of the owner of the organization" + "Cannot remove the administrator role from the organization owner" ) ); } diff --git a/server/private/routers/user/setUserOrgRoles.ts b/server/private/routers/user/setUserOrgRoles.ts index 7567ffc54..7790eacfb 100644 --- a/server/private/routers/user/setUserOrgRoles.ts +++ b/server/private/routers/user/setUserOrgRoles.ts @@ -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 - .select({ roleId: roles.roleId }) + .select({ roleId: roles.roleId, isAdmin: roles.isAdmin }) .from(roles) .where( 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[] = []; await db.transaction(async (trx) => { await trx diff --git a/server/routers/user/addUserRoleLegacy.ts b/server/routers/user/addUserRoleLegacy.ts index 9696e4aac..6e5b805ab 100644 --- a/server/routers/user/addUserRoleLegacy.ts +++ b/server/routers/user/addUserRoleLegacy.ts @@ -88,11 +88,11 @@ export async function addUserRoleLegacy( ); } - if (existingUser.isOwner) { + if (existingUser.isOwner && role.isAdmin !== true) { return next( createHttpError( HttpCode.FORBIDDEN, - "Cannot change the role of the owner of the organization" + "The organization owner must retain an administrator role" ) ); } diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index c415e186c..af900150b 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -47,10 +47,7 @@ export async function queryUser(orgId: string, userId: string) { .from(userOrgRoles) .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .where( - and( - eq(userOrgRoles.userId, userId), - eq(userOrgRoles.orgId, orgId) - ) + and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId)) ); 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), roles: roleRows.map((r) => ({ roleId: r.roleId, - name: r.roleName ?? "" + name: r.roleName ?? "", + isAdmin: r.isAdmin === true })) }; } diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 717d7f211..2bb9723ed 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -1,5 +1,6 @@ "use client"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import IdpTypeBadge from "@app/components/IdpTypeBadge"; import OrgRolesTagField from "@app/components/OrgRolesTagField"; import { @@ -25,6 +26,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; +import { useUserContext } from "@app/hooks/useUserContext"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; @@ -32,7 +34,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UserType } from "@server/types/UserTypes"; import { useTranslations } from "next-intl"; import { useParams } from "next/navigation"; -import { useActionState, useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -42,13 +44,15 @@ const accessControlsFormSchema = z.object({ roles: z.array( z.object({ id: z.string(), - text: z.string() + text: z.string(), + isAdmin: z.boolean().optional() }) ) }); export default function AccessControlsPage() { const { orgUser: user, updateOrgUser } = userOrgUserContext(); + const { user: sessionUser } = useUserContext(); const { env } = useEnvContext(); const api = createApiClient({ env }); @@ -72,7 +76,8 @@ export default function AccessControlsPage() { autoProvisioned: user.autoProvisioned || false, roles: (user.roles ?? []).map((r) => ({ id: r.roleId.toString(), - text: r.name + text: r.name, + isAdmin: r.isAdmin === true })) } }); @@ -84,7 +89,8 @@ export default function AccessControlsPage() { "roles", (user.roles ?? []).map((r) => ({ id: r.roleId.toString(), - text: r.name + text: r.name, + isAdmin: r.isAdmin === true })) ); form.setValue("autoProvisioned", user.autoProvisioned || false); @@ -95,11 +101,11 @@ export default function AccessControlsPage() { ? t("singleRolePerUserPlanNotice") : t("singleRolePerUserEditionNotice"); - const [, action, isSubmitting] = useActionState(onSubmit, null); - async function onSubmit() { - const isValid = await form.trigger(); - if (!isValid) return; + const [isSaving, setIsSaving] = useState(false); + const [confirmRemoveOwnAdminOpen, setConfirmRemoveOwnAdminOpen] = + useState(false); + async function executeSave() { const values = form.getValues(); if (values.roles.length === 0) { @@ -111,6 +117,7 @@ export default function AccessControlsPage() { return; } + setIsSaving(true); try { const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const updateRoleRequest = supportsMultipleRolesPerUser @@ -130,7 +137,8 @@ export default function AccessControlsPage() { roleIds, roles: values.roles.map((r) => ({ roleId: parseInt(r.id, 10), - name: r.text + name: r.text, + isAdmin: r.isAdmin === true })), autoProvisioned: values.autoProvisioned }); @@ -149,11 +157,61 @@ export default function AccessControlsPage() { 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 ( + +

{t("removeOwnAdminRoleConfirmDescription")}

+ + } + buttonText={t("removeOwnAdminRoleConfirmButton")} + string={t("removeOwnAdminRoleConfirmPhrase")} + onConfirm={executeSave} + /> + @@ -168,7 +226,7 @@ export default function AccessControlsPage() {
void handleAccessControlsSubmit(e)} className="space-y-4" id="access-controls-form" > @@ -237,8 +295,8 @@ export default function AccessControlsPage() { - ) : ( - - - - )} + ); } @@ -359,22 +348,45 @@ export default function UsersTable({ }} dialog={
-

{t("userQuestionOrgRemove")}

-

{t("userMessageOrgRemove")}

+

+ {t( + isRemovingSelf + ? "userQuestionOrgRemoveSelf" + : "userQuestionOrgRemove" + )} +

+

+ {t( + isRemovingSelf + ? "userMessageOrgRemoveSelf" + : "userMessageOrgRemove" + )} +

} - buttonText={t("userRemoveOrgConfirm")} + buttonText={t( + isRemovingSelf + ? "userRemoveOrgConfirmSelf" + : "userRemoveOrgConfirm" + )} + warningText={ + isRemovingSelf ? t("userRemoveOrgSelfWarning") : undefined + } onConfirm={async () => startTransition(removeUser)} string={ - selectedUser - ? getUserDisplayName({ - email: selectedUser.email, - name: selectedUser.name, - username: selectedUser.username - }) - : "" + isRemovingSelf + ? t("userRemoveOrgConfirmPhraseSelf") + : selectedUser + ? getUserDisplayName({ + email: selectedUser.email, + name: selectedUser.name, + username: selectedUser.username + }) + : "" } - title={t("userRemoveOrg")} + title={t( + isRemovingSelf ? "userRemoveOrgSelf" : "userRemoveOrg" + )} /> = { emptyPlaceholder?: string; diff --git a/src/components/roles-selector.tsx b/src/components/roles-selector.tsx index 7f1b62e60..811971f49 100644 --- a/src/components/roles-selector.tsx +++ b/src/components/roles-selector.tsx @@ -6,7 +6,7 @@ import { useDebounce } from "use-debounce"; import { useTranslations } from "next-intl"; 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 = { orgId: string;