diff --git a/messages/en-US.json b/messages/en-US.json index 617dc9af2..2264f1332 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3346,6 +3346,8 @@ "idpUnassociateQuestion": "Are you sure you want to unassociate this identity provider from this organization?", "idpUnassociateDescription": "All users associated with this identity provider will be removed from this organization, but the identity provider will still continue to exist for other associated organizations.", "idpUnassociateConfirm": "Confirm Unassociate Identity Provider", + "idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG", + "idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG", "idpUnassociateWarning": "This cannot be undone for this organization.", "idpUnassociatedDescription": "Identity provider unassociated from this organization successfully", "idpUnassociateMenu": "Unassociate", diff --git a/server/private/routers/orgIdp/unassociateOrgIdp.ts b/server/private/routers/orgIdp/unassociateOrgIdp.ts index f6ab557b3..41b2e6c89 100644 --- a/server/private/routers/orgIdp/unassociateOrgIdp.ts +++ b/server/private/routers/orgIdp/unassociateOrgIdp.ts @@ -13,13 +13,15 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, idpOrg } from "@server/db"; +import { db, idpOrg, orgs, primaryDb, users, userOrgs } from "@server/db"; 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 { and, eq, sql } from "drizzle-orm"; +import { removeUserFromOrg } from "@server/lib/userOrg"; +import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z @@ -76,9 +78,55 @@ export async function unassociateOrgIdp( ); } - await db - .delete(idpOrg) - .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Organization not found") + ); + } + + const orgUsersFromIdp = await db + .select({ + userId: userOrgs.userId, + isOwner: userOrgs.isOwner + }) + .from(userOrgs) + .innerJoin(users, eq(users.userId, userOrgs.userId)) + .where(and(eq(userOrgs.orgId, orgId), eq(users.idpId, idpId))); + + if (orgUsersFromIdp.some((u) => u.isOwner)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot unassociate identity provider while an organization owner uses it" + ) + ); + } + + const userIdsToRemove = orgUsersFromIdp.map((u) => u.userId); + + await db.transaction(async (trx) => { + for (const userId of userIdsToRemove) { + await removeUserFromOrg(org, userId, trx); + } + + await trx + .delete(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); + }); + + for (const userId of userIdsToRemove) { + calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + logger.error( + `Failed to calculate user clients after removing user ${userId} from org ${orgId} during IdP unassociation: ${e}` + ); + }); + } return response(res, { data: null, diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 0443cd8ed..cafbca55f 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -13,7 +13,7 @@ import { StrategyOption, StrategySelect } from "@app/components/StrategySelect"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { Button } from "@app/components/ui/button"; import { useParams, useRouter } from "next/navigation"; -import { useActionState, useState } from "react"; +import { useActionState, useRef, useState } from "react"; import { Form, FormControl, @@ -205,23 +205,7 @@ export default function Page() { } }); - useEffect(() => { - if (selectedOption === "internal") { - setSendEmail(env.email.emailEnabled); - internalForm.reset(); - setInviteLink(null); - setExpiresInDays(1); - } else if (selectedOption && selectedOption !== "internal") { - googleAzureForm.reset(); - genericOidcForm.reset(); - } - }, [ - selectedOption, - env.email.emailEnabled, - internalForm, - googleAzureForm, - genericOidcForm - ]); + const prevSelectedOptionRef = useRef(selectedOption); useEffect(() => { if (!selectedOption) { @@ -315,19 +299,10 @@ export default function Page() { onSubmitInternal, null ); - const [, submitGoogleAzureAction, isSubmittingGoogleAzure] = useActionState( - onSubmitGoogleAzure, - null - ); - const [, submitGenericOidcAction, isSubmittingGenericOidc] = useActionState( - onSubmitGenericOidc, - null - ); + const [isSubmittingExternal, setIsSubmittingExternal] = useState(false); const loading = - isSubmittingInternal || - isSubmittingGoogleAzure || - isSubmittingGenericOidc; + isSubmittingInternal || isSubmittingExternal; async function onSubmitInternal() { const isValid = await internalForm.trigger(); @@ -378,17 +353,16 @@ export default function Page() { } } - async function onSubmitGoogleAzure() { - const isValid = await googleAzureForm.trigger(); - if (!isValid) return; - - const values = googleAzureForm.getValues(); - + async function onSubmitGoogleAzure( + values: z.infer + ) { const selectedUserOption = userOptions.find( (opt) => opt.id === selectedOption ); if (!selectedUserOption?.idpId) return; + setIsSubmittingExternal(true); + const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const res = await api @@ -419,19 +393,20 @@ export default function Page() { }); router.push(`/${orgId}/settings/access/users`); } + + setIsSubmittingExternal(false); } - async function onSubmitGenericOidc() { - const isValid = await genericOidcForm.trigger(); - if (!isValid) return; - - const values = genericOidcForm.getValues(); - + async function onSubmitGenericOidc( + values: z.infer + ) { const selectedUserOption = userOptions.find( (opt) => opt.id === selectedOption ); if (!selectedUserOption?.idpId) return; + setIsSubmittingExternal(true); + const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const res = await api @@ -462,6 +437,27 @@ export default function Page() { }); router.push(`/${orgId}/settings/access/users`); } + + setIsSubmittingExternal(false); + } + + function handleUserTypeChange(value: string) { + if (prevSelectedOptionRef.current === value) { + return; + } + + prevSelectedOptionRef.current = value; + setSelectedOption(value); + + if (value === "internal") { + setSendEmail(env.email.emailEnabled); + internalForm.reset(); + setInviteLink(null); + setExpiresInDays(1); + } else { + googleAzureForm.reset(); + genericOidcForm.reset(); + } } return ( @@ -496,16 +492,8 @@ export default function Page() { { - setSelectedOption(value); - if (value === "internal") { - internalForm.reset(); - } else { - googleAzureForm.reset(); - genericOidcForm.reset(); - } - }} + value={selectedOption} + onChange={handleUserTypeChange} cols={3} /> @@ -714,9 +702,9 @@ export default function Page() { })() && (
@@ -797,9 +785,9 @@ export default function Page() { })() && ( diff --git a/src/components/OrgIdpTable.tsx b/src/components/OrgIdpTable.tsx index c0199c6d3..f72bc5341 100644 --- a/src/components/OrgIdpTable.tsx +++ b/src/components/OrgIdpTable.tsx @@ -125,6 +125,13 @@ function IdpImportRowIcon({ return ; } +function isUserMemberOfIdp( + userIdpId: number | null | undefined, + idpId: number +) { + return userIdpId != null && userIdpId === idpId; +} + type Props = { idps: IdpRow[]; orgId: string; @@ -362,9 +369,17 @@ export default function IdpTable({ idps, orgId }: Props) {

{t("idpDeleteGlobalDescription")}

} - buttonText={t("idpConfirmDelete")} + buttonText={ + isUserMemberOfIdp(user.idpId, selectedIdp.idpId) + ? t("idpConfirmDeleteAndRemoveMeFromOrg") + : t("idpConfirmDelete") + } onConfirm={async () => deleteIdp(selectedIdp.idpId)} - string={selectedIdp.name} + string={ + isUserMemberOfIdp(user.idpId, selectedIdp.idpId) + ? t("idpConfirmDeleteAndRemoveMeFromOrg") + : selectedIdp.name + } title={t("idpDelete")} /> )} @@ -381,11 +396,25 @@ export default function IdpTable({ idps, orgId }: Props) {

{t("idpUnassociateDescription")}

} - buttonText={t("idpUnassociateConfirm")} + buttonText={ + isUserMemberOfIdp( + user.idpId, + selectedUnassociateIdp.idpId + ) + ? t("idpUnassociateAndRemoveMeFromOrg") + : t("idpUnassociateConfirm") + } onConfirm={async () => unassociateIdp(selectedUnassociateIdp.idpId) } - string={selectedUnassociateIdp.name} + string={ + isUserMemberOfIdp( + user.idpId, + selectedUnassociateIdp.idpId + ) + ? t("idpUnassociateAndRemoveMeFromOrg") + : selectedUnassociateIdp.name + } title={t("idpUnassociateTitle")} warningText={t("idpUnassociateWarning")} />