From 93400ace275a7ee776321c840d38b0efbad3049f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 16 Apr 2026 20:58:18 -0700 Subject: [PATCH 1/6] import and unassocaite org idp --- messages/en-US.json | 19 +- public/idp/openid.png | Bin 0 -> 9038 bytes server/private/routers/external.ts | 39 ++- server/private/routers/orgIdp/importOrgIdp.ts | 225 ++++++++++++++++ server/private/routers/orgIdp/index.ts | 3 + .../routers/orgIdp/listUserAdminOrgIdps.ts | 160 +++++++++++ .../routers/orgIdp/unassociateOrgIdp.ts | 109 ++++++++ server/routers/orgIdp/types.ts | 19 ++ .../settings/access/users/create/page.tsx | 48 +--- src/components/IdpLoginButtons.tsx | 21 +- src/components/IdpTypeBadge.tsx | 32 +-- src/components/IdpTypeIcon.tsx | 53 ++++ src/components/LoginForm.tsx | 21 +- src/components/OrgIdpDataTable.tsx | 10 +- src/components/OrgIdpTable.tsx | 250 +++++++++++++++++- .../idp/OidcIdpProviderTypeSelect.tsx | 25 +- src/components/ui/controlled-data-table.tsx | 64 ++++- src/components/ui/data-table.tsx | 54 +++- 18 files changed, 997 insertions(+), 155 deletions(-) create mode 100644 public/idp/openid.png create mode 100644 server/private/routers/orgIdp/importOrgIdp.ts create mode 100644 server/private/routers/orgIdp/listUserAdminOrgIdps.ts create mode 100644 server/private/routers/orgIdp/unassociateOrgIdp.ts create mode 100644 src/components/IdpTypeIcon.tsx diff --git a/messages/en-US.json b/messages/en-US.json index d1ce572bd..abc4f2928 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2899,5 +2899,22 @@ "httpDestUpdatedSuccess": "Destination updated successfully", "httpDestCreatedSuccess": "Destination created successfully", "httpDestUpdateFailed": "Failed to update destination", - "httpDestCreateFailed": "Failed to create destination" + "httpDestCreateFailed": "Failed to create destination", + "idpAddActionCreateNew": "Create new identity provider", + "idpAddActionImportFromOrg": "Import from another organization", + "idpImportDialogTitle": "Import Identity Provider", + "idpImportDialogDescription": "Choose an identity provider from an organization where you are an admin. It will be linked to this organization.", + "idpImportSearchPlaceholder": "Search by organization or provider name...", + "idpImportEmpty": "No identity providers found.", + "idpImportedDescription": "Identity provider imported successfully.", + "idpDeleteGlobalQuestion": "Are you sure you want to permanently delete this identity provider?", + "idpDeleteGlobalDescription": "This will permanently delete the identity provider from all organizations it is associated with.", + "idpUnassociateTitle": "Unassociate Identity Provider", + "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", + "idpUnassociateWarning": "This cannot be undone for this organization.", + "idpUnassociatedDescription": "Identity provider unassociated from this organization successfully", + "idpUnassociateMenu": "Unassociate", + "idpDeleteAllOrgsMenu": "Delete" } diff --git a/public/idp/openid.png b/public/idp/openid.png new file mode 100644 index 0000000000000000000000000000000000000000..d4422c872f476fcc85bfce6c69983d37adcfb284 GIT binary patch literal 9038 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 P|862Hr35XNGz|D3rRuK6 literal 0 HcmV?d00001 diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 1be4eaaca..53960b743 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -40,7 +40,9 @@ import { verifyRoleAccess, verifyUserAccess, verifyUserCanSetUserOrgRoles, - verifySiteProvisioningKeyAccess + verifySiteProvisioningKeyAccess, + verifyIsLoggedInUser, + verifyAdmin } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -94,6 +96,17 @@ authenticated.put( orgIdp.createOrgOidcIdp ); +authenticated.post( + "/org/:orgId/idp/:idpId/import", + verifyValidLicense, + verifyValidSubscription(tierMatrix.orgOidc), + verifyOrgAccess, + verifyLimits, + verifyAdmin, + logActionAudit(ActionsEnum.createIdp), + orgIdp.importOrgIdp +); + authenticated.post( "/org/:orgId/idp/:idpId/oidc", verifyValidLicense, @@ -116,6 +129,16 @@ authenticated.delete( orgIdp.deleteOrgIdp ); +authenticated.delete( + "/org/:orgId/idp/:idpId/association", + verifyValidLicense, + verifyOrgAccess, + verifyIdpAccess, + verifyUserHasAction(ActionsEnum.deleteIdp), + logActionAudit(ActionsEnum.deleteIdp), + orgIdp.unassociateOrgIdp +); + authenticated.get( "/org/:orgId/idp/:idpId", verifyValidLicense, @@ -125,16 +148,14 @@ authenticated.get( orgIdp.getOrgIdp ); -authenticated.get( - "/org/:orgId/idp", - verifyValidLicense, - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.listIdps), - orgIdp.listOrgIdps -); - authenticated.get("/org/:orgId/idp", orgIdp.listOrgIdps); // anyone can see this; it's just a list of idp names and ids +authenticated.get( + "/user/:userId/admin-org-idps", + verifyIsLoggedInUser, + orgIdp.listUserAdminOrgIdps +); + authenticated.get( "/org/:orgId/certificate/:domainId/:domain", verifyValidLicense, diff --git a/server/private/routers/orgIdp/importOrgIdp.ts b/server/private/routers/orgIdp/importOrgIdp.ts new file mode 100644 index 000000000..906c504d8 --- /dev/null +++ b/server/private/routers/orgIdp/importOrgIdp.ts @@ -0,0 +1,225 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } 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 { OpenAPITags, registry } from "@server/openApi"; +import { idp, idpOrg, orgs, roles, userOrgs } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types"; +import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; +import privateConfig from "#private/lib/config"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + idpId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + sourceOrgId: z.string().nonempty() +}); + +async function userIsOrgAdmin( + userId: string, + orgId: string, + session: Request["session"] +): Promise { + const [userOrgRow] = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!userOrgRow) { + return false; + } + + const policyCheck = await checkOrgAccessPolicy({ + orgId, + userId, + session + }); + if (!policyCheck.allowed || policyCheck.error) { + return false; + } + + const roleIds = await getUserOrgRoleIds(userId, orgId); + if (roleIds.length === 0) { + return false; + } + + const [adminRole] = await db + .select() + .from(roles) + .where(and(inArray(roles.roleId, roleIds), eq(roles.isAdmin, true))) + .limit(1); + + return !!adminRole; +} + +export async function importOrgIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId: targetOrgId, idpId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { sourceOrgId } = parsedBody.data; + + if ( + privateConfig.getRawPrivateConfig().app.identity_provider_mode !== + "org" + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." + ) + ); + } + + if (sourceOrgId === targetOrgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Source and target organization must be different" + ) + ); + } + + const userId = req.user!.userId; + + const sourceLinked = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, sourceOrgId))) + .limit(1); + + if (sourceLinked.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "IdP not found for the source organization" + ) + ); + } + + const sourceAdmin = await userIsOrgAdmin( + userId, + sourceOrgId, + req.session + ); + if (!sourceAdmin) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You must be an organization admin in the source organization where this IdP is linked" + ) + ); + } + + const [targetOrg] = await db + .select({ orgId: orgs.orgId }) + .from(orgs) + .where(eq(orgs.orgId, targetOrgId)) + .limit(1); + + if (!targetOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Target organization not found" + ) + ); + } + + const [existingIdp] = await db + .select() + .from(idp) + .where(eq(idp.idpId, idpId)) + .limit(1); + + if (!existingIdp) { + return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found")); + } + + const alreadyTarget = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, targetOrgId))) + .limit(1); + + if (alreadyTarget.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "This IdP is already linked to the target organization" + ) + ); + } + + await db.insert(idpOrg).values({ + idpId, + orgId: targetOrgId, + roleMapping: null, + orgMapping: `'${targetOrgId}'` + }); + + const redirectUrl = await generateOidcRedirectUrl(idpId, targetOrgId); + + return response(res, { + data: { + idpId, + redirectUrl + }, + success: true, + error: false, + message: "Org IdP imported successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/orgIdp/index.ts b/server/private/routers/orgIdp/index.ts index e3f967f86..2997468be 100644 --- a/server/private/routers/orgIdp/index.ts +++ b/server/private/routers/orgIdp/index.ts @@ -12,7 +12,10 @@ */ export * from "./createOrgOidcIdp"; +export * from "./importOrgIdp"; export * from "./getOrgIdp"; export * from "./listOrgIdps"; +export * from "./listUserAdminOrgIdps"; export * from "./updateOrgOidcIdp"; export * from "./deleteOrgIdp"; +export * from "./unassociateOrgIdp"; diff --git a/server/private/routers/orgIdp/listUserAdminOrgIdps.ts b/server/private/routers/orgIdp/listUserAdminOrgIdps.ts new file mode 100644 index 000000000..78faa48fa --- /dev/null +++ b/server/private/routers/orgIdp/listUserAdminOrgIdps.ts @@ -0,0 +1,160 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idpOidcConfig } from "@server/db"; +import { idp, idpOrg, orgs, roles, userOrgRoles } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; + +const querySchema = z.strictObject({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()) +}); + +const paramsSchema = z.strictObject({ + userId: z.string().nonempty() +}); + +async function getOrgIdsWhereUserIsAdmin(userId: string): Promise { + const rows = await db + .select({ orgId: userOrgRoles.orgId }) + .from(userOrgRoles) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where(and(eq(userOrgRoles.userId, userId), eq(roles.isAdmin, true))); + return [...new Set(rows.map((r) => r.orgId))]; +} + +async function queryIdpsForOrgs( + orgIds: string[], + limit: number, + offset: number +) { + return db + .select({ + idpId: idp.idpId, + orgId: idpOrg.orgId, + orgName: orgs.name, + name: idp.name, + type: idp.type, + variant: idpOidcConfig.variant, + tags: idp.tags + }) + .from(idpOrg) + .where(inArray(idpOrg.orgId, orgIds)) + .innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId)) + .innerJoin(idp, eq(idp.idpId, idpOrg.idpId)) + .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId)) + .orderBy(sql`idp.name DESC`) + .limit(limit) + .offset(offset); +} + +async function countIdpsForOrgs(orgIds: string[]) { + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(idpOrg) + .innerJoin(idp, eq(idp.idpId, idpOrg.idpId)) + .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId)) + .where(inArray(idpOrg.orgId, orgIds)); + return count; +} + +export async function listUserAdminOrgIdps( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { userId } = parsedParams.data; + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const adminOrgIds = await getOrgIdsWhereUserIsAdmin(userId); + + if (adminOrgIds.length === 0) { + return response(res, { + data: { + idps: [], + pagination: { + total: 0, + limit, + offset + } + }, + success: true, + error: false, + message: "Org Idps retrieved successfully", + status: HttpCode.OK + }); + } + + const list = await queryIdpsForOrgs(adminOrgIds, limit, offset); + const total = await countIdpsForOrgs(adminOrgIds); + + return response(res, { + data: { + idps: list, + pagination: { + total, + limit, + offset + } + }, + success: true, + error: false, + message: "Org Idps retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/orgIdp/unassociateOrgIdp.ts b/server/private/routers/orgIdp/unassociateOrgIdp.ts new file mode 100644 index 000000000..e6b0a6a2e --- /dev/null +++ b/server/private/routers/orgIdp/unassociateOrgIdp.ts @@ -0,0 +1,109 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idpOrg } 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 { OpenAPITags, registry } from "@server/openApi"; +import privateConfig from "#private/lib/config"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + idpId: z.coerce.number().int().positive() + }) + .strict(); + +export async function unassociateOrgIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, idpId } = parsedParams.data; + + if ( + privateConfig.getRawPrivateConfig().app.identity_provider_mode !== + "org" + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." + ) + ); + } + + const [association] = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))) + .limit(1); + + if (!association) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `IdP with ID ${idpId} is not associated with organization ${orgId}` + ) + ); + } + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(idpOrg) + .where(eq(idpOrg.idpId, idpId)); + + if (count <= 1) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "This is the last organization associated with this identity provider. Delete it instead." + ) + ); + } + + await db + .delete(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); + + return response(res, { + data: null, + success: true, + error: false, + message: "Org IdP unassociated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/orgIdp/types.ts b/server/routers/orgIdp/types.ts index f6f581eed..40dbb2cf4 100644 --- a/server/routers/orgIdp/types.ts +++ b/server/routers/orgIdp/types.ts @@ -25,3 +25,22 @@ export type ListOrgIdpsResponse = { offset: number; }; }; + +export type ListUserAdminOrgIdpsEntry = { + idpId: number; + orgId: string; + orgName: string; + name: string; + type: string; + variant: string; + tags: string | null; +}; + +export type ListUserAdminOrgIdpsResponse = { + idps: ListUserAdminOrgIdpsEntry[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 0263d2b72..858ac8da8 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -46,7 +46,7 @@ import { Checkbox } from "@app/components/ui/checkbox"; import { ListIdpsResponse } from "@server/routers/idp"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; -import Image from "next/image"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import OrgRolesTagField from "@app/components/OrgRolesTagField"; @@ -152,31 +152,8 @@ export default function Page() { const getIdpIcon = (variant: string | null) => { if (!variant) return null; - - switch (variant.toLowerCase()) { - case "google": - return ( - {t("idpGoogleAlt")} - ); - case "azure": - return ( - {t("idpAzureAlt")} - ); - default: - return null; - } + const type = variant.toLowerCase(); + return ; }; const validFor = [ @@ -340,15 +317,16 @@ export default function Page() { const roleIds = values.roles.map((r) => parseInt(r.id, 10)); - const res = await api.post>( - `/org/${orgId}/create-invite`, - { - email: values.email, - roleIds, - validHours: parseInt(values.validForHours), - sendEmail - } - ) + const res = await api + .post>( + `/org/${orgId}/create-invite`, + { + email: values.email, + roleIds, + validHours: parseInt(values.validForHours), + sendEmail + } + ) .catch((e) => { if (e.response?.status === 409) { toast({ diff --git a/src/components/IdpLoginButtons.tsx b/src/components/IdpLoginButtons.tsx index 50d849812..4fc4c9901 100644 --- a/src/components/IdpLoginButtons.tsx +++ b/src/components/IdpLoginButtons.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { Button } from "@app/components/ui/button"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { useTranslations } from "next-intl"; -import Image from "next/image"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; import { generateOidcUrlProxy, type GenerateOidcUrlResponse @@ -135,24 +135,7 @@ export default function IdpLoginButtons({ disabled={loading} loading={loading} > - {effectiveType === "google" && ( - Google - )} - {effectiveType === "azure" && ( - Azure - )} + {idp.name} ); diff --git a/src/components/IdpTypeBadge.tsx b/src/components/IdpTypeBadge.tsx index b0e90660b..d18c96d9b 100644 --- a/src/components/IdpTypeBadge.tsx +++ b/src/components/IdpTypeBadge.tsx @@ -1,7 +1,7 @@ "use client"; import { Badge } from "@app/components/ui/badge"; -import Image from "next/image"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; type IdpTypeBadgeProps = { type: string; @@ -29,34 +29,8 @@ export default function IdpTypeBadge({ variant="secondary" className="inline-flex items-center space-x-1 w-fit" > - {effectiveType === "google" && ( - <> - Google - {effectiveName} - - )} - {effectiveType === "azure" && ( - <> - Azure - {effectiveName} - - )} - {effectiveType === "oidc" && {effectiveName}} - {!["google", "azure", "oidc"].includes(effectiveType) && ( - {effectiveName} - )} + + {effectiveName} ); } diff --git a/src/components/IdpTypeIcon.tsx b/src/components/IdpTypeIcon.tsx new file mode 100644 index 000000000..be49f9654 --- /dev/null +++ b/src/components/IdpTypeIcon.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import Image from "next/image"; +import { ReactNode } from "react"; + +type Props = { + type?: string | null; + variant?: string | null; + size?: number; + className?: string; + alt?: string; + fallback?: ReactNode; +}; + +export default function IdpTypeIcon({ + type, + variant, + size = 16, + className, + alt, + fallback = null +}: Props) { + const effectiveType = (variant || type || "").toLowerCase(); + + let src: string | null = null; + let defaultAlt = ""; + + if (effectiveType === "google") { + src = "/idp/google.png"; + defaultAlt = "Google"; + } else if (effectiveType === "azure") { + src = "/idp/azure.png"; + defaultAlt = "Azure"; + } else if (effectiveType === "oidc") { + src = "/idp/openid.png"; + defaultAlt = "OAuth2/OIDC"; + } + + if (!src) { + return <>{fallback}; + } + + return ( + {alt + ); +} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index c3b1fc384..e87a8b1a8 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -27,7 +27,6 @@ import { LockIcon } from "lucide-react"; import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; import { createApiClient } from "@app/lib/api"; import Link from "next/link"; -import Image from "next/image"; import { GenerateOidcUrlResponse } from "@server/routers/idp"; import { Separator } from "./ui/separator"; import { useTranslations } from "next-intl"; @@ -37,6 +36,7 @@ import { } from "@app/actions/server"; import { redirect as redirectTo } from "next/navigation"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; // @ts-ignore import { loadReoScript } from "reodotdev"; import { build } from "@server/build"; @@ -393,24 +393,7 @@ export default function LoginForm({ loginWithIdp(idp.idpId); }} > - {effectiveType === "google" && ( - Google - )} - {effectiveType === "azure" && ( - Azure - )} + {idp.name} ); diff --git a/src/components/OrgIdpDataTable.tsx b/src/components/OrgIdpDataTable.tsx index 9a45f49e8..7e3f7ab65 100644 --- a/src/components/OrgIdpDataTable.tsx +++ b/src/components/OrgIdpDataTable.tsx @@ -1,19 +1,24 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { DataTable } from "@app/components/ui/data-table"; +import { + DataTable, + type DataTableAddAction +} from "@app/components/ui/data-table"; import { useTranslations } from "next-intl"; interface DataTableProps { columns: ColumnDef[]; data: TData[]; onAdd?: () => void; + addActions?: DataTableAddAction[]; } export function IdpDataTable({ columns, data, - onAdd + onAdd, + addActions }: DataTableProps) { const t = useTranslations(); @@ -27,6 +32,7 @@ export function IdpDataTable({ searchColumn="name" addButtonText={t("idpAdd")} onAdd={onAdd} + addActions={addActions} enableColumnVisibility={true} stickyRightColumn="actions" /> diff --git a/src/components/OrgIdpTable.tsx b/src/components/OrgIdpTable.tsx index 8f53f4847..ebcdca334 100644 --- a/src/components/OrgIdpTable.tsx +++ b/src/components/OrgIdpTable.tsx @@ -4,13 +4,37 @@ import { ColumnDef } from "@tanstack/react-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { IdpDataTable } from "@app/components/OrgIdpDataTable"; import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; -import { useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + ArrowRight, + ArrowUpDown, + KeyRound, + MoreHorizontal +} from "lucide-react"; +import { useMemo, useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useUserContext } from "@app/hooks/useUserContext"; import { useRouter } from "next/navigation"; import { DropdownMenu, @@ -21,6 +45,11 @@ import { import Link from "next/link"; import { useTranslations } from "next-intl"; import IdpTypeBadge from "@app/components/IdpTypeBadge"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; +import { useQuery } from "@tanstack/react-query"; +import { useDebounce } from "use-debounce"; +import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; +import { cn } from "@app/lib/cn"; export type IdpRow = { idpId: number; @@ -29,6 +58,15 @@ export type IdpRow = { variant?: string; }; +type AdminIdpRow = ListUserAdminOrgIdpsResponse["idps"][number]; + +function IdpImportRowIcon({ + type, + variant +}: Pick) { + return ; +} + type Props = { idps: IdpRow[]; orgId: string; @@ -37,10 +75,48 @@ type Props = { export default function IdpTable({ idps, orgId }: Props) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedIdp, setSelectedIdp] = useState(null); + const [isUnassociateModalOpen, setIsUnassociateModalOpen] = useState(false); + const [selectedUnassociateIdp, setSelectedUnassociateIdp] = + useState(null); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [importSearchQuery, setImportSearchQuery] = useState(""); + const [importSubmitting, setImportSubmitting] = useState(false); + const [debouncedImportSearch] = useDebounce(importSearchQuery, 150); + const api = createApiClient(useEnvContext()); + const { user } = useUserContext(); const router = useRouter(); const t = useTranslations(); + const { data: adminIdpsRaw = [] } = useQuery({ + queryKey: ["admin-org-idps", user.userId], + queryFn: async () => { + const res = await api.get<{ + data: ListUserAdminOrgIdpsResponse; + }>(`/user/${user.userId}/admin-org-idps`); + return res.data.data.idps; + }, + enabled: importDialogOpen && !!user?.userId + }); + + const importableIdps = useMemo(() => { + const localIds = new Set(idps.map((i) => i.idpId)); + return adminIdpsRaw.filter( + (row) => row.orgId !== orgId && !localIds.has(row.idpId) + ); + }, [adminIdpsRaw, orgId, idps]); + + const shownImportIdps = useMemo(() => { + const q = debouncedImportSearch.trim().toLowerCase(); + if (!q) { + return importableIdps; + } + return importableIdps.filter((row) => { + const hay = `${row.orgName} ${row.name}`.toLowerCase(); + return hay.includes(q); + }); + }, [importableIdps, debouncedImportSearch]); + const deleteIdp = async (idpId: number) => { try { await api.delete(`/org/${orgId}/idp/${idpId}`); @@ -59,6 +135,49 @@ export default function IdpTable({ idps, orgId }: Props) { } }; + const importIdp = async (row: AdminIdpRow) => { + setImportSubmitting(true); + try { + await api.post(`/org/${orgId}/idp/${row.idpId}/import`, { + sourceOrgId: row.orgId + }); + toast({ + title: t("success"), + description: t("idpImportedDescription") + }); + setImportDialogOpen(false); + setImportSearchQuery(""); + router.refresh(); + router.push(`/${orgId}/settings/idp/${row.idpId}/general`); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setImportSubmitting(false); + } + }; + + const unassociateIdp = async (idpId: number) => { + try { + await api.delete(`/org/${orgId}/idp/${idpId}/association`); + toast({ + title: t("success"), + description: t("idpUnassociatedDescription") + }); + setIsUnassociateModalOpen(false); + router.refresh(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + const columns: ExtendedColumnDef[] = [ { accessorKey: "idpId", @@ -142,6 +261,14 @@ export default function IdpTable({ idps, orgId }: Props) { {t("viewSettings")} + { + setSelectedUnassociateIdp(siteRow); + setIsUnassociateModalOpen(true); + }} + > + {t("idpUnassociateMenu")} + { setSelectedIdp(siteRow); @@ -149,7 +276,7 @@ export default function IdpTable({ idps, orgId }: Props) { }} > - {t("delete")} + {t("idpDeleteAllOrgsMenu")} @@ -179,8 +306,8 @@ export default function IdpTable({ idps, orgId }: Props) { }} dialog={
-

{t("idpQuestionRemove")}

-

{t("idpMessageRemove")}

+

{t("idpDeleteGlobalQuestion")}

+

{t("idpDeleteGlobalDescription")}

} buttonText={t("idpConfirmDelete")} @@ -189,11 +316,122 @@ export default function IdpTable({ idps, orgId }: Props) { title={t("idpDelete")} /> )} + {selectedUnassociateIdp && ( + { + setIsUnassociateModalOpen(val); + setSelectedUnassociateIdp(null); + }} + dialog={ +
+

{t("idpUnassociateQuestion")}

+

{t("idpUnassociateDescription")}

+
+ } + buttonText={t("idpUnassociateConfirm")} + onConfirm={async () => + unassociateIdp(selectedUnassociateIdp.idpId) + } + string={selectedUnassociateIdp.name} + title={t("idpUnassociateTitle")} + warningText={t("idpUnassociateWarning")} + /> + )} + + { + setImportDialogOpen(open); + if (!open) { + setImportSearchQuery(""); + } + }} + > + + + + {t("idpImportDialogTitle")} + + + {t("idpImportDialogDescription")} + + + + + + + + {t("idpImportEmpty")} + + + {shownImportIdps.map((row) => ( + { + void importIdp(row); + }} + > +
+ +
+
+
+ {row.orgName} +
+
+ {row.name} +
+
+
+ ))} +
+
+
+
+ + + + + +
+
router.push(`/${orgId}/settings/idp/create`)} + addActions={[ + { + label: t("idpAddActionCreateNew"), + onSelect: () => { + router.push(`/${orgId}/settings/idp/create`); + } + }, + { + label: t("idpAddActionImportFromOrg"), + onSelect: () => { + setImportDialogOpen(true); + } + } + ]} /> ); diff --git a/src/components/idp/OidcIdpProviderTypeSelect.tsx b/src/components/idp/OidcIdpProviderTypeSelect.tsx index 4665d9c0d..038254ebe 100644 --- a/src/components/idp/OidcIdpProviderTypeSelect.tsx +++ b/src/components/idp/OidcIdpProviderTypeSelect.tsx @@ -6,8 +6,8 @@ import { } from "@app/components/StrategySelect"; import { useEnvContext } from "@app/hooks/useEnvContext"; import type { IdpOidcProviderType } from "@app/lib/idp/oidcIdpProviderDefaults"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; import { useTranslations } from "next-intl"; -import Image from "next/image"; import { useEffect, useMemo } from "react"; type Props = { @@ -32,7 +32,8 @@ export function OidcIdpProviderTypeSelect({ value, onTypeChange }: Props) { { id: "oidc", title: "OAuth2/OIDC", - description: t("idpOidcDescription") + description: t("idpOidcDescription"), + icon: } ]; if (hideTemplates) { @@ -44,29 +45,13 @@ export function OidcIdpProviderTypeSelect({ value, onTypeChange }: Props) { id: "google", title: t("idpGoogleTitle"), description: t("idpGoogleDescription"), - icon: ( - {t("idpGoogleAlt")} - ) + icon: }, { id: "azure", title: t("idpAzureTitle"), description: t("idpAzureDescription"), - icon: ( - {t("idpAzureAlt")} - ) + icon: } ]; }, [hideTemplates, t]); diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 34a35455c..1690d92a8 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -18,12 +18,14 @@ import { TableRow } from "@/components/ui/table"; import { DataTablePagination } from "@app/components/DataTablePagination"; +import type { DataTableAddAction } from "@app/components/ui/data-table"; import { Button } from "@app/components/ui/button"; import { Card, CardContent, CardHeader } from "@app/components/ui/card"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger @@ -31,7 +33,14 @@ import { import { Input } from "@app/components/ui/input"; import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; -import { Columns, Filter, Plus, RefreshCw, Search } from "lucide-react"; +import { + ChevronDown, + Columns, + Filter, + Plus, + RefreshCw, + Search +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; @@ -67,6 +76,8 @@ type ControlledDataTableProps = { tableId: string; addButtonText?: string; onAdd?: () => void; + addActions?: DataTableAddAction[]; + addButtonDisabled?: boolean; onRefresh?: () => void; isRefreshing?: boolean; refreshButtonDisabled?: boolean; @@ -90,6 +101,8 @@ export function ControlledDataTable({ rows, addButtonText, onAdd, + addActions, + addButtonDisabled = false, onRefresh, isRefreshing, refreshButtonDisabled = false, @@ -348,16 +361,49 @@ export function ControlledDataTable({ )} - {onAdd && addButtonText && ( + {addActions && addActions.length > 0 && addButtonText ? (
- + + + + + + {addActions.map((action, i) => ( + + action.onSelect() + } + > + {action.label} + + ))} + +
+ ) : ( + onAdd && + addButtonText && ( +
+ +
+ ) )} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index c62afd329..cf252f3ea 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -33,7 +33,7 @@ import { Button } from "@app/components/ui/button"; import { useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Plus, Search, RefreshCw, Columns, Filter } from "lucide-react"; +import { ChevronDown, Plus, Search, RefreshCw, Columns, Filter } from "lucide-react"; import { Card, CardContent, @@ -46,6 +46,7 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger @@ -165,12 +166,20 @@ export type DataTablePaginationState = PaginationState & { export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void; +/** When set (non-empty), replaces the single add button with a dropdown; `onAdd` is not used. */ +export type DataTableAddAction = { + label: string; + onSelect: () => void; +}; + type DataTableProps = { columns: ExtendedColumnDef[]; data: TData[]; title?: string; addButtonText?: string; onAdd?: () => void; + /** Prefer over `onAdd` when non-empty. */ + addActions?: DataTableAddAction[]; addButtonDisabled?: boolean; onRefresh?: () => void; isRefreshing?: boolean; @@ -205,6 +214,7 @@ export function DataTable({ title, addButtonText, onAdd, + addActions, addButtonDisabled = false, onRefresh, isRefreshing, @@ -637,13 +647,45 @@ export function DataTable({ )} - {onAdd && addButtonText && ( + {addActions && addActions.length > 0 && addButtonText ? (
- + + + + + + {addActions.map((action, i) => ( + + action.onSelect() + } + > + {action.label} + + ))} + +
+ ) : ( + onAdd && + addButtonText && ( +
+ +
+ ) )} From 707cc4b2758b988101c2e98147f8d42a69bcbcc0 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 16 Apr 2026 21:00:48 -0700 Subject: [PATCH 2/6] move idp mode check to a middleware --- server/private/routers/external.ts | 5 +++ .../routers/orgIdp/createOrgOidcIdp.ts | 13 ------- server/private/routers/orgIdp/deleteOrgIdp.ts | 13 ------- server/private/routers/orgIdp/importOrgIdp.ts | 13 ------- server/private/routers/orgIdp/index.ts | 1 + .../orgIdp/requireOrgIdentityProviderMode.ts | 34 +++++++++++++++++++ .../routers/orgIdp/unassociateOrgIdp.ts | 13 ------- .../routers/orgIdp/updateOrgOidcIdp.ts | 13 ------- 8 files changed, 40 insertions(+), 65 deletions(-) create mode 100644 server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 53960b743..7872da700 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -89,6 +89,7 @@ authenticated.put( "/org/:orgId/idp/oidc", verifyValidLicense, verifyValidSubscription(tierMatrix.orgOidc), + orgIdp.requireOrgIdentityProviderMode, verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createIdp), @@ -100,6 +101,7 @@ authenticated.post( "/org/:orgId/idp/:idpId/import", verifyValidLicense, verifyValidSubscription(tierMatrix.orgOidc), + orgIdp.requireOrgIdentityProviderMode, verifyOrgAccess, verifyLimits, verifyAdmin, @@ -111,6 +113,7 @@ authenticated.post( "/org/:orgId/idp/:idpId/oidc", verifyValidLicense, verifyValidSubscription(tierMatrix.orgOidc), + orgIdp.requireOrgIdentityProviderMode, verifyOrgAccess, verifyIdpAccess, verifyLimits, @@ -122,6 +125,7 @@ authenticated.post( authenticated.delete( "/org/:orgId/idp/:idpId", verifyValidLicense, + orgIdp.requireOrgIdentityProviderMode, verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.deleteIdp), @@ -132,6 +136,7 @@ authenticated.delete( authenticated.delete( "/org/:orgId/idp/:idpId/association", verifyValidLicense, + orgIdp.requireOrgIdentityProviderMode, verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.deleteIdp), diff --git a/server/private/routers/orgIdp/createOrgOidcIdp.ts b/server/private/routers/orgIdp/createOrgOidcIdp.ts index b14348a2a..e01d27607 100644 --- a/server/private/routers/orgIdp/createOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/createOrgOidcIdp.ts @@ -27,7 +27,6 @@ import config from "@server/lib/config"; import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types"; import { isSubscribed } from "#private/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import privateConfig from "#private/lib/config"; import { build } from "@server/build"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); @@ -94,18 +93,6 @@ export async function createOrgOidcIdp( ); } - if ( - privateConfig.getRawPrivateConfig().app.identity_provider_mode !== - "org" - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." - ) - ); - } - const { clientId, clientSecret, diff --git a/server/private/routers/orgIdp/deleteOrgIdp.ts b/server/private/routers/orgIdp/deleteOrgIdp.ts index 304826cd1..9e5dfccee 100644 --- a/server/private/routers/orgIdp/deleteOrgIdp.ts +++ b/server/private/routers/orgIdp/deleteOrgIdp.ts @@ -22,7 +22,6 @@ import { fromError } from "zod-validation-error"; import { idp, idpOidcConfig, idpOrg } from "@server/db"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import privateConfig from "#private/lib/config"; const paramsSchema = z .object({ @@ -60,18 +59,6 @@ export async function deleteOrgIdp( const { idpId } = parsedParams.data; - if ( - privateConfig.getRawPrivateConfig().app.identity_provider_mode !== - "org" - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." - ) - ); - } - // Check if IDP exists const [existingIdp] = await db .select() diff --git a/server/private/routers/orgIdp/importOrgIdp.ts b/server/private/routers/orgIdp/importOrgIdp.ts index 906c504d8..88c62cbac 100644 --- a/server/private/routers/orgIdp/importOrgIdp.ts +++ b/server/private/routers/orgIdp/importOrgIdp.ts @@ -24,7 +24,6 @@ import { idp, idpOrg, orgs, roles, userOrgs } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; -import privateConfig from "#private/lib/config"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; @@ -105,18 +104,6 @@ export async function importOrgIdp( const { sourceOrgId } = parsedBody.data; - if ( - privateConfig.getRawPrivateConfig().app.identity_provider_mode !== - "org" - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." - ) - ); - } - if (sourceOrgId === targetOrgId) { return next( createHttpError( diff --git a/server/private/routers/orgIdp/index.ts b/server/private/routers/orgIdp/index.ts index 2997468be..192d883a6 100644 --- a/server/private/routers/orgIdp/index.ts +++ b/server/private/routers/orgIdp/index.ts @@ -19,3 +19,4 @@ export * from "./listUserAdminOrgIdps"; export * from "./updateOrgOidcIdp"; export * from "./deleteOrgIdp"; export * from "./unassociateOrgIdp"; +export * from "./requireOrgIdentityProviderMode"; diff --git a/server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts b/server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts new file mode 100644 index 000000000..7942af123 --- /dev/null +++ b/server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts @@ -0,0 +1,34 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import privateConfig from "#private/lib/config"; +import HttpCode from "@server/types/HttpCode"; + +export function requireOrgIdentityProviderMode( + _req: Request, + _res: Response, + next: NextFunction +): void { + if (privateConfig.getRawPrivateConfig().app.identity_provider_mode !== "org") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." + ) + ); + } + + return next(); +} diff --git a/server/private/routers/orgIdp/unassociateOrgIdp.ts b/server/private/routers/orgIdp/unassociateOrgIdp.ts index e6b0a6a2e..f6ab557b3 100644 --- a/server/private/routers/orgIdp/unassociateOrgIdp.ts +++ b/server/private/routers/orgIdp/unassociateOrgIdp.ts @@ -21,7 +21,6 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { and, eq, sql } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import privateConfig from "#private/lib/config"; const paramsSchema = z .object({ @@ -48,18 +47,6 @@ export async function unassociateOrgIdp( const { orgId, idpId } = parsedParams.data; - if ( - privateConfig.getRawPrivateConfig().app.identity_provider_mode !== - "org" - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." - ) - ); - } - const [association] = await db .select() .from(idpOrg) diff --git a/server/private/routers/orgIdp/updateOrgOidcIdp.ts b/server/private/routers/orgIdp/updateOrgOidcIdp.ts index 17bf2ee35..4043369b3 100644 --- a/server/private/routers/orgIdp/updateOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/updateOrgOidcIdp.ts @@ -26,7 +26,6 @@ import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import { isSubscribed } from "#private/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import privateConfig from "#private/lib/config"; import { build } from "@server/build"; const paramsSchema = z @@ -99,18 +98,6 @@ export async function updateOrgOidcIdp( ); } - if ( - privateConfig.getRawPrivateConfig().app.identity_provider_mode !== - "org" - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." - ) - ); - } - const { idpId, orgId } = parsedParams.data; const { clientId, From 796d14a9e419759a3139680b5678279bd789208a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 16 Apr 2026 22:12:15 -0700 Subject: [PATCH 3/6] support org mapping on org idp --- messages/en-US.json | 4 +- .../routers/orgIdp/createOrgOidcIdp.ts | 9 +- .../routers/orgIdp/updateOrgOidcIdp.ts | 17 ++- .../(private)/idp/[idpId]/general/page.tsx | 23 ++- .../settings/(private)/idp/create/page.tsx | 16 +- src/app/admin/idp/[idpId]/policies/page.tsx | 71 +++------ src/components/AutoProvisionConfigWidget.tsx | 139 +++++++++++++----- src/components/RoleMappingConfigFields.tsx | 26 ++-- 8 files changed, 189 insertions(+), 116 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index abc4f2928..3d706bfff 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -949,7 +949,7 @@ "defaultMappingsRole": "Default Role Mapping", "defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.", "defaultMappingsOrg": "Default Organization Mapping", - "defaultMappingsOrgDescription": "When set, this expression must return the organization ID or true for the user to access that organization. When unset, defining an organization policy for that org is enough: the user is allowed in as long as a valid role mapping can be resolved for them within the organization.", + "defaultMappingsOrgDescription": "When set, this expression must return the organization ID or true for the user to access that organization. When unset, defining a role mapping is enough: the user is allowed in as long as a valid role mapping can be resolved for them within the organization.", "defaultMappingsSubmit": "Save Default Mappings", "orgPoliciesEdit": "Edit Organization Policy", "org": "Organization", @@ -2026,7 +2026,7 @@ }, "internationaldomaindetected": "International Domain Detected", "willbestoredas": "Will be stored as:", - "roleMappingDescription": "Determine how roles are assigned to users when they sign in when Auto Provision is enabled.", + "roleMappingDescription": "Determine how roles are assigned to users when they sign in with this identity provider.", "selectRole": "Select a Role", "roleMappingExpression": "Expression", "selectRolePlaceholder": "Choose a role", diff --git a/server/private/routers/orgIdp/createOrgOidcIdp.ts b/server/private/routers/orgIdp/createOrgOidcIdp.ts index e01d27607..2b946d956 100644 --- a/server/private/routers/orgIdp/createOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/createOrgOidcIdp.ts @@ -44,6 +44,7 @@ const bodySchema = z.strictObject({ autoProvision: z.boolean().optional(), variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"), roleMapping: z.string().optional(), + orgMapping: z.string().nullish(), tags: z.string().optional() }); @@ -105,6 +106,7 @@ export async function createOrgOidcIdp( name, variant, roleMapping, + orgMapping: orgMappingBody, tags } = parsedBody.data; @@ -152,11 +154,16 @@ export async function createOrgOidcIdp( variant }); + const orgMapping = + orgMappingBody !== undefined + ? orgMappingBody + : `'${orgId}'`; + await trx.insert(idpOrg).values({ idpId: idpRes.idpId, orgId: orgId, roleMapping: roleMapping || null, - orgMapping: `'${orgId}'` + orgMapping }); }); diff --git a/server/private/routers/orgIdp/updateOrgOidcIdp.ts b/server/private/routers/orgIdp/updateOrgOidcIdp.ts index 4043369b3..7c379f8ec 100644 --- a/server/private/routers/orgIdp/updateOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/updateOrgOidcIdp.ts @@ -47,6 +47,7 @@ const bodySchema = z.strictObject({ scopes: z.string().optional(), autoProvision: z.boolean().optional(), roleMapping: z.string().optional(), + orgMapping: z.string().nullish(), tags: z.string().optional() }); @@ -110,6 +111,7 @@ export async function updateOrgOidcIdp( namePath, name, roleMapping, + orgMapping, tags } = parsedBody.data; @@ -205,13 +207,20 @@ export async function updateOrgOidcIdp( .where(eq(idpOidcConfig.idpId, idpId)); } + const idpOrgPolicyPatch: { + roleMapping?: string; + orgMapping?: string | null; + } = {}; if (roleMapping !== undefined) { - // Update IdP-org policy + idpOrgPolicyPatch.roleMapping = roleMapping; + } + if (orgMapping !== undefined) { + idpOrgPolicyPatch.orgMapping = orgMapping; + } + if (Object.keys(idpOrgPolicyPatch).length > 0) { await trx .update(idpOrg) - .set({ - roleMapping - }) + .set(idpOrgPolicyPatch) .where( and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)) ); diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx index 37334e342..90b89f76f 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -97,7 +97,8 @@ export default function GeneralPage() { emailPath: z.string().nullable().optional(), namePath: z.string().nullable().optional(), scopes: z.string().min(1, { message: t("idpScopeRequired") }), - autoProvision: z.boolean().default(false) + autoProvision: z.boolean().default(false), + orgMapping: z.string().optional() }); // Google form schema (simplified) @@ -109,7 +110,8 @@ export default function GeneralPage() { .min(1, { message: t("idpClientSecretRequired") }), roleMapping: z.string().nullable().optional(), roleId: z.number().nullable().optional(), - autoProvision: z.boolean().default(false) + autoProvision: z.boolean().default(false), + orgMapping: z.string().optional() }); // Azure form schema (simplified with tenant ID) @@ -122,7 +124,8 @@ export default function GeneralPage() { tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }), roleMapping: z.string().nullable().optional(), roleId: z.number().nullable().optional(), - autoProvision: z.boolean().default(false) + autoProvision: z.boolean().default(false), + orgMapping: z.string().optional() }); type OidcFormValues = z.infer; @@ -160,7 +163,8 @@ export default function GeneralPage() { autoProvision: true, roleMapping: null, roleId: null, - tenantId: "" + tenantId: "", + orgMapping: "" } }); @@ -227,7 +231,8 @@ export default function GeneralPage() { clientSecret: data.idpOidcConfig.clientSecret, autoProvision: data.idp.autoProvision, roleMapping: roleMapping || null, - roleId: null + roleId: null, + orgMapping: data.idpOrg?.orgMapping ?? "" }; // Add variant-specific fields @@ -344,12 +349,14 @@ export default function GeneralPage() { } // Build payload based on variant + const orgMappingTrimmed = data.orgMapping?.trim() ?? ""; let payload: any = { name: data.name, clientId: data.clientId, clientSecret: data.clientSecret, autoProvision: data.autoProvision, - roleMapping: roleMappingExpression + roleMapping: roleMappingExpression, + orgMapping: orgMappingTrimmed === "" ? null : orgMappingTrimmed }; // Add variant-specific fields @@ -532,6 +539,10 @@ export default function GeneralPage() { } rawExpression={rawRoleExpression} onRawExpressionChange={setRawRoleExpression} + orgMappingField={{ + control: form.control, + name: "orgMapping" + }} /> diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx index 10d86b976..a7796e2a9 100644 --- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx @@ -91,7 +91,8 @@ export default function Page() { tenantId: z.string().optional(), autoProvision: z.boolean().default(false), roleMapping: z.string().nullable().optional(), - roleId: z.number().nullable().optional() + roleId: z.number().nullable().optional(), + orgMapping: z.string().optional() }); type CreateIdpFormValues = z.infer; @@ -112,7 +113,8 @@ export default function Page() { tenantId: "", autoProvision: false, roleMapping: null, - roleId: null + roleId: null, + orgMapping: "" } }); @@ -177,7 +179,7 @@ export default function Page() { return; } - const payload = { + const payload: Record = { name: data.name, clientId: data.clientId, clientSecret: data.clientSecret, @@ -191,6 +193,10 @@ export default function Page() { scopes: data.scopes, variant: data.type }; + const trimmedOrgMapping = data.orgMapping?.trim(); + if (trimmedOrgMapping) { + payload.orgMapping = trimmedOrgMapping; + } // Use the appropriate endpoint based on provider type const endpoint = "oidc"; @@ -336,6 +342,10 @@ export default function Page() { } rawExpression={rawRoleExpression} onRawExpressionChange={setRawRoleExpression} + orgMappingField={{ + control: form.control, + name: "orgMapping" + }} /> diff --git a/src/app/admin/idp/[idpId]/policies/page.tsx b/src/app/admin/idp/[idpId]/policies/page.tsx index 60e8a094a..e9438da33 100644 --- a/src/app/admin/idp/[idpId]/policies/page.tsx +++ b/src/app/admin/idp/[idpId]/policies/page.tsx @@ -20,7 +20,6 @@ import { import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, @@ -63,7 +62,7 @@ import { SettingsSectionForm } from "@app/components/Settings"; import { useTranslations } from "next-intl"; -import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; +import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; import { compileRoleMappingExpression, createMappingBuilderRule, @@ -499,9 +498,17 @@ export default function PoliciesPage() { id="policy-default-mappings-form" className="space-y-6" > - {}} + orgMappingField={{ + control: defaultMappingsForm.control, + name: "defaultOrgMapping", + labelKey: "defaultMappingsOrg" + }} + roleMappingFieldIdPrefix="admin-idp-default-role" + showFreeformRoleNamesHint roleMappingMode={defaultRoleMappingMode} onRoleMappingModeChange={ setDefaultRoleMappingMode @@ -528,27 +535,6 @@ export default function PoliciesPage() { setDefaultRawRoleExpression } /> - - ( - - - {t("defaultMappingsOrg")} - - - - - - {t( - "defaultMappingsOrgDescription" - )} - - - - )} - /> @@ -687,9 +673,15 @@ export default function PoliciesPage() { )} /> - {}} + orgMappingField={{ + control: form.control, + name: "orgMapping" + }} + roleMappingFieldIdPrefix="admin-idp-policy-role" roleMappingMode={policyRoleMappingMode} onRoleMappingModeChange={ setPolicyRoleMappingMode @@ -716,27 +708,6 @@ export default function PoliciesPage() { setPolicyRawRoleExpression } /> - - ( - - - {t("orgMappingPathOptional")} - - - - - - {t( - "defaultMappingsOrgDescription" - )} - - - - )} - /> diff --git a/src/components/AutoProvisionConfigWidget.tsx b/src/components/AutoProvisionConfigWidget.tsx index d4df3f50d..4767544d0 100644 --- a/src/components/AutoProvisionConfigWidget.tsx +++ b/src/components/AutoProvisionConfigWidget.tsx @@ -1,19 +1,33 @@ "use client"; -import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; -import { FormDescription } from "@app/components/ui/form"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; import { SwitchInput } from "@app/components/SwitchInput"; -import { useTranslations } from "next-intl"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping"; -import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; +import { useTranslations } from "next-intl"; +import type { Control } from "react-hook-form"; type Role = { roleId: number; name: string; }; +export type IdpOrgMappingFieldBinding = { + control: unknown; + name: string; + labelKey?: string; +}; + type AutoProvisionConfigWidgetProps = { autoProvision: boolean; onAutoProvisionChange: (checked: boolean) => void; @@ -28,6 +42,11 @@ type AutoProvisionConfigWidgetProps = { onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void; rawExpression: string; onRawExpressionChange: (expression: string) => void; + orgMappingField: IdpOrgMappingFieldBinding; + showAutoProvisionSwitch?: boolean; + roleMappingFieldIdPrefix?: string; + showFreeformRoleNamesHint?: boolean; + autoProvisionSwitchId?: string; }; export default function AutoProvisionConfigWidget({ @@ -43,41 +62,95 @@ export default function AutoProvisionConfigWidget({ mappingBuilderRules, onMappingBuilderRulesChange, rawExpression, - onRawExpressionChange + onRawExpressionChange, + orgMappingField, + showAutoProvisionSwitch = true, + roleMappingFieldIdPrefix = "org-idp-auto-provision", + showFreeformRoleNamesHint = false, + autoProvisionSwitchId = "auto-provision-toggle" }: AutoProvisionConfigWidgetProps) { const t = useTranslations(); const { isPaidUser } = usePaidStatus(); + const showMappingTabs = showAutoProvisionSwitch === false || autoProvision; + + const orgMappingLabelKey = + orgMappingField.labelKey ?? "orgMappingPathOptional"; + return (
-
- -
+ {showAutoProvisionSwitch && ( +
+ +
+ )} - {autoProvision && ( - + {showMappingTabs && ( + +
+ +
+
+
+

+ {t("defaultMappingsOrgDescription")} +

+ + } + name={orgMappingField.name} + render={({ field }) => ( + + + {t(orgMappingLabelKey)} + + + + + + + )} + /> +
+
+
)}
); diff --git a/src/components/RoleMappingConfigFields.tsx b/src/components/RoleMappingConfigFields.tsx index 12790d4aa..d62b7f9e8 100644 --- a/src/components/RoleMappingConfigFields.tsx +++ b/src/components/RoleMappingConfigFields.tsx @@ -79,10 +79,7 @@ export default function RoleMappingConfigFields({ ); useEffect(() => { - if ( - !supportsMultipleRolesPerUser && - mappingBuilderRules.length > 1 - ) { + if (!supportsMultipleRolesPerUser && mappingBuilderRules.length > 1) { onMappingBuilderRulesChange([mappingBuilderRules[0]]); } }, [ @@ -95,11 +92,7 @@ export default function RoleMappingConfigFields({ if (!supportsMultipleRolesPerUser && fixedRoleNames.length > 1) { onFixedRoleNamesChange([fixedRoleNames[0]]); } - }, [ - supportsMultipleRolesPerUser, - fixedRoleNames, - onFixedRoleNamesChange - ]); + }, [supportsMultipleRolesPerUser, fixedRoleNames, onFixedRoleNamesChange]); const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`; const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`; @@ -116,7 +109,6 @@ export default function RoleMappingConfigFields({ return (
- {t("roleMapping")} {t("roleMappingDescription")} @@ -272,7 +264,9 @@ export default function RoleMappingConfigFields({ supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser } - showRemoveButton={mappingBuilderShowsRemoveColumn} + showRemoveButton={ + mappingBuilderShowsRemoveColumn + } rule={rule} onChange={(nextRule) => { const nextRules = mappingBuilderRules.map( @@ -390,12 +384,10 @@ function BuilderRuleRow({ text: name }))} setTags={(nextTags) => { - const prevRoleTags = rule.roleNames.map( - (name) => ({ - id: name, - text: name - }) - ); + const prevRoleTags = rule.roleNames.map((name) => ({ + id: name, + text: name + })); const next = typeof nextTags === "function" ? nextTags(prevRoleTags) From 83244458953086aedb52d756d12a12ce2d4638b4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 17 Apr 2026 10:59:24 -0700 Subject: [PATCH 4/6] add auto provsion section back to create global idp --- messages/en-US.json | 1 + src/app/admin/idp/create/page.tsx | 45 ++++++++++++++++++------------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 3d706bfff..ff09dc4fd 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -898,6 +898,7 @@ "idpDisplayName": "A display name for this identity provider", "idpAutoProvisionUsers": "Auto Provision Users", "idpAutoProvisionUsersDescription": "When enabled, users will be automatically created in the system upon first login with the ability to map users to roles and organizations.", + "idpAutoProvisionConfigureAfterCreate": "You can configure auto provision settings once the identity provider is created.", "licenseBadge": "EE", "idpType": "Provider Type", "idpTypeDescription": "Select the type of identity provider you want to configure", diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx index 82036c510..6e3270a55 100644 --- a/src/app/admin/idp/create/page.tsx +++ b/src/app/admin/idp/create/page.tsx @@ -24,7 +24,6 @@ import { import HeaderTitle from "@app/components/SettingsSectionTitle"; import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { SwitchInput } from "@app/components/SwitchInput"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; import { Input } from "@app/components/ui/input"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; @@ -34,7 +33,6 @@ import { createApiClient, formatAxiosError } from "@app/lib/api"; import { applyOidcIdpProviderType } from "@app/lib/idp/oidcIdpProviderDefaults"; import { zodResolver } from "@hookform/resolvers/zod"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { InfoIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -220,23 +218,6 @@ export default function Page() { )} /> -
- { - form.setValue( - "autoProvision", - checked - ); - }} - /> -
@@ -244,6 +225,32 @@ export default function Page() { + + + + {t("idpAutoProvisionUsers")} + + + + + + +
+ { + form.setValue("autoProvision", checked); + }} + /> +

+ {t("idpAutoProvisionConfigureAfterCreate")} +

+
+
+
+
Date: Fri, 17 Apr 2026 17:14:21 -0700 Subject: [PATCH 5/6] dont allow import idp if not paid --- src/components/OrgIdpTable.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/OrgIdpTable.tsx b/src/components/OrgIdpTable.tsx index ebcdca334..0e3a83dc2 100644 --- a/src/components/OrgIdpTable.tsx +++ b/src/components/OrgIdpTable.tsx @@ -50,6 +50,8 @@ import { useQuery } from "@tanstack/react-query"; import { useDebounce } from "use-debounce"; import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; import { cn } from "@app/lib/cn"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; export type IdpRow = { idpId: number; @@ -85,9 +87,12 @@ export default function IdpTable({ idps, orgId }: Props) { const api = createApiClient(useEnvContext()); const { user } = useUserContext(); + const { isPaidUser } = usePaidStatus(); const router = useRouter(); const t = useTranslations(); + const canImportOrgOidcIdp = isPaidUser(tierMatrix.orgOidc); + const { data: adminIdpsRaw = [] } = useQuery({ queryKey: ["admin-org-idps", user.userId], queryFn: async () => { @@ -378,7 +383,11 @@ export default function IdpTable({ idps, orgId }: Props) { key={`${row.idpId}:${row.orgId}`} className="items-start gap-3 py-2.5" value={`${row.idpId}:${row.orgId}:${row.orgName}:${row.name}`} + disabled={!canImportOrgOidcIdp} onSelect={() => { + if (!canImportOrgOidcIdp) { + return; + } void importIdp(row); }} > From a569054e94f3b887a067515895eed8d1fa4865f9 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 17 Apr 2026 17:25:21 -0700 Subject: [PATCH 6/6] dont set org mapping by default --- server/private/routers/orgIdp/createOrgOidcIdp.ts | 7 +------ server/private/routers/orgIdp/importOrgIdp.ts | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/server/private/routers/orgIdp/createOrgOidcIdp.ts b/server/private/routers/orgIdp/createOrgOidcIdp.ts index 2b946d956..97928d99f 100644 --- a/server/private/routers/orgIdp/createOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/createOrgOidcIdp.ts @@ -154,16 +154,11 @@ export async function createOrgOidcIdp( variant }); - const orgMapping = - orgMappingBody !== undefined - ? orgMappingBody - : `'${orgId}'`; - await trx.insert(idpOrg).values({ idpId: idpRes.idpId, orgId: orgId, roleMapping: roleMapping || null, - orgMapping + orgMapping: orgMappingBody }); }); diff --git a/server/private/routers/orgIdp/importOrgIdp.ts b/server/private/routers/orgIdp/importOrgIdp.ts index 88c62cbac..0620a77ed 100644 --- a/server/private/routers/orgIdp/importOrgIdp.ts +++ b/server/private/routers/orgIdp/importOrgIdp.ts @@ -188,7 +188,7 @@ export async function importOrgIdp( idpId, orgId: targetOrgId, roleMapping: null, - orgMapping: `'${targetOrgId}'` + orgMapping: null }); const redirectUrl = await generateOidcRedirectUrl(idpId, targetOrgId);