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)