mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-28 17:35:30 +00:00
support policy buildiner in global idp
This commit is contained in:
@@ -509,6 +509,7 @@
|
||||
"userSaved": "User saved",
|
||||
"userSavedDescription": "The user has been updated.",
|
||||
"autoProvisioned": "Auto Provisioned",
|
||||
"autoProvisionSettings": "Auto Provision Settings",
|
||||
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
|
||||
"accessControlsDescription": "Manage what this user can access and do in the organization",
|
||||
"accessControlsSubmit": "Save Access Controls",
|
||||
@@ -1042,7 +1043,6 @@
|
||||
"pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.",
|
||||
"overview": "Overview",
|
||||
"home": "Home",
|
||||
"accessControl": "Access Control",
|
||||
"settings": "Settings",
|
||||
"usersAll": "All Users",
|
||||
"license": "License",
|
||||
@@ -1942,6 +1942,24 @@
|
||||
"invalidValue": "Invalid value",
|
||||
"idpTypeLabel": "Identity Provider Type",
|
||||
"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",
|
||||
"idpGoogleConfigurationDescription": "Configure the Google OAuth2 credentials",
|
||||
"idpGoogleClientIdDescription": "Google OAuth2 Client ID",
|
||||
@@ -2514,9 +2532,9 @@
|
||||
"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.",
|
||||
"agent": "Agent",
|
||||
"personalUseOnly": "Personal Use Only",
|
||||
"loginPageLicenseWatermark": "This instance is licensed for personal use only.",
|
||||
"instanceIsUnlicensed": "This instance is unlicensed.",
|
||||
"personalUseOnly": "Personal Use Only",
|
||||
"loginPageLicenseWatermark": "This instance is licensed for personal use only.",
|
||||
"instanceIsUnlicensed": "This instance is unlicensed.",
|
||||
"portRestrictions": "Port Restrictions",
|
||||
"allPorts": "All",
|
||||
"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.",
|
||||
"forced": "Forced",
|
||||
"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.",
|
||||
"pageTitle": "Page Title",
|
||||
"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.",
|
||||
"approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useForm } from "react-hook-form";
|
||||
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 {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
@@ -189,15 +190,6 @@ export default function GeneralPage() {
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
|
||||
<Alert variant="neutral" className="">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("redirectUrlAbout")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("redirectUrlAboutDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -239,9 +231,32 @@ export default function GeneralPage() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("idpAutoProvisionUsersDescription")}
|
||||
</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{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>
|
||||
</SettingsSectionForm>
|
||||
@@ -375,29 +390,6 @@ export default function GeneralPage() {
|
||||
className="space-y-4"
|
||||
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
|
||||
control={form.control}
|
||||
name="identifierPath"
|
||||
|
||||
@@ -34,7 +34,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
href: `/admin/idp/${params.idpId}/general`
|
||||
},
|
||||
{
|
||||
title: t("orgPolicies"),
|
||||
title: t("autoProvisionSettings"),
|
||||
href: `/admin/idp/${params.idpId}/policies`
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
@@ -34,6 +34,7 @@ import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react";
|
||||
import PolicyTable, { PolicyRow } from "../../../../../components/PolicyTable";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ListOrgsResponse } from "@server/routers/org";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -50,8 +51,6 @@ import {
|
||||
} from "@app/components/ui/command";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
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 {
|
||||
SettingsContainer,
|
||||
@@ -64,16 +63,40 @@ import {
|
||||
SettingsSectionForm
|
||||
} from "@app/components/Settings";
|
||||
import { useTranslations } from "next-intl";
|
||||
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
|
||||
import {
|
||||
compileRoleMappingExpression,
|
||||
createMappingBuilderRule,
|
||||
defaultRoleMappingConfig,
|
||||
detectRoleMappingConfig,
|
||||
MappingBuilderRule,
|
||||
RoleMappingMode
|
||||
} from "@app/lib/idpRoleMapping";
|
||||
|
||||
type Organization = {
|
||||
orgId: 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() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const { idpId } = useParams();
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -88,14 +111,39 @@ export default function PoliciesPage() {
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
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({
|
||||
orgId: z.string().min(1, { message: t("orgRequired") }),
|
||||
roleMapping: z.string().optional(),
|
||||
orgMapping: z.string().optional()
|
||||
});
|
||||
|
||||
const defaultMappingsSchema = z.object({
|
||||
defaultRoleMapping: z.string().optional(),
|
||||
defaultOrgMapping: z.string().optional()
|
||||
});
|
||||
|
||||
@@ -106,15 +154,15 @@ export default function PoliciesPage() {
|
||||
resolver: zodResolver(policyFormSchema),
|
||||
defaultValues: {
|
||||
orgId: "",
|
||||
roleMapping: "",
|
||||
orgMapping: ""
|
||||
}
|
||||
});
|
||||
|
||||
const policyFormOrgId = form.watch("orgId");
|
||||
|
||||
const defaultMappingsForm = useForm({
|
||||
resolver: zodResolver(defaultMappingsSchema),
|
||||
defaultValues: {
|
||||
defaultRoleMapping: "",
|
||||
defaultOrgMapping: ""
|
||||
}
|
||||
});
|
||||
@@ -127,9 +175,16 @@ export default function PoliciesPage() {
|
||||
if (res.status === 200) {
|
||||
const data = res.data.data;
|
||||
defaultMappingsForm.reset({
|
||||
defaultRoleMapping: data.idp.defaultRoleMapping || "",
|
||||
defaultOrgMapping: data.idp.defaultOrgMapping || ""
|
||||
});
|
||||
resetRoleMappingStateFromDetected(
|
||||
setDefaultRoleMappingMode,
|
||||
setDefaultFixedRoleNames,
|
||||
setDefaultMappingBuilderClaimPath,
|
||||
setDefaultMappingBuilderRules,
|
||||
setDefaultRawRoleExpression,
|
||||
data.idp.defaultRoleMapping
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
@@ -184,11 +239,67 @@ export default function PoliciesPage() {
|
||||
load();
|
||||
}, [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 roleMappingExpression = compileRoleMappingExpression({
|
||||
mode: policyRoleMappingMode,
|
||||
fixedRoleNames: policyFixedRoleNames,
|
||||
mappingBuilder: {
|
||||
claimPath: policyMappingBuilderClaimPath,
|
||||
rules: policyMappingBuilderRules
|
||||
},
|
||||
rawExpression: policyRawRoleExpression
|
||||
});
|
||||
|
||||
setAddPolicyLoading(true);
|
||||
try {
|
||||
const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, {
|
||||
roleMapping: data.roleMapping,
|
||||
roleMapping: roleMappingExpression,
|
||||
orgMapping: data.orgMapping
|
||||
});
|
||||
if (res.status === 201) {
|
||||
@@ -197,7 +308,7 @@ export default function PoliciesPage() {
|
||||
name:
|
||||
organizations.find((org) => org.orgId === data.orgId)
|
||||
?.name || "",
|
||||
roleMapping: data.roleMapping,
|
||||
roleMapping: roleMappingExpression,
|
||||
orgMapping: data.orgMapping
|
||||
};
|
||||
setPolicies([...policies, newPolicy]);
|
||||
@@ -207,6 +318,7 @@ export default function PoliciesPage() {
|
||||
});
|
||||
setShowAddDialog(false);
|
||||
form.reset();
|
||||
resetPolicyDialogRoleMappingState();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
@@ -222,12 +334,22 @@ export default function PoliciesPage() {
|
||||
const onEditPolicy = async (data: PolicyFormValues) => {
|
||||
if (!editingPolicy) return;
|
||||
|
||||
const roleMappingExpression = compileRoleMappingExpression({
|
||||
mode: policyRoleMappingMode,
|
||||
fixedRoleNames: policyFixedRoleNames,
|
||||
mappingBuilder: {
|
||||
claimPath: policyMappingBuilderClaimPath,
|
||||
rules: policyMappingBuilderRules
|
||||
},
|
||||
rawExpression: policyRawRoleExpression
|
||||
});
|
||||
|
||||
setEditPolicyLoading(true);
|
||||
try {
|
||||
const res = await api.post(
|
||||
`/idp/${idpId}/org/${editingPolicy.orgId}`,
|
||||
{
|
||||
roleMapping: data.roleMapping,
|
||||
roleMapping: roleMappingExpression,
|
||||
orgMapping: data.orgMapping
|
||||
}
|
||||
);
|
||||
@@ -237,7 +359,7 @@ export default function PoliciesPage() {
|
||||
policy.orgId === editingPolicy.orgId
|
||||
? {
|
||||
...policy,
|
||||
roleMapping: data.roleMapping,
|
||||
roleMapping: roleMappingExpression,
|
||||
orgMapping: data.orgMapping
|
||||
}
|
||||
: policy
|
||||
@@ -250,6 +372,7 @@ export default function PoliciesPage() {
|
||||
setShowAddDialog(false);
|
||||
setEditingPolicy(null);
|
||||
form.reset();
|
||||
resetPolicyDialogRoleMappingState();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
@@ -287,10 +410,20 @@ export default function PoliciesPage() {
|
||||
};
|
||||
|
||||
const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => {
|
||||
const defaultRoleMappingExpression = compileRoleMappingExpression({
|
||||
mode: defaultRoleMappingMode,
|
||||
fixedRoleNames: defaultFixedRoleNames,
|
||||
mappingBuilder: {
|
||||
claimPath: defaultMappingBuilderClaimPath,
|
||||
rules: defaultMappingBuilderRules
|
||||
},
|
||||
rawExpression: defaultRawRoleExpression
|
||||
});
|
||||
|
||||
setUpdateDefaultMappingsLoading(true);
|
||||
try {
|
||||
const res = await api.post(`/idp/${idpId}/oidc`, {
|
||||
defaultRoleMapping: data.defaultRoleMapping,
|
||||
defaultRoleMapping: defaultRoleMappingExpression,
|
||||
defaultOrgMapping: data.defaultOrgMapping
|
||||
});
|
||||
if (res.status === 200) {
|
||||
@@ -317,25 +450,36 @@ export default function PoliciesPage() {
|
||||
return (
|
||||
<>
|
||||
<SettingsContainer>
|
||||
<Alert variant="neutral" className="mb-6">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("orgPoliciesAbout")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{/*TODO(vlalx): Validate replacing */}
|
||||
{t("orgPoliciesAboutDescription")}{" "}
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/identity-providers/auto-provisioning"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t("orgPoliciesAboutDescriptionLink")}
|
||||
<ExternalLink className="ml-1 h-4 w-4 inline" />
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<PolicyTable
|
||||
policies={policies}
|
||||
onDelete={onDeletePolicy}
|
||||
onAdd={() => {
|
||||
loadOrganizations();
|
||||
form.reset({
|
||||
orgId: "",
|
||||
orgMapping: ""
|
||||
});
|
||||
setEditingPolicy(null);
|
||||
resetPolicyDialogRoleMappingState();
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
onEdit={(policy) => {
|
||||
setEditingPolicy(policy);
|
||||
form.reset({
|
||||
orgId: policy.orgId,
|
||||
orgMapping: policy.orgMapping || ""
|
||||
});
|
||||
resetRoleMappingStateFromDetected(
|
||||
setPolicyRoleMappingMode,
|
||||
setPolicyFixedRoleNames,
|
||||
setPolicyMappingBuilderClaimPath,
|
||||
setPolicyMappingBuilderRules,
|
||||
setPolicyRawRoleExpression,
|
||||
policy.roleMapping
|
||||
);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
@@ -353,51 +497,58 @@ export default function PoliciesPage() {
|
||||
onUpdateDefaultMappings
|
||||
)}
|
||||
id="policy-default-mappings-form"
|
||||
className="space-y-4"
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<FormField
|
||||
control={defaultMappingsForm.control}
|
||||
name="defaultRoleMapping"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("defaultMappingsRole")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"defaultMappingsRoleDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<RoleMappingConfigFields
|
||||
fieldIdPrefix="admin-idp-default-role"
|
||||
showFreeformRoleNamesHint={true}
|
||||
roleMappingMode={defaultRoleMappingMode}
|
||||
onRoleMappingModeChange={
|
||||
setDefaultRoleMappingMode
|
||||
}
|
||||
roles={[]}
|
||||
fixedRoleNames={defaultFixedRoleNames}
|
||||
onFixedRoleNamesChange={
|
||||
setDefaultFixedRoleNames
|
||||
}
|
||||
mappingBuilderClaimPath={
|
||||
defaultMappingBuilderClaimPath
|
||||
}
|
||||
onMappingBuilderClaimPathChange={
|
||||
setDefaultMappingBuilderClaimPath
|
||||
}
|
||||
mappingBuilderRules={
|
||||
defaultMappingBuilderRules
|
||||
}
|
||||
onMappingBuilderRulesChange={
|
||||
setDefaultMappingBuilderRules
|
||||
}
|
||||
rawExpression={defaultRawRoleExpression}
|
||||
onRawExpressionChange={
|
||||
setDefaultRawRoleExpression
|
||||
}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={defaultMappingsForm.control}
|
||||
name="defaultOrgMapping"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("defaultMappingsOrg")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"defaultMappingsOrgDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={defaultMappingsForm.control}
|
||||
name="defaultOrgMapping"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("defaultMappingsOrg")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"defaultMappingsOrgDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<SettingsSectionFooter>
|
||||
@@ -411,41 +562,20 @@ export default function PoliciesPage() {
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSectionBody>
|
||||
</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>
|
||||
|
||||
<Credenza
|
||||
open={showAddDialog}
|
||||
onOpenChange={(val) => {
|
||||
setShowAddDialog(val);
|
||||
setEditingPolicy(null);
|
||||
form.reset();
|
||||
if (!val) {
|
||||
setEditingPolicy(null);
|
||||
form.reset();
|
||||
resetPolicyDialogRoleMappingState();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaContent className="max-w-4xl w-[calc(100vw-2rem)] sm:w-full">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{editingPolicy
|
||||
@@ -456,7 +586,7 @@ export default function PoliciesPage() {
|
||||
{t("orgPolicyConfig")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<CredenzaBody className="min-w-0 overflow-x-auto">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(
|
||||
@@ -557,25 +687,34 @@ export default function PoliciesPage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roleMapping"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("roleMappingPathOptional")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"defaultMappingsRoleDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
<RoleMappingConfigFields
|
||||
fieldIdPrefix="admin-idp-policy-role"
|
||||
showFreeformRoleNamesHint={false}
|
||||
roleMappingMode={policyRoleMappingMode}
|
||||
onRoleMappingModeChange={
|
||||
setPolicyRoleMappingMode
|
||||
}
|
||||
roles={policyOrgRoles}
|
||||
fixedRoleNames={policyFixedRoleNames}
|
||||
onFixedRoleNamesChange={
|
||||
setPolicyFixedRoleNames
|
||||
}
|
||||
mappingBuilderClaimPath={
|
||||
policyMappingBuilderClaimPath
|
||||
}
|
||||
onMappingBuilderClaimPathChange={
|
||||
setPolicyMappingBuilderClaimPath
|
||||
}
|
||||
mappingBuilderRules={
|
||||
policyMappingBuilderRules
|
||||
}
|
||||
onMappingBuilderRulesChange={
|
||||
setPolicyMappingBuilderRules
|
||||
}
|
||||
rawExpression={policyRawRoleExpression}
|
||||
onRawExpressionChange={
|
||||
setPolicyRawRoleExpression
|
||||
}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
|
||||
@@ -340,16 +340,6 @@ export default function Page() {
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("idpOidcConfigureAlert")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("idpOidcConfigureAlertDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -369,29 +359,6 @@ export default function Page() {
|
||||
id="create-idp-form"
|
||||
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
|
||||
control={form.control}
|
||||
name="identifierPath"
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FormLabel,
|
||||
FormDescription
|
||||
} from "@app/components/ui/form";
|
||||
import { FormDescription } from "@app/components/ui/form";
|
||||
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 { useMemo, useState } from "react";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import {
|
||||
createMappingBuilderRule,
|
||||
MappingBuilderRule,
|
||||
RoleMappingMode
|
||||
} from "@app/lib/idpRoleMapping";
|
||||
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
|
||||
|
||||
type Role = {
|
||||
roleId: number;
|
||||
@@ -57,18 +49,6 @@ export default function AutoProvisionConfigWidget({
|
||||
}: AutoProvisionConfigWidgetProps) {
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
roles.map((role) => ({
|
||||
id: role.name,
|
||||
text: role.name
|
||||
})),
|
||||
[roles]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -80,261 +60,30 @@ export default function AutoProvisionConfigWidget({
|
||||
onCheckedChange={onAutoProvisionChange}
|
||||
disabled={!isPaidUser(tierMatrix.autoProvisioning)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<FormDescription className="text-sm text-muted-foreground">
|
||||
{t("idpAutoProvisionUsersDescription")}
|
||||
</span>
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
{autoProvision && (
|
||||
<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="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>
|
||||
<RoleMappingConfigFields
|
||||
fieldIdPrefix="org-idp-auto-provision"
|
||||
showFreeformRoleNamesHint={false}
|
||||
roleMappingMode={roleMappingMode}
|
||||
onRoleMappingModeChange={onRoleMappingModeChange}
|
||||
roles={roles}
|
||||
fixedRoleNames={fixedRoleNames}
|
||||
onFixedRoleNamesChange={onFixedRoleNamesChange}
|
||||
mappingBuilderClaimPath={mappingBuilderClaimPath}
|
||||
onMappingBuilderClaimPathChange={
|
||||
onMappingBuilderClaimPathChange
|
||||
}
|
||||
mappingBuilderRules={mappingBuilderRules}
|
||||
onMappingBuilderRulesChange={onMappingBuilderRulesChange}
|
||||
rawExpression={rawExpression}
|
||||
onRawExpressionChange={onRawExpressionChange}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
366
src/components/RoleMappingConfigFields.tsx
Normal file
366
src/components/RoleMappingConfigFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user