Files
pangolin/src/components/RoleMappingConfigFields.tsx
2026-03-28 18:09:36 -07:00

472 lines
19 KiB
TypeScript

"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 { useEffect, useMemo, useState } from "react";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import {
createMappingBuilderRule,
MappingBuilderRule,
RoleMappingMode
} from "@app/lib/idpRoleMapping";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
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 { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
number | null
>(null);
const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac);
const showSingleRoleDisclaimer =
!env.flags.disableEnterpriseFeatures &&
!isPaidUser(tierMatrix.fullRbac);
const restrictToOrgRoles = roles.length > 0;
const roleOptions = useMemo(
() =>
roles.map((role) => ({
id: role.name,
text: role.name
})),
[roles]
);
useEffect(() => {
if (
!supportsMultipleRolesPerUser &&
mappingBuilderRules.length > 1
) {
onMappingBuilderRulesChange([mappingBuilderRules[0]]);
}
}, [
supportsMultipleRolesPerUser,
mappingBuilderRules,
onMappingBuilderRulesChange
]);
useEffect(() => {
if (!supportsMultipleRolesPerUser && fixedRoleNames.length > 1) {
onFixedRoleNamesChange([fixedRoleNames[0]]);
}
}, [
supportsMultipleRolesPerUser,
fixedRoleNames,
onFixedRoleNamesChange
]);
const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`;
const mappingBuilderShowsRemoveColumn =
supportsMultipleRolesPerUser || mappingBuilderRules.length > 1;
/** Same template on header + rows so 1fr/1.75fr columns line up (auto third col differs per row otherwise). */
const mappingRulesGridClass = mappingBuilderShowsRemoveColumn
? "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)_6rem] md:gap-x-3"
: "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)] 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>
{showSingleRoleDisclaimer && (
<FormDescription className="mt-3">
{build === "saas"
? t("singleRolePerUserPlanNotice")
: t("singleRolePerUserEditionNotice")}
</FormDescription>
)}
</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 prevTags = fixedRoleNames.map((name) => ({
id: name,
text: name
}));
const next =
typeof nextTags === "function"
? nextTags(prevTags)
: nextTags;
let names = [
...new Set(next.map((tag) => tag.text))
];
if (!supportsMultipleRolesPerUser) {
if (
names.length === 0 &&
fixedRoleNames.length > 0
) {
onFixedRoleNamesChange([
fixedRoleNames[
fixedRoleNames.length - 1
]!
]);
return;
}
if (names.length > 1) {
names = [names[names.length - 1]!];
}
}
onFixedRoleNamesChange(names);
}}
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 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>
{mappingBuilderShowsRemoveColumn ? (
<span aria-hidden className="min-w-0" />
) : null}
</div>
{mappingBuilderRules.map((rule, index) => (
<BuilderRuleRow
key={rule.id ?? `mapping-rule-${index}`}
mappingRulesGridClass={mappingRulesGridClass}
fieldIdPrefix={`${fieldIdPrefix}-rule-${index}`}
roleOptions={roleOptions}
restrictToOrgRoles={restrictToOrgRoles}
showFreeformRoleNamesHint={
showFreeformRoleNamesHint
}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showRemoveButton={mappingBuilderShowsRemoveColumn}
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>
{supportsMultipleRolesPerUser ? (
<Button
type="button"
variant="outline"
onClick={() => {
onMappingBuilderRulesChange([
...mappingBuilderRules,
createMappingBuilderRule()
]);
}}
>
{t("roleMappingAddMappingRule")}
</Button>
) : null}
</div>
)}
{roleMappingMode === "rawExpression" && (
<div className="space-y-2">
<Input
value={rawExpression}
onChange={(e) => onRawExpressionChange(e.target.value)}
placeholder={t("roleMappingExpressionPlaceholder")}
/>
<FormDescription>
{supportsMultipleRolesPerUser
? t("roleMappingRawExpressionResultDescription")
: t(
"roleMappingRawExpressionResultDescriptionSingleRole"
)}
</FormDescription>
</div>
)}
</div>
);
}
function BuilderRuleRow({
rule,
roleOptions,
restrictToOrgRoles,
showFreeformRoleNamesHint,
fieldIdPrefix,
mappingRulesGridClass,
supportsMultipleRolesPerUser,
showRemoveButton,
onChange,
onRemove
}: {
rule: MappingBuilderRule;
roleOptions: Tag[];
restrictToOrgRoles: boolean;
showFreeformRoleNamesHint: boolean;
fieldIdPrefix: string;
mappingRulesGridClass: string;
supportsMultipleRolesPerUser: boolean;
showRemoveButton: boolean;
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 prevRoleTags = rule.roleNames.map(
(name) => ({
id: name,
text: name
})
);
const next =
typeof nextTags === "function"
? nextTags(prevRoleTags)
: nextTags;
let names = [
...new Set(next.map((tag) => tag.text))
];
if (!supportsMultipleRolesPerUser) {
if (
names.length === 0 &&
rule.roleNames.length > 0
) {
onChange({
...rule,
roleNames: [
rule.roleNames[
rule.roleNames.length - 1
]!
]
});
return;
}
if (names.length > 1) {
names = [names[names.length - 1]!];
}
}
onChange({
...rule,
roleNames: names
});
}}
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>
{showRemoveButton ? (
<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>
) : null}
</div>
);
}