"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 (
{t("roleMapping")} {t("roleMappingDescription")}
{showSingleRoleDisclaimer && ( {build === "saas" ? t("singleRolePerUserPlanNotice") : t("singleRolePerUserEditionNotice")} )}
{roleMappingMode === "fixedRoles" && (
({ 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" /> {showFreeformRoleNamesHint ? t("roleMappingFixedRolesDescriptionDefaultPolicy") : t("roleMappingFixedRolesDescriptionSameForAll")}
)} {roleMappingMode === "mappingBuilder" && (
{t("roleMappingClaimPath")} onMappingBuilderClaimPathChange(e.target.value) } placeholder={t("roleMappingClaimPathPlaceholder")} /> {t("roleMappingClaimPathDescription")}
{t("roleMappingMatchValue")} {t("roleMappingAssignRoles")} {mappingBuilderShowsRemoveColumn ? ( ) : null}
{mappingBuilderRules.map((rule, index) => ( { 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()] ); }} /> ))}
{supportsMultipleRolesPerUser ? ( ) : null}
)} {roleMappingMode === "rawExpression" && (
onRawExpressionChange(e.target.value)} placeholder={t("roleMappingExpressionPlaceholder")} /> {supportsMultipleRolesPerUser ? t("roleMappingRawExpressionResultDescription") : t( "roleMappingRawExpressionResultDescriptionSingleRole" )}
)}
); } 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(null); return (
{t("roleMappingMatchValue")} onChange({ ...rule, matchValue: e.target.value }) } placeholder={t("roleMappingMatchValuePlaceholder")} />
{t("roleMappingAssignRoles")}
({ 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" }} />
{showFreeformRoleNamesHint && (

{t("roleMappingBuilderFreeformRowHint")}

)}
{showRemoveButton ? (
) : null}
); }