support policy buildiner in global idp

This commit is contained in:
miloschwartz
2026-03-27 17:35:35 -07:00
parent ad7d68d2b4
commit bea20674a8
7 changed files with 703 additions and 471 deletions

View File

@@ -509,6 +509,7 @@
"userSaved": "User saved", "userSaved": "User saved",
"userSavedDescription": "The user has been updated.", "userSavedDescription": "The user has been updated.",
"autoProvisioned": "Auto Provisioned", "autoProvisioned": "Auto Provisioned",
"autoProvisionSettings": "Auto Provision Settings",
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider", "autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
"accessControlsDescription": "Manage what this user can access and do in the organization", "accessControlsDescription": "Manage what this user can access and do in the organization",
"accessControlsSubmit": "Save Access Controls", "accessControlsSubmit": "Save Access Controls",
@@ -1042,7 +1043,6 @@
"pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.", "pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.",
"overview": "Overview", "overview": "Overview",
"home": "Home", "home": "Home",
"accessControl": "Access Control",
"settings": "Settings", "settings": "Settings",
"usersAll": "All Users", "usersAll": "All Users",
"license": "License", "license": "License",
@@ -1942,6 +1942,24 @@
"invalidValue": "Invalid value", "invalidValue": "Invalid value",
"idpTypeLabel": "Identity Provider Type", "idpTypeLabel": "Identity Provider Type",
"roleMappingExpressionPlaceholder": "e.g., contains(groups, 'admin') && 'Admin' || 'Member'", "roleMappingExpressionPlaceholder": "e.g., contains(groups, 'admin') && 'Admin' || 'Member'",
"roleMappingModeFixedRoles": "Fixed roles",
"roleMappingModeMappingBuilder": "Mapping builder",
"roleMappingModeRawExpression": "Raw expression",
"roleMappingFixedRolesPlaceholderSelect": "Select one or more roles",
"roleMappingFixedRolesPlaceholderFreeform": "Type role names (exact match per organization)",
"roleMappingFixedRolesDescriptionSameForAll": "Assign the same role set to every auto-provisioned user.",
"roleMappingFixedRolesDescriptionDefaultPolicy": "For default policies, type role names that exist in each organization where users are provisioned. Names must match exactly.",
"roleMappingClaimPath": "Claim path",
"roleMappingClaimPathPlaceholder": "groups",
"roleMappingClaimPathDescription": "Path in the token payload that contains source values (for example, groups).",
"roleMappingMatchValue": "Match value",
"roleMappingAssignRoles": "Assign roles",
"roleMappingAddMappingRule": "Add mapping rule",
"roleMappingRawExpressionResultDescription": "Expression must evaluate to a string or string array.",
"roleMappingMatchValuePlaceholder": "Match value (for example: admin)",
"roleMappingAssignRolesPlaceholderFreeform": "Type role names (exact per org)",
"roleMappingBuilderFreeformRowHint": "Role names must match a role in each target organization.",
"roleMappingRemoveRule": "Remove",
"idpGoogleConfiguration": "Google Configuration", "idpGoogleConfiguration": "Google Configuration",
"idpGoogleConfigurationDescription": "Configure the Google OAuth2 credentials", "idpGoogleConfigurationDescription": "Configure the Google OAuth2 credentials",
"idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientIdDescription": "Google OAuth2 Client ID",
@@ -2514,9 +2532,9 @@
"remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?", "remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?",
"remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials.", "remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials.",
"agent": "Agent", "agent": "Agent",
"personalUseOnly": "Personal Use Only", "personalUseOnly": "Personal Use Only",
"loginPageLicenseWatermark": "This instance is licensed for personal use only.", "loginPageLicenseWatermark": "This instance is licensed for personal use only.",
"instanceIsUnlicensed": "This instance is unlicensed.", "instanceIsUnlicensed": "This instance is unlicensed.",
"portRestrictions": "Port Restrictions", "portRestrictions": "Port Restrictions",
"allPorts": "All", "allPorts": "All",
"custom": "Custom", "custom": "Custom",
@@ -2570,7 +2588,7 @@
"automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.", "automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.",
"forced": "Forced", "forced": "Forced",
"forcedModeDescription": "Always show the maintenance page regardless of backend health. Use this for planned maintenance when you want to prevent all access.", "forcedModeDescription": "Always show the maintenance page regardless of backend health. Use this for planned maintenance when you want to prevent all access.",
"warning:" : "Warning:", "warning:": "Warning:",
"forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.", "forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.",
"pageTitle": "Page Title", "pageTitle": "Page Title",
"pageTitleDescription": "The main heading displayed on the maintenance page", "pageTitleDescription": "The main heading displayed on the maintenance page",
@@ -2687,5 +2705,6 @@
"approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.", "approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.",
"approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review", "approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
"approvalsEmptyStateButtonText": "Manage Roles", "approvalsEmptyStateButtonText": "Manage Roles",
"domainErrorTitle": "We are having trouble verifying your domain" "domainErrorTitle": "We are having trouble verifying your domain",
"idpAdminAutoProvisionPoliciesTabHint": "Configure role mapping and organization policies on the <policiesTabLink>Auto Provision Settings</policiesTabLink> tab."
} }

View File

@@ -15,7 +15,8 @@ import {
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useRouter, useParams, redirect } from "next/navigation"; import { useRouter, useParams } from "next/navigation";
import Link from "next/link";
import { import {
SettingsContainer, SettingsContainer,
SettingsSection, SettingsSection,
@@ -189,15 +190,6 @@ export default function GeneralPage() {
</InfoSection> </InfoSection>
</InfoSections> </InfoSections>
<Alert variant="neutral" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("redirectUrlAbout")}
</AlertTitle>
<AlertDescription>
{t("redirectUrlAboutDescription")}
</AlertDescription>
</Alert>
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
@@ -239,9 +231,32 @@ export default function GeneralPage() {
}} }}
/> />
</div> </div>
<span className="text-sm text-muted-foreground"> <div className="flex flex-col gap-2">
{t("idpAutoProvisionUsersDescription")} <span className="text-sm text-muted-foreground">
</span> {t(
"idpAutoProvisionUsersDescription"
)}
</span>
{form.watch("autoProvision") && (
<FormDescription>
{t.rich(
"idpAdminAutoProvisionPoliciesTabHint",
{
policiesTabLink: (
chunks
) => (
<Link
href={`/admin/idp/${idpId}/policies`}
className="text-primary hover:underline inline-flex items-center gap-1"
>
{chunks}
</Link>
)
}
)}
</FormDescription>
)}
</div>
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
@@ -375,29 +390,6 @@ export default function GeneralPage() {
className="space-y-4" className="space-y-4"
id="general-settings-form" id="general-settings-form"
> >
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("idpJmespathAbout")}
</AlertTitle>
<AlertDescription>
{t(
"idpJmespathAboutDescription"
)}
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
{t(
"idpJmespathAboutDescriptionLink"
)}{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<FormField <FormField
control={form.control} control={form.control}
name="identifierPath" name="identifierPath"

View File

@@ -34,7 +34,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
href: `/admin/idp/${params.idpId}/general` href: `/admin/idp/${params.idpId}/general`
}, },
{ {
title: t("orgPolicies"), title: t("autoProvisionSettings"),
href: `/admin/idp/${params.idpId}/policies` href: `/admin/idp/${params.idpId}/policies`
} }
]; ];

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams } from "next/navigation";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
@@ -34,6 +34,7 @@ import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react";
import PolicyTable, { PolicyRow } from "../../../../../components/PolicyTable"; import PolicyTable, { PolicyRow } from "../../../../../components/PolicyTable";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { ListOrgsResponse } from "@server/routers/org"; import { ListOrgsResponse } from "@server/routers/org";
import { ListRolesResponse } from "@server/routers/role";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -50,8 +51,6 @@ import {
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { CaretSortIcon } from "@radix-ui/react-icons"; import { CaretSortIcon } from "@radix-ui/react-icons";
import Link from "next/link"; import Link from "next/link";
import { Textarea } from "@app/components/ui/textarea";
import { InfoPopup } from "@app/components/ui/info-popup";
import { GetIdpResponse } from "@server/routers/idp"; import { GetIdpResponse } from "@server/routers/idp";
import { import {
SettingsContainer, SettingsContainer,
@@ -64,16 +63,40 @@ import {
SettingsSectionForm SettingsSectionForm
} from "@app/components/Settings"; } from "@app/components/Settings";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
import {
compileRoleMappingExpression,
createMappingBuilderRule,
defaultRoleMappingConfig,
detectRoleMappingConfig,
MappingBuilderRule,
RoleMappingMode
} from "@app/lib/idpRoleMapping";
type Organization = { type Organization = {
orgId: string; orgId: string;
name: string; name: string;
}; };
function resetRoleMappingStateFromDetected(
setMode: (m: RoleMappingMode) => void,
setFixed: (v: string[]) => void,
setClaim: (v: string) => void,
setRules: (v: MappingBuilderRule[]) => void,
setRaw: (v: string) => void,
stored: string | null | undefined
) {
const d = detectRoleMappingConfig(stored);
setMode(d.mode);
setFixed(d.fixedRoleNames);
setClaim(d.mappingBuilder.claimPath);
setRules(d.mappingBuilder.rules);
setRaw(d.rawExpression);
}
export default function PoliciesPage() { export default function PoliciesPage() {
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const router = useRouter();
const { idpId } = useParams(); const { idpId } = useParams();
const t = useTranslations(); const t = useTranslations();
@@ -88,14 +111,39 @@ export default function PoliciesPage() {
const [showAddDialog, setShowAddDialog] = useState(false); const [showAddDialog, setShowAddDialog] = useState(false);
const [editingPolicy, setEditingPolicy] = useState<PolicyRow | null>(null); const [editingPolicy, setEditingPolicy] = useState<PolicyRow | null>(null);
const [defaultRoleMappingMode, setDefaultRoleMappingMode] =
useState<RoleMappingMode>("fixedRoles");
const [defaultFixedRoleNames, setDefaultFixedRoleNames] = useState<
string[]
>([]);
const [defaultMappingBuilderClaimPath, setDefaultMappingBuilderClaimPath] =
useState("groups");
const [defaultMappingBuilderRules, setDefaultMappingBuilderRules] =
useState<MappingBuilderRule[]>([createMappingBuilderRule()]);
const [defaultRawRoleExpression, setDefaultRawRoleExpression] =
useState("");
const [policyRoleMappingMode, setPolicyRoleMappingMode] =
useState<RoleMappingMode>("fixedRoles");
const [policyFixedRoleNames, setPolicyFixedRoleNames] = useState<string[]>(
[]
);
const [policyMappingBuilderClaimPath, setPolicyMappingBuilderClaimPath] =
useState("groups");
const [policyMappingBuilderRules, setPolicyMappingBuilderRules] = useState<
MappingBuilderRule[]
>([createMappingBuilderRule()]);
const [policyRawRoleExpression, setPolicyRawRoleExpression] = useState("");
const [policyOrgRoles, setPolicyOrgRoles] = useState<
{ roleId: number; name: string }[]
>([]);
const policyFormSchema = z.object({ const policyFormSchema = z.object({
orgId: z.string().min(1, { message: t("orgRequired") }), orgId: z.string().min(1, { message: t("orgRequired") }),
roleMapping: z.string().optional(),
orgMapping: z.string().optional() orgMapping: z.string().optional()
}); });
const defaultMappingsSchema = z.object({ const defaultMappingsSchema = z.object({
defaultRoleMapping: z.string().optional(),
defaultOrgMapping: z.string().optional() defaultOrgMapping: z.string().optional()
}); });
@@ -106,15 +154,15 @@ export default function PoliciesPage() {
resolver: zodResolver(policyFormSchema), resolver: zodResolver(policyFormSchema),
defaultValues: { defaultValues: {
orgId: "", orgId: "",
roleMapping: "",
orgMapping: "" orgMapping: ""
} }
}); });
const policyFormOrgId = form.watch("orgId");
const defaultMappingsForm = useForm({ const defaultMappingsForm = useForm({
resolver: zodResolver(defaultMappingsSchema), resolver: zodResolver(defaultMappingsSchema),
defaultValues: { defaultValues: {
defaultRoleMapping: "",
defaultOrgMapping: "" defaultOrgMapping: ""
} }
}); });
@@ -127,9 +175,16 @@ export default function PoliciesPage() {
if (res.status === 200) { if (res.status === 200) {
const data = res.data.data; const data = res.data.data;
defaultMappingsForm.reset({ defaultMappingsForm.reset({
defaultRoleMapping: data.idp.defaultRoleMapping || "",
defaultOrgMapping: data.idp.defaultOrgMapping || "" defaultOrgMapping: data.idp.defaultOrgMapping || ""
}); });
resetRoleMappingStateFromDetected(
setDefaultRoleMappingMode,
setDefaultFixedRoleNames,
setDefaultMappingBuilderClaimPath,
setDefaultMappingBuilderRules,
setDefaultRawRoleExpression,
data.idp.defaultRoleMapping
);
} }
} catch (e) { } catch (e) {
toast({ toast({
@@ -184,11 +239,67 @@ export default function PoliciesPage() {
load(); load();
}, [idpId]); }, [idpId]);
useEffect(() => {
if (!showAddDialog) {
return;
}
const orgId = editingPolicy?.orgId || policyFormOrgId;
if (!orgId) {
setPolicyOrgRoles([]);
return;
}
let cancelled = false;
(async () => {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("accessRoleErrorFetch"),
description: formatAxiosError(
e,
t("accessRoleErrorFetchDescription")
)
});
return null;
});
if (!cancelled && res?.status === 200) {
setPolicyOrgRoles(res.data.data.roles);
}
})();
return () => {
cancelled = true;
};
}, [showAddDialog, editingPolicy?.orgId, policyFormOrgId, api, t]);
function resetPolicyDialogRoleMappingState() {
const d = defaultRoleMappingConfig();
setPolicyRoleMappingMode(d.mode);
setPolicyFixedRoleNames(d.fixedRoleNames);
setPolicyMappingBuilderClaimPath(d.mappingBuilder.claimPath);
setPolicyMappingBuilderRules(d.mappingBuilder.rules);
setPolicyRawRoleExpression(d.rawExpression);
}
const onAddPolicy = async (data: PolicyFormValues) => { const onAddPolicy = async (data: PolicyFormValues) => {
const roleMappingExpression = compileRoleMappingExpression({
mode: policyRoleMappingMode,
fixedRoleNames: policyFixedRoleNames,
mappingBuilder: {
claimPath: policyMappingBuilderClaimPath,
rules: policyMappingBuilderRules
},
rawExpression: policyRawRoleExpression
});
setAddPolicyLoading(true); setAddPolicyLoading(true);
try { try {
const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, { const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, {
roleMapping: data.roleMapping, roleMapping: roleMappingExpression,
orgMapping: data.orgMapping orgMapping: data.orgMapping
}); });
if (res.status === 201) { if (res.status === 201) {
@@ -197,7 +308,7 @@ export default function PoliciesPage() {
name: name:
organizations.find((org) => org.orgId === data.orgId) organizations.find((org) => org.orgId === data.orgId)
?.name || "", ?.name || "",
roleMapping: data.roleMapping, roleMapping: roleMappingExpression,
orgMapping: data.orgMapping orgMapping: data.orgMapping
}; };
setPolicies([...policies, newPolicy]); setPolicies([...policies, newPolicy]);
@@ -207,6 +318,7 @@ export default function PoliciesPage() {
}); });
setShowAddDialog(false); setShowAddDialog(false);
form.reset(); form.reset();
resetPolicyDialogRoleMappingState();
} }
} catch (e) { } catch (e) {
toast({ toast({
@@ -222,12 +334,22 @@ export default function PoliciesPage() {
const onEditPolicy = async (data: PolicyFormValues) => { const onEditPolicy = async (data: PolicyFormValues) => {
if (!editingPolicy) return; if (!editingPolicy) return;
const roleMappingExpression = compileRoleMappingExpression({
mode: policyRoleMappingMode,
fixedRoleNames: policyFixedRoleNames,
mappingBuilder: {
claimPath: policyMappingBuilderClaimPath,
rules: policyMappingBuilderRules
},
rawExpression: policyRawRoleExpression
});
setEditPolicyLoading(true); setEditPolicyLoading(true);
try { try {
const res = await api.post( const res = await api.post(
`/idp/${idpId}/org/${editingPolicy.orgId}`, `/idp/${idpId}/org/${editingPolicy.orgId}`,
{ {
roleMapping: data.roleMapping, roleMapping: roleMappingExpression,
orgMapping: data.orgMapping orgMapping: data.orgMapping
} }
); );
@@ -237,7 +359,7 @@ export default function PoliciesPage() {
policy.orgId === editingPolicy.orgId policy.orgId === editingPolicy.orgId
? { ? {
...policy, ...policy,
roleMapping: data.roleMapping, roleMapping: roleMappingExpression,
orgMapping: data.orgMapping orgMapping: data.orgMapping
} }
: policy : policy
@@ -250,6 +372,7 @@ export default function PoliciesPage() {
setShowAddDialog(false); setShowAddDialog(false);
setEditingPolicy(null); setEditingPolicy(null);
form.reset(); form.reset();
resetPolicyDialogRoleMappingState();
} }
} catch (e) { } catch (e) {
toast({ toast({
@@ -287,10 +410,20 @@ export default function PoliciesPage() {
}; };
const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => { const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => {
const defaultRoleMappingExpression = compileRoleMappingExpression({
mode: defaultRoleMappingMode,
fixedRoleNames: defaultFixedRoleNames,
mappingBuilder: {
claimPath: defaultMappingBuilderClaimPath,
rules: defaultMappingBuilderRules
},
rawExpression: defaultRawRoleExpression
});
setUpdateDefaultMappingsLoading(true); setUpdateDefaultMappingsLoading(true);
try { try {
const res = await api.post(`/idp/${idpId}/oidc`, { const res = await api.post(`/idp/${idpId}/oidc`, {
defaultRoleMapping: data.defaultRoleMapping, defaultRoleMapping: defaultRoleMappingExpression,
defaultOrgMapping: data.defaultOrgMapping defaultOrgMapping: data.defaultOrgMapping
}); });
if (res.status === 200) { if (res.status === 200) {
@@ -317,25 +450,36 @@ export default function PoliciesPage() {
return ( return (
<> <>
<SettingsContainer> <SettingsContainer>
<Alert variant="neutral" className="mb-6"> <PolicyTable
<InfoIcon className="h-4 w-4" /> policies={policies}
<AlertTitle className="font-semibold"> onDelete={onDeletePolicy}
{t("orgPoliciesAbout")} onAdd={() => {
</AlertTitle> loadOrganizations();
<AlertDescription> form.reset({
{/*TODO(vlalx): Validate replacing */} orgId: "",
{t("orgPoliciesAboutDescription")}{" "} orgMapping: ""
<Link });
href="https://docs.pangolin.net/manage/identity-providers/auto-provisioning" setEditingPolicy(null);
target="_blank" resetPolicyDialogRoleMappingState();
rel="noopener noreferrer" setShowAddDialog(true);
className="text-primary hover:underline" }}
> onEdit={(policy) => {
{t("orgPoliciesAboutDescriptionLink")} setEditingPolicy(policy);
<ExternalLink className="ml-1 h-4 w-4 inline" /> form.reset({
</Link> orgId: policy.orgId,
</AlertDescription> orgMapping: policy.orgMapping || ""
</Alert> });
resetRoleMappingStateFromDetected(
setPolicyRoleMappingMode,
setPolicyFixedRoleNames,
setPolicyMappingBuilderClaimPath,
setPolicyMappingBuilderRules,
setPolicyRawRoleExpression,
policy.roleMapping
);
setShowAddDialog(true);
}}
/>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
@@ -353,51 +497,58 @@ export default function PoliciesPage() {
onUpdateDefaultMappings onUpdateDefaultMappings
)} )}
id="policy-default-mappings-form" id="policy-default-mappings-form"
className="space-y-4" className="space-y-6"
> >
<div className="grid gap-6 md:grid-cols-2"> <RoleMappingConfigFields
<FormField fieldIdPrefix="admin-idp-default-role"
control={defaultMappingsForm.control} showFreeformRoleNamesHint={true}
name="defaultRoleMapping" roleMappingMode={defaultRoleMappingMode}
render={({ field }) => ( onRoleMappingModeChange={
<FormItem> setDefaultRoleMappingMode
<FormLabel> }
{t("defaultMappingsRole")} roles={[]}
</FormLabel> fixedRoleNames={defaultFixedRoleNames}
<FormControl> onFixedRoleNamesChange={
<Input {...field} /> setDefaultFixedRoleNames
</FormControl> }
<FormDescription> mappingBuilderClaimPath={
{t( defaultMappingBuilderClaimPath
"defaultMappingsRoleDescription" }
)} onMappingBuilderClaimPathChange={
</FormDescription> setDefaultMappingBuilderClaimPath
<FormMessage /> }
</FormItem> mappingBuilderRules={
)} defaultMappingBuilderRules
/> }
onMappingBuilderRulesChange={
setDefaultMappingBuilderRules
}
rawExpression={defaultRawRoleExpression}
onRawExpressionChange={
setDefaultRawRoleExpression
}
/>
<FormField <FormField
control={defaultMappingsForm.control} control={defaultMappingsForm.control}
name="defaultOrgMapping" name="defaultOrgMapping"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t("defaultMappingsOrg")} {t("defaultMappingsOrg")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t( {t(
"defaultMappingsOrgDescription" "defaultMappingsOrgDescription"
)} )}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div>
</form> </form>
</Form> </Form>
<SettingsSectionFooter> <SettingsSectionFooter>
@@ -411,41 +562,20 @@ export default function PoliciesPage() {
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
<PolicyTable
policies={policies}
onDelete={onDeletePolicy}
onAdd={() => {
loadOrganizations();
form.reset({
orgId: "",
roleMapping: "",
orgMapping: ""
});
setEditingPolicy(null);
setShowAddDialog(true);
}}
onEdit={(policy) => {
setEditingPolicy(policy);
form.reset({
orgId: policy.orgId,
roleMapping: policy.roleMapping || "",
orgMapping: policy.orgMapping || ""
});
setShowAddDialog(true);
}}
/>
</SettingsContainer> </SettingsContainer>
<Credenza <Credenza
open={showAddDialog} open={showAddDialog}
onOpenChange={(val) => { onOpenChange={(val) => {
setShowAddDialog(val); setShowAddDialog(val);
setEditingPolicy(null); if (!val) {
form.reset(); setEditingPolicy(null);
form.reset();
resetPolicyDialogRoleMappingState();
}
}} }}
> >
<CredenzaContent> <CredenzaContent className="max-w-4xl w-[calc(100vw-2rem)] sm:w-full">
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle> <CredenzaTitle>
{editingPolicy {editingPolicy
@@ -456,7 +586,7 @@ export default function PoliciesPage() {
{t("orgPolicyConfig")} {t("orgPolicyConfig")}
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody className="min-w-0 overflow-x-auto">
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit( onSubmit={form.handleSubmit(
@@ -557,25 +687,34 @@ export default function PoliciesPage() {
)} )}
/> />
<FormField <RoleMappingConfigFields
control={form.control} fieldIdPrefix="admin-idp-policy-role"
name="roleMapping" showFreeformRoleNamesHint={false}
render={({ field }) => ( roleMappingMode={policyRoleMappingMode}
<FormItem> onRoleMappingModeChange={
<FormLabel> setPolicyRoleMappingMode
{t("roleMappingPathOptional")} }
</FormLabel> roles={policyOrgRoles}
<FormControl> fixedRoleNames={policyFixedRoleNames}
<Input {...field} /> onFixedRoleNamesChange={
</FormControl> setPolicyFixedRoleNames
<FormDescription> }
{t( mappingBuilderClaimPath={
"defaultMappingsRoleDescription" policyMappingBuilderClaimPath
)} }
</FormDescription> onMappingBuilderClaimPathChange={
<FormMessage /> setPolicyMappingBuilderClaimPath
</FormItem> }
)} mappingBuilderRules={
policyMappingBuilderRules
}
onMappingBuilderRulesChange={
setPolicyMappingBuilderRules
}
rawExpression={policyRawRoleExpression}
onRawExpressionChange={
setPolicyRawRoleExpression
}
/> />
<FormField <FormField

View File

@@ -340,16 +340,6 @@ export default function Page() {
/> />
</form> </form>
</Form> </Form>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("idpOidcConfigureAlert")}
</AlertTitle>
<AlertDescription>
{t("idpOidcConfigureAlertDescription")}
</AlertDescription>
</Alert>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
@@ -369,29 +359,6 @@ export default function Page() {
id="create-idp-form" id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
> >
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("idpJmespathAbout")}
</AlertTitle>
<AlertDescription>
{t(
"idpJmespathAboutDescription"
)}{" "}
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
{t(
"idpJmespathAboutDescriptionLink"
)}{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<FormField <FormField
control={form.control} control={form.control}
name="identifierPath" name="identifierPath"

View File

@@ -1,23 +1,15 @@
"use client"; "use client";
import { import { FormDescription } from "@app/components/ui/form";
FormLabel,
FormDescription
} from "@app/components/ui/form";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { import {
createMappingBuilderRule,
MappingBuilderRule, MappingBuilderRule,
RoleMappingMode RoleMappingMode
} from "@app/lib/idpRoleMapping"; } from "@app/lib/idpRoleMapping";
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
type Role = { type Role = {
roleId: number; roleId: number;
@@ -57,18 +49,6 @@ export default function AutoProvisionConfigWidget({
}: AutoProvisionConfigWidgetProps) { }: AutoProvisionConfigWidgetProps) {
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
number | null
>(null);
const roleOptions = useMemo(
() =>
roles.map((role) => ({
id: role.name,
text: role.name
})),
[roles]
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -80,261 +60,30 @@ export default function AutoProvisionConfigWidget({
onCheckedChange={onAutoProvisionChange} onCheckedChange={onAutoProvisionChange}
disabled={!isPaidUser(tierMatrix.autoProvisioning)} disabled={!isPaidUser(tierMatrix.autoProvisioning)}
/> />
<span className="text-sm text-muted-foreground"> <FormDescription className="text-sm text-muted-foreground">
{t("idpAutoProvisionUsersDescription")} {t("idpAutoProvisionUsersDescription")}
</span> </FormDescription>
</div> </div>
{autoProvision && ( {autoProvision && (
<div className="space-y-4"> <RoleMappingConfigFields
<div> fieldIdPrefix="org-idp-auto-provision"
<FormLabel className="mb-2"> showFreeformRoleNamesHint={false}
{t("roleMapping")} roleMappingMode={roleMappingMode}
</FormLabel> onRoleMappingModeChange={onRoleMappingModeChange}
<FormDescription className="mb-4"> roles={roles}
{t("roleMappingDescription")} fixedRoleNames={fixedRoleNames}
</FormDescription> onFixedRoleNamesChange={onFixedRoleNamesChange}
mappingBuilderClaimPath={mappingBuilderClaimPath}
<RadioGroup onMappingBuilderClaimPathChange={
value={roleMappingMode} onMappingBuilderClaimPathChange
onValueChange={onRoleMappingModeChange} }
className="flex flex-wrap gap-x-6 gap-y-2" mappingBuilderRules={mappingBuilderRules}
> onMappingBuilderRulesChange={onMappingBuilderRulesChange}
<div className="flex items-center space-x-2"> rawExpression={rawExpression}
<RadioGroupItem onRawExpressionChange={onRawExpressionChange}
value="fixedRoles" />
id="fixed-roles-mode"
/>
<label
htmlFor="fixed-roles-mode"
className="text-sm font-medium"
>
Fixed roles
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="mappingBuilder"
id="mapping-builder-mode"
/>
<label
htmlFor="mapping-builder-mode"
className="text-sm font-medium"
>
Mapping builder
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="rawExpression"
id="expression-mode"
/>
<label
htmlFor="expression-mode"
className="text-sm font-medium"
>
Raw expression
</label>
</div>
</RadioGroup>
</div>
{roleMappingMode === "fixedRoles" && (
<div className="space-y-2">
<TagInput
tags={fixedRoleNames.map((name) => ({
id: name,
text: name
}))}
setTags={(nextTags) => {
const next =
typeof nextTags === "function"
? nextTags(
fixedRoleNames.map((name) => ({
id: name,
text: name
}))
)
: nextTags;
onFixedRoleNamesChange(
[...new Set(next.map((tag) => tag.text))]
);
}}
activeTagIndex={activeFixedRoleTagIndex}
setActiveTagIndex={setActiveFixedRoleTagIndex}
placeholder="Select one or more roles"
enableAutocomplete={true}
autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={true}
allowDuplicates={false}
sortTags={true}
size="sm"
/>
<FormDescription>
Assign the same role set to every auto-provisioned
user.
</FormDescription>
</div>
)}
{roleMappingMode === "mappingBuilder" && (
<div className="space-y-4 rounded-md border p-3">
<div className="space-y-2">
<FormLabel>Claim path</FormLabel>
<Input
value={mappingBuilderClaimPath}
onChange={(e) =>
onMappingBuilderClaimPathChange(
e.target.value
)
}
placeholder="groups"
/>
<FormDescription>
Path in the token payload that contains source
values (for example, groups).
</FormDescription>
</div>
<div className="space-y-3">
<div className="hidden md:grid md:grid-cols-[minmax(220px,1fr)_minmax(340px,2fr)_auto] md:gap-3">
<FormLabel>Match value</FormLabel>
<FormLabel>Assign roles</FormLabel>
<span />
</div>
{mappingBuilderRules.map((rule, index) => (
<BuilderRuleRow
key={rule.id ?? `mapping-rule-${index}`}
roleOptions={roleOptions}
rule={rule}
onChange={(nextRule) => {
const nextRules =
mappingBuilderRules.map(
(row, i) =>
i === index
? nextRule
: row
);
onMappingBuilderRulesChange(
nextRules
);
}}
onRemove={() => {
const nextRules =
mappingBuilderRules.filter(
(_, i) => i !== index
);
onMappingBuilderRulesChange(
nextRules.length
? nextRules
: [createMappingBuilderRule()]
);
}}
/>
))}
</div>
<Button
type="button"
variant="outline"
onClick={() => {
onMappingBuilderRulesChange([
...mappingBuilderRules,
createMappingBuilderRule()
]);
}}
>
Add mapping rule
</Button>
</div>
)}
{roleMappingMode === "rawExpression" && (
<div className="space-y-2">
<Input
value={rawExpression}
onChange={(e) =>
onRawExpressionChange(e.target.value)
}
placeholder={t("roleMappingExpressionPlaceholder")}
/>
<FormDescription>
Expression must evaluate to a string or string
array.
</FormDescription>
</div>
)}
</div>
)} )}
</div> </div>
); );
} }
function BuilderRuleRow({
rule,
roleOptions,
onChange,
onRemove
}: {
rule: MappingBuilderRule;
roleOptions: Tag[];
onChange: (rule: MappingBuilderRule) => void;
onRemove: () => void;
}) {
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
return (
<div className="grid gap-3 rounded-md border p-3 md:grid-cols-[minmax(220px,1fr)_minmax(340px,2fr)_auto] md:items-start">
<div className="space-y-1">
<FormLabel className="text-xs md:hidden">Match value</FormLabel>
<Input
value={rule.matchValue}
onChange={(e) =>
onChange({
...rule,
matchValue: e.target.value
})
}
placeholder="Match value (for example: admin)"
/>
</div>
<div className="space-y-1 min-w-0">
<FormLabel className="text-xs md:hidden">Assign roles</FormLabel>
<TagInput
tags={rule.roleNames.map((name) => ({ id: name, text: name }))}
setTags={(nextTags) => {
const next =
typeof nextTags === "function"
? nextTags(
rule.roleNames.map((name) => ({
id: name,
text: name
}))
)
: nextTags;
onChange({
...rule,
roleNames: [...new Set(next.map((tag) => tag.text))]
});
}}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder="Assign roles"
enableAutocomplete={true}
autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={true}
allowDuplicates={false}
sortTags={true}
size="sm"
/>
</div>
<div className="flex justify-end md:justify-start">
<Button type="button" variant="ghost" onClick={onRemove}>
Remove
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,366 @@
"use client";
import { FormLabel, FormDescription } from "@app/components/ui/form";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import {
createMappingBuilderRule,
MappingBuilderRule,
RoleMappingMode
} from "@app/lib/idpRoleMapping";
export type RoleMappingRoleOption = {
roleId: number;
name: string;
};
export type RoleMappingConfigFieldsProps = {
roleMappingMode: RoleMappingMode;
onRoleMappingModeChange: (mode: RoleMappingMode) => void;
roles: RoleMappingRoleOption[];
fixedRoleNames: string[];
onFixedRoleNamesChange: (roleNames: string[]) => void;
mappingBuilderClaimPath: string;
onMappingBuilderClaimPathChange: (claimPath: string) => void;
mappingBuilderRules: MappingBuilderRule[];
onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void;
rawExpression: string;
onRawExpressionChange: (expression: string) => void;
/** Unique prefix for radio `id`/`htmlFor` when multiple instances exist on one page. */
fieldIdPrefix?: string;
/** When true, show extra hint for global default policies (no org role list). */
showFreeformRoleNamesHint?: boolean;
};
export default function RoleMappingConfigFields({
roleMappingMode,
onRoleMappingModeChange,
roles,
fixedRoleNames,
onFixedRoleNamesChange,
mappingBuilderClaimPath,
onMappingBuilderClaimPathChange,
mappingBuilderRules,
onMappingBuilderRulesChange,
rawExpression,
onRawExpressionChange,
fieldIdPrefix = "role-mapping",
showFreeformRoleNamesHint = false
}: RoleMappingConfigFieldsProps) {
const t = useTranslations();
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
number | null
>(null);
const restrictToOrgRoles = roles.length > 0;
const roleOptions = useMemo(
() =>
roles.map((role) => ({
id: role.name,
text: role.name
})),
[roles]
);
const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`;
/** Same template on header + rows so 1fr/1.75fr columns line up (auto third col differs per row otherwise). */
const mappingRulesGridClass =
"md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)_6rem] md:gap-x-3";
return (
<div className="space-y-4">
<div>
<FormLabel className="mb-2">{t("roleMapping")}</FormLabel>
<FormDescription className="mb-4">
{t("roleMappingDescription")}
</FormDescription>
<RadioGroup
value={roleMappingMode}
onValueChange={onRoleMappingModeChange}
className="flex flex-wrap gap-x-6 gap-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="fixedRoles" id={fixedRadioId} />
<label
htmlFor={fixedRadioId}
className="text-sm font-medium"
>
{t("roleMappingModeFixedRoles")}
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="mappingBuilder"
id={builderRadioId}
/>
<label
htmlFor={builderRadioId}
className="text-sm font-medium"
>
{t("roleMappingModeMappingBuilder")}
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="rawExpression" id={rawRadioId} />
<label
htmlFor={rawRadioId}
className="text-sm font-medium"
>
{t("roleMappingModeRawExpression")}
</label>
</div>
</RadioGroup>
</div>
{roleMappingMode === "fixedRoles" && (
<div className="space-y-2 min-w-0 max-w-full">
<TagInput
tags={fixedRoleNames.map((name) => ({
id: name,
text: name
}))}
setTags={(nextTags) => {
const next =
typeof nextTags === "function"
? nextTags(
fixedRoleNames.map((name) => ({
id: name,
text: name
}))
)
: nextTags;
onFixedRoleNamesChange([
...new Set(next.map((tag) => tag.text))
]);
}}
activeTagIndex={activeFixedRoleTagIndex}
setActiveTagIndex={setActiveFixedRoleTagIndex}
placeholder={
restrictToOrgRoles
? t("roleMappingFixedRolesPlaceholderSelect")
: t("roleMappingFixedRolesPlaceholderFreeform")
}
enableAutocomplete={restrictToOrgRoles}
autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={restrictToOrgRoles}
allowDuplicates={false}
sortTags={true}
size="sm"
/>
<FormDescription>
{showFreeformRoleNamesHint
? t("roleMappingFixedRolesDescriptionDefaultPolicy")
: t("roleMappingFixedRolesDescriptionSameForAll")}
</FormDescription>
</div>
)}
{roleMappingMode === "mappingBuilder" && (
<div className="space-y-4 rounded-md border p-3 min-w-0 max-w-full">
<div className="space-y-2">
<FormLabel>{t("roleMappingClaimPath")}</FormLabel>
<Input
value={mappingBuilderClaimPath}
onChange={(e) =>
onMappingBuilderClaimPathChange(e.target.value)
}
placeholder={t("roleMappingClaimPathPlaceholder")}
/>
<FormDescription>
{t("roleMappingClaimPathDescription")}
</FormDescription>
</div>
<div className="space-y-3">
<div
className={`hidden ${mappingRulesGridClass} md:items-end`}
>
<FormLabel className="min-w-0">
{t("roleMappingMatchValue")}
</FormLabel>
<FormLabel className="min-w-0">
{t("roleMappingAssignRoles")}
</FormLabel>
<span aria-hidden className="min-w-0" />
</div>
{mappingBuilderRules.map((rule, index) => (
<BuilderRuleRow
key={rule.id ?? `mapping-rule-${index}`}
mappingRulesGridClass={mappingRulesGridClass}
fieldIdPrefix={`${fieldIdPrefix}-rule-${index}`}
roleOptions={roleOptions}
restrictToOrgRoles={restrictToOrgRoles}
showFreeformRoleNamesHint={
showFreeformRoleNamesHint
}
rule={rule}
onChange={(nextRule) => {
const nextRules = mappingBuilderRules.map(
(row, i) =>
i === index ? nextRule : row
);
onMappingBuilderRulesChange(nextRules);
}}
onRemove={() => {
const nextRules =
mappingBuilderRules.filter(
(_, i) => i !== index
);
onMappingBuilderRulesChange(
nextRules.length
? nextRules
: [createMappingBuilderRule()]
);
}}
/>
))}
</div>
<Button
type="button"
variant="outline"
onClick={() => {
onMappingBuilderRulesChange([
...mappingBuilderRules,
createMappingBuilderRule()
]);
}}
>
{t("roleMappingAddMappingRule")}
</Button>
</div>
)}
{roleMappingMode === "rawExpression" && (
<div className="space-y-2">
<Input
value={rawExpression}
onChange={(e) => onRawExpressionChange(e.target.value)}
placeholder={t("roleMappingExpressionPlaceholder")}
/>
<FormDescription>
{t("roleMappingRawExpressionResultDescription")}
</FormDescription>
</div>
)}
</div>
);
}
function BuilderRuleRow({
rule,
roleOptions,
restrictToOrgRoles,
showFreeformRoleNamesHint,
fieldIdPrefix,
mappingRulesGridClass,
onChange,
onRemove
}: {
rule: MappingBuilderRule;
roleOptions: Tag[];
restrictToOrgRoles: boolean;
showFreeformRoleNamesHint: boolean;
fieldIdPrefix: string;
mappingRulesGridClass: string;
onChange: (rule: MappingBuilderRule) => void;
onRemove: () => void;
}) {
const t = useTranslations();
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
return (
<div
className={`grid gap-3 min-w-0 ${mappingRulesGridClass} md:items-start`}
>
<div className="space-y-1 min-w-0">
<FormLabel className="text-xs md:hidden">
{t("roleMappingMatchValue")}
</FormLabel>
<Input
id={`${fieldIdPrefix}-match`}
value={rule.matchValue}
onChange={(e) =>
onChange({
...rule,
matchValue: e.target.value
})
}
placeholder={t("roleMappingMatchValuePlaceholder")}
/>
</div>
<div className="space-y-1 min-w-0 w-full max-w-full">
<FormLabel className="text-xs md:hidden">
{t("roleMappingAssignRoles")}
</FormLabel>
<div className="min-w-0 max-w-full">
<TagInput
tags={rule.roleNames.map((name) => ({
id: name,
text: name
}))}
setTags={(nextTags) => {
const next =
typeof nextTags === "function"
? nextTags(
rule.roleNames.map((name) => ({
id: name,
text: name
}))
)
: nextTags;
onChange({
...rule,
roleNames: [
...new Set(next.map((tag) => tag.text))
]
});
}}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder={
restrictToOrgRoles
? t("roleMappingAssignRoles")
: t("roleMappingAssignRolesPlaceholderFreeform")
}
enableAutocomplete={restrictToOrgRoles}
autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={restrictToOrgRoles}
allowDuplicates={false}
sortTags={true}
size="sm"
styleClasses={{
inlineTagsContainer: "min-w-0 max-w-full"
}}
/>
</div>
{showFreeformRoleNamesHint && (
<p className="text-sm text-muted-foreground">
{t("roleMappingBuilderFreeformRowHint")}
</p>
)}
</div>
<div className="flex min-w-0 justify-end md:justify-start md:pt-0">
<Button
type="button"
variant="outline"
className="h-9 shrink-0 px-2"
onClick={onRemove}
>
{t("roleMappingRemoveRule")}
</Button>
</div>
</div>
);
}