remove idp user if unassociate idp, warn, and fix create user form bug

This commit is contained in:
miloschwartz
2026-06-03 21:41:55 -07:00
parent 00ec7a5c66
commit f5ab837cce
4 changed files with 132 additions and 65 deletions

View File

@@ -3346,6 +3346,8 @@
"idpUnassociateQuestion": "Are you sure you want to unassociate this identity provider from this organization?", "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.", "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", "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.", "idpUnassociateWarning": "This cannot be undone for this organization.",
"idpUnassociatedDescription": "Identity provider unassociated from this organization successfully", "idpUnassociatedDescription": "Identity provider unassociated from this organization successfully",
"idpUnassociateMenu": "Unassociate", "idpUnassociateMenu": "Unassociate",

View File

@@ -13,13 +13,15 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; 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 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 logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { and, eq, sql } from "drizzle-orm"; 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"; import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z const paramsSchema = z
@@ -76,9 +78,55 @@ export async function unassociateOrgIdp(
); );
} }
await db const [org] = await db
.delete(idpOrg) .select()
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); .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<null>(res, { return response<null>(res, {
data: null, data: null,

View File

@@ -13,7 +13,7 @@ import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useActionState, useState } from "react"; import { useActionState, useRef, useState } from "react";
import { import {
Form, Form,
FormControl, FormControl,
@@ -205,23 +205,7 @@ export default function Page() {
} }
}); });
useEffect(() => { const prevSelectedOptionRef = useRef(selectedOption);
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
]);
useEffect(() => { useEffect(() => {
if (!selectedOption) { if (!selectedOption) {
@@ -315,19 +299,10 @@ export default function Page() {
onSubmitInternal, onSubmitInternal,
null null
); );
const [, submitGoogleAzureAction, isSubmittingGoogleAzure] = useActionState( const [isSubmittingExternal, setIsSubmittingExternal] = useState(false);
onSubmitGoogleAzure,
null
);
const [, submitGenericOidcAction, isSubmittingGenericOidc] = useActionState(
onSubmitGenericOidc,
null
);
const loading = const loading =
isSubmittingInternal || isSubmittingInternal || isSubmittingExternal;
isSubmittingGoogleAzure ||
isSubmittingGenericOidc;
async function onSubmitInternal() { async function onSubmitInternal() {
const isValid = await internalForm.trigger(); const isValid = await internalForm.trigger();
@@ -378,17 +353,16 @@ export default function Page() {
} }
} }
async function onSubmitGoogleAzure() { async function onSubmitGoogleAzure(
const isValid = await googleAzureForm.trigger(); values: z.infer<typeof googleAzureFormSchema>
if (!isValid) return; ) {
const values = googleAzureForm.getValues();
const selectedUserOption = userOptions.find( const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption (opt) => opt.id === selectedOption
); );
if (!selectedUserOption?.idpId) return; if (!selectedUserOption?.idpId) return;
setIsSubmittingExternal(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api const res = await api
@@ -419,19 +393,20 @@ export default function Page() {
}); });
router.push(`/${orgId}/settings/access/users`); router.push(`/${orgId}/settings/access/users`);
} }
setIsSubmittingExternal(false);
} }
async function onSubmitGenericOidc() { async function onSubmitGenericOidc(
const isValid = await genericOidcForm.trigger(); values: z.infer<typeof genericOidcFormSchema>
if (!isValid) return; ) {
const values = genericOidcForm.getValues();
const selectedUserOption = userOptions.find( const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption (opt) => opt.id === selectedOption
); );
if (!selectedUserOption?.idpId) return; if (!selectedUserOption?.idpId) return;
setIsSubmittingExternal(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api const res = await api
@@ -462,6 +437,27 @@ export default function Page() {
}); });
router.push(`/${orgId}/settings/access/users`); 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 ( return (
@@ -496,16 +492,8 @@ export default function Page() {
<SettingsSectionBody> <SettingsSectionBody>
<StrategySelect <StrategySelect
options={userOptions} options={userOptions}
defaultValue={selectedOption || undefined} value={selectedOption}
onChange={(value) => { onChange={handleUserTypeChange}
setSelectedOption(value);
if (value === "internal") {
internalForm.reset();
} else {
googleAzureForm.reset();
genericOidcForm.reset();
}
}}
cols={3} cols={3}
/> />
</SettingsSectionBody> </SettingsSectionBody>
@@ -714,9 +702,9 @@ export default function Page() {
})() && ( })() && (
<Form {...googleAzureForm}> <Form {...googleAzureForm}>
<form <form
action={ onSubmit={googleAzureForm.handleSubmit(
submitGoogleAzureAction onSubmitGoogleAzure
} )}
className="space-y-4" className="space-y-4"
id="create-user-form" id="create-user-form"
> >
@@ -797,9 +785,9 @@ export default function Page() {
})() && ( })() && (
<Form {...genericOidcForm}> <Form {...genericOidcForm}>
<form <form
action={ onSubmit={genericOidcForm.handleSubmit(
submitGenericOidcAction onSubmitGenericOidc
} )}
className="space-y-4" className="space-y-4"
id="create-user-form" id="create-user-form"
> >

View File

@@ -125,6 +125,13 @@ function IdpImportRowIcon({
return <IdpTypeIcon type={type} variant={variant} size={20} />; return <IdpTypeIcon type={type} variant={variant} size={20} />;
} }
function isUserMemberOfIdp(
userIdpId: number | null | undefined,
idpId: number
) {
return userIdpId != null && userIdpId === idpId;
}
type Props = { type Props = {
idps: IdpRow[]; idps: IdpRow[];
orgId: string; orgId: string;
@@ -362,9 +369,17 @@ export default function IdpTable({ idps, orgId }: Props) {
<p>{t("idpDeleteGlobalDescription")}</p> <p>{t("idpDeleteGlobalDescription")}</p>
</div> </div>
} }
buttonText={t("idpConfirmDelete")} buttonText={
isUserMemberOfIdp(user.idpId, selectedIdp.idpId)
? t("idpConfirmDeleteAndRemoveMeFromOrg")
: t("idpConfirmDelete")
}
onConfirm={async () => deleteIdp(selectedIdp.idpId)} onConfirm={async () => deleteIdp(selectedIdp.idpId)}
string={selectedIdp.name} string={
isUserMemberOfIdp(user.idpId, selectedIdp.idpId)
? t("idpConfirmDeleteAndRemoveMeFromOrg")
: selectedIdp.name
}
title={t("idpDelete")} title={t("idpDelete")}
/> />
)} )}
@@ -381,11 +396,25 @@ export default function IdpTable({ idps, orgId }: Props) {
<p>{t("idpUnassociateDescription")}</p> <p>{t("idpUnassociateDescription")}</p>
</div> </div>
} }
buttonText={t("idpUnassociateConfirm")} buttonText={
isUserMemberOfIdp(
user.idpId,
selectedUnassociateIdp.idpId
)
? t("idpUnassociateAndRemoveMeFromOrg")
: t("idpUnassociateConfirm")
}
onConfirm={async () => onConfirm={async () =>
unassociateIdp(selectedUnassociateIdp.idpId) unassociateIdp(selectedUnassociateIdp.idpId)
} }
string={selectedUnassociateIdp.name} string={
isUserMemberOfIdp(
user.idpId,
selectedUnassociateIdp.idpId
)
? t("idpUnassociateAndRemoveMeFromOrg")
: selectedUnassociateIdp.name
}
title={t("idpUnassociateTitle")} title={t("idpUnassociateTitle")}
warningText={t("idpUnassociateWarning")} warningText={t("idpUnassociateWarning")}
/> />