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",
"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."
}

View File

@@ -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"

View File

@@ -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`
}
];

View File

@@ -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

View File

@@ -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"

View File

@@ -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>
);
}

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>
);
}