Merge branch 'multi-role' into dev

This commit is contained in:
Owen
2026-03-29 13:55:23 -07:00
126 changed files with 5337 additions and 2014 deletions

View File

@@ -45,8 +45,17 @@ import { useTranslations } from "next-intl";
import { AxiosResponse } from "axios";
import { ListRolesResponse } from "@server/routers/role";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
compileRoleMappingExpression,
createMappingBuilderRule,
detectRoleMappingConfig,
ensureMappingBuilderRuleIds,
MappingBuilderRule,
RoleMappingMode
} from "@app/lib/idpRoleMapping";
export default function GeneralPage() {
const { env } = useEnvContext();
@@ -56,9 +65,15 @@ export default function GeneralPage() {
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [roleMappingMode, setRoleMappingMode] = useState<
"role" | "expression"
>("role");
const [roleMappingMode, setRoleMappingMode] =
useState<RoleMappingMode>("fixedRoles");
const [fixedRoleNames, setFixedRoleNames] = useState<string[]>([]);
const [mappingBuilderClaimPath, setMappingBuilderClaimPath] =
useState("groups");
const [mappingBuilderRules, setMappingBuilderRules] = useState<
MappingBuilderRule[]
>([createMappingBuilderRule()]);
const [rawRoleExpression, setRawRoleExpression] = useState("");
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
@@ -190,34 +205,8 @@ export default function GeneralPage() {
// Set the variant
setVariant(idpVariant as "oidc" | "google" | "azure");
// Check if roleMapping matches the basic pattern '{role name}' (simple single role)
// This should NOT match complex expressions like 'Admin' || 'Member'
const isBasicRolePattern =
roleMapping &&
typeof roleMapping === "string" &&
/^'[^']+'$/.test(roleMapping);
// Determine if roleMapping is a number (roleId) or matches basic pattern
const isRoleId =
!isNaN(Number(roleMapping)) && roleMapping !== "";
const isRoleName = isBasicRolePattern;
// Extract role name from basic pattern for matching
let extractedRoleName = null;
if (isRoleName) {
extractedRoleName = roleMapping.slice(1, -1); // Remove quotes
}
// Try to find matching role by name if we have a basic pattern
let matchingRoleId = undefined;
if (extractedRoleName && availableRoles.length > 0) {
const matchingRole = availableRoles.find(
(role) => role.name === extractedRoleName
);
if (matchingRole) {
matchingRoleId = matchingRole.roleId;
}
}
const detectedRoleMappingConfig =
detectRoleMappingConfig(roleMapping);
// Extract tenant ID from Azure URLs if present
let tenantId = "";
@@ -238,9 +227,7 @@ export default function GeneralPage() {
clientSecret: data.idpOidcConfig.clientSecret,
autoProvision: data.idp.autoProvision,
roleMapping: roleMapping || null,
roleId: isRoleId
? Number(roleMapping)
: matchingRoleId || null
roleId: null
};
// Add variant-specific fields
@@ -259,10 +246,18 @@ export default function GeneralPage() {
form.reset(formData);
// Set the role mapping mode based on the data
// Default to "expression" unless it's a simple roleId or basic '{role name}' pattern
setRoleMappingMode(
matchingRoleId && isRoleName ? "role" : "expression"
setRoleMappingMode(detectedRoleMappingConfig.mode);
setFixedRoleNames(detectedRoleMappingConfig.fixedRoleNames);
setMappingBuilderClaimPath(
detectedRoleMappingConfig.mappingBuilder.claimPath
);
setMappingBuilderRules(
ensureMappingBuilderRuleIds(
detectedRoleMappingConfig.mappingBuilder.rules
)
);
setRawRoleExpression(
detectedRoleMappingConfig.rawExpression
);
}
} catch (e) {
@@ -327,7 +322,26 @@ export default function GeneralPage() {
return;
}
const roleName = roles.find((r) => r.roleId === data.roleId)?.name;
const roleMappingExpression = compileRoleMappingExpression({
mode: roleMappingMode,
fixedRoleNames,
mappingBuilder: {
claimPath: mappingBuilderClaimPath,
rules: mappingBuilderRules
},
rawExpression: rawRoleExpression
});
if (data.autoProvision && !roleMappingExpression) {
toast({
title: t("error"),
description:
"A role mapping is required when auto-provisioning is enabled.",
variant: "destructive"
});
setLoading(false);
return;
}
// Build payload based on variant
let payload: any = {
@@ -335,10 +349,7 @@ export default function GeneralPage() {
clientId: data.clientId,
clientSecret: data.clientSecret,
autoProvision: data.autoProvision,
roleMapping:
roleMappingMode === "role"
? `'${roleName}'`
: data.roleMapping || ""
roleMapping: roleMappingExpression
};
// Add variant-specific fields
@@ -438,16 +449,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>
{/* IDP Type Indicator */}
<div className="flex items-center space-x-2 mb-4">
<span className="text-sm font-medium text-muted-foreground">
@@ -493,46 +494,47 @@ export default function GeneralPage() {
{t("idpAutoProvisionUsers")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpAutoProvisionUsersDescription")}
<IdpAutoProvisionUsersDescription />
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert
tiers={tierMatrix.autoProvisioning}
/>
<PaidFeaturesAlert
tiers={tierMatrix.autoProvisioning}
/>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<AutoProvisionConfigWidget
control={form.control}
autoProvision={form.watch(
"autoProvision"
)}
onAutoProvisionChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
roleMappingMode={roleMappingMode}
onRoleMappingModeChange={(data) => {
setRoleMappingMode(data);
// Clear roleId and roleMapping when mode changes
form.setValue("roleId", null);
form.setValue("roleMapping", null);
}}
roles={roles}
roleIdFieldName="roleId"
roleMappingFieldName="roleMapping"
/>
</form>
</Form>
</SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<AutoProvisionConfigWidget
autoProvision={form.watch("autoProvision")}
onAutoProvisionChange={(checked) => {
form.setValue("autoProvision", checked);
}}
roleMappingMode={roleMappingMode}
onRoleMappingModeChange={(data) => {
setRoleMappingMode(data);
}}
roles={roles}
fixedRoleNames={fixedRoleNames}
onFixedRoleNamesChange={setFixedRoleNames}
mappingBuilderClaimPath={
mappingBuilderClaimPath
}
onMappingBuilderClaimPathChange={
setMappingBuilderClaimPath
}
mappingBuilderRules={mappingBuilderRules}
onMappingBuilderRulesChange={
setMappingBuilderRules
}
rawExpression={rawRoleExpression}
onRawExpressionChange={setRawRoleExpression}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
@@ -832,29 +834,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

@@ -1,6 +1,7 @@
"use client";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SettingsContainer,
@@ -13,7 +14,7 @@ import {
SettingsSectionTitle
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { StrategySelect } from "@app/components/StrategySelect";
import { OidcIdpProviderTypeSelect } from "@app/components/idp/OidcIdpProviderTypeSelect";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import {
@@ -27,21 +28,26 @@ import {
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { applyOidcIdpProviderType } from "@app/lib/idp/oidcIdpProviderDefaults";
import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { ListRolesResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
compileRoleMappingExpression,
createMappingBuilderRule,
MappingBuilderRule,
RoleMappingMode
} from "@app/lib/idpRoleMapping";
export default function Page() {
const { env } = useEnvContext();
@@ -49,9 +55,15 @@ export default function Page() {
const router = useRouter();
const [createLoading, setCreateLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [roleMappingMode, setRoleMappingMode] = useState<
"role" | "expression"
>("role");
const [roleMappingMode, setRoleMappingMode] =
useState<RoleMappingMode>("fixedRoles");
const [fixedRoleNames, setFixedRoleNames] = useState<string[]>([]);
const [mappingBuilderClaimPath, setMappingBuilderClaimPath] =
useState("groups");
const [mappingBuilderRules, setMappingBuilderRules] = useState<
MappingBuilderRule[]
>([createMappingBuilderRule()]);
const [rawRoleExpression, setRawRoleExpression] = useState("");
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
@@ -84,49 +96,6 @@ export default function Page() {
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
interface ProviderTypeOption {
id: "oidc" | "google" | "azure";
title: string;
description: string;
icon?: React.ReactNode;
}
const providerTypes: ReadonlyArray<ProviderTypeOption> = [
{
id: "oidc",
title: "OAuth2/OIDC",
description: t("idpOidcDescription")
},
{
id: "google",
title: t("idpGoogleTitle"),
description: t("idpGoogleDescription"),
icon: (
<Image
src="/idp/google.png"
alt={t("idpGoogleAlt")}
width={24}
height={24}
className="rounded"
/>
)
},
{
id: "azure",
title: t("idpAzureTitle"),
description: t("idpAzureDescription"),
icon: (
<Image
src="/idp/azure.png"
alt={t("idpAzureAlt")}
width={24}
height={24}
className="rounded"
/>
)
}
];
const form = useForm({
resolver: zodResolver(createIdpFormSchema),
defaultValues: {
@@ -174,47 +143,6 @@ export default function Page() {
fetchRoles();
}, []);
// Handle provider type changes and set defaults
const handleProviderChange = (value: "oidc" | "google" | "azure") => {
form.setValue("type", value);
if (value === "google") {
// Set Google defaults
form.setValue(
"authUrl",
"https://accounts.google.com/o/oauth2/v2/auth"
);
form.setValue("tokenUrl", "https://oauth2.googleapis.com/token");
form.setValue("identifierPath", "email");
form.setValue("emailPath", "email");
form.setValue("namePath", "name");
form.setValue("scopes", "openid profile email");
} else if (value === "azure") {
// Set Azure Entra ID defaults (URLs will be constructed dynamically)
form.setValue(
"authUrl",
"https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/authorize"
);
form.setValue(
"tokenUrl",
"https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/token"
);
form.setValue("identifierPath", "email");
form.setValue("emailPath", "email");
form.setValue("namePath", "name");
form.setValue("scopes", "openid profile email");
form.setValue("tenantId", "");
} else {
// Reset to OIDC defaults
form.setValue("authUrl", "");
form.setValue("tokenUrl", "");
form.setValue("identifierPath", "sub");
form.setValue("namePath", "name");
form.setValue("emailPath", "email");
form.setValue("scopes", "openid profile email");
}
};
async function onSubmit(data: CreateIdpFormValues) {
setCreateLoading(true);
@@ -228,7 +156,26 @@ export default function Page() {
tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId);
}
const roleName = roles.find((r) => r.roleId === data.roleId)?.name;
const roleMappingExpression = compileRoleMappingExpression({
mode: roleMappingMode,
fixedRoleNames,
mappingBuilder: {
claimPath: mappingBuilderClaimPath,
rules: mappingBuilderRules
},
rawExpression: rawRoleExpression
});
if (data.autoProvision && !roleMappingExpression) {
toast({
title: t("error"),
description:
"A role mapping is required when auto-provisioning is enabled.",
variant: "destructive"
});
setCreateLoading(false);
return;
}
const payload = {
name: data.name,
@@ -240,10 +187,7 @@ export default function Page() {
emailPath: data.emailPath,
namePath: data.namePath,
autoProvision: data.autoProvision,
roleMapping:
roleMappingMode === "role"
? `'${roleName}'`
: data.roleMapping || "",
roleMapping: roleMappingExpression,
scopes: data.scopes,
variant: data.type
};
@@ -308,23 +252,12 @@ export default function Page() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div>
<div className="mb-2">
<span className="text-sm font-medium">
{t("idpType")}
</span>
</div>
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
handleProviderChange(
value as "oidc" | "google" | "azure"
);
}}
cols={3}
/>
</div>
<OidcIdpProviderTypeSelect
value={form.watch("type")}
onTypeChange={(next) => {
applyOidcIdpProviderType(form.setValue, next);
}}
/>
<SettingsSectionForm>
<Form {...form}>
@@ -364,47 +297,48 @@ export default function Page() {
{t("idpAutoProvisionUsers")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpAutoProvisionUsersDescription")}
<IdpAutoProvisionUsersDescription />
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert
tiers={tierMatrix.autoProvisioning}
/>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<AutoProvisionConfigWidget
control={form.control}
autoProvision={
form.watch(
"autoProvision"
) as boolean
} // is this right?
onAutoProvisionChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
roleMappingMode={roleMappingMode}
onRoleMappingModeChange={(data) => {
setRoleMappingMode(data);
// Clear roleId and roleMapping when mode changes
form.setValue("roleId", null);
form.setValue("roleMapping", null);
}}
roles={roles}
roleIdFieldName="roleId"
roleMappingFieldName="roleMapping"
/>
</form>
</Form>
</SettingsSectionForm>
<PaidFeaturesAlert
tiers={tierMatrix.autoProvisioning}
/>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<AutoProvisionConfigWidget
autoProvision={
form.watch("autoProvision") as boolean
} // is this right?
onAutoProvisionChange={(checked) => {
form.setValue("autoProvision", checked);
}}
roleMappingMode={roleMappingMode}
onRoleMappingModeChange={(data) => {
setRoleMappingMode(data);
}}
roles={roles}
fixedRoleNames={fixedRoleNames}
onFixedRoleNamesChange={setFixedRoleNames}
mappingBuilderClaimPath={
mappingBuilderClaimPath
}
onMappingBuilderClaimPathChange={
setMappingBuilderClaimPath
}
mappingBuilderRules={mappingBuilderRules}
onMappingBuilderRulesChange={
setMappingBuilderRules
}
rawExpression={rawRoleExpression}
onRawExpressionChange={setRawRoleExpression}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
@@ -679,16 +613,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>

View File

@@ -29,9 +29,8 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
let invitations: {
inviteId: string;
email: string;
expiresAt: string;
roleId: number;
roleName?: string;
expiresAt: number;
roles: { roleId: number; roleName: string | null }[];
}[] = [];
let hasInvitations = false;
@@ -66,12 +65,15 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
}
const invitationRows: InvitationRow[] = invitations.map((invite) => {
const names = invite.roles
.map((r) => r.roleName || t("accessRoleUnknown"))
.filter(Boolean);
return {
id: invite.inviteId,
email: invite.email,
expiresAt: new Date(Number(invite.expiresAt)).toISOString(),
role: invite.roleName || t("accessRoleUnknown"),
roleId: invite.roleId
roleLabels: names,
roleIds: invite.roles.map((r) => r.roleId)
};
});

View File

@@ -8,18 +8,10 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Checkbox } from "@app/components/ui/checkbox";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -44,34 +36,69 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { UserType } from "@server/types/UserTypes";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
const accessControlsFormSchema = z.object({
username: z.string(),
autoProvisioned: z.boolean(),
roles: z.array(
z.object({
id: z.string(),
text: z.string()
})
)
});
export default function AccessControlsPage() {
const { orgUser: user } = userOrgUserContext();
const { orgUser: user, updateOrgUser } = userOrgUserContext();
const { env } = useEnvContext();
const api = createApiClient(useEnvContext());
const api = createApiClient({ env });
const { orgId } = useParams();
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
null
);
const t = useTranslations();
const formSchema = z.object({
username: z.string(),
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }),
autoProvisioned: z.boolean()
});
const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.fullRbac);
const supportsMultipleRolesPerUser = isPaid;
const showMultiRolePaywallMessage =
!env.flags.disableEnterpriseFeatures &&
((build === "saas" && !isPaid) ||
(build === "enterprise" && !isPaid) ||
(build === "oss" && !isPaid));
const form = useForm({
resolver: zodResolver(formSchema),
resolver: zodResolver(accessControlsFormSchema),
defaultValues: {
username: user.username!,
roleId: user.roleId?.toString(),
autoProvisioned: user.autoProvisioned || false
autoProvisioned: user.autoProvisioned || false,
roles: (user.roles ?? []).map((r) => ({
id: r.roleId.toString(),
text: r.name
}))
}
});
const currentRoleIds = user.roleIds ?? [];
useEffect(() => {
form.setValue(
"roles",
(user.roles ?? []).map((r) => ({
id: r.roleId.toString(),
text: r.name
}))
);
}, [user.userId, currentRoleIds.join(",")]);
useEffect(() => {
async function fetchRoles() {
const res = await api
@@ -94,32 +121,59 @@ export default function AccessControlsPage() {
}
fetchRoles();
form.setValue("roleId", user.roleId.toString());
form.setValue("autoProvisioned", user.autoProvisioned || false);
}, []);
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const allRoleOptions = roles.map((role) => ({
id: role.roleId.toString(),
text: role.name
}));
const paywallMessage =
build === "saas"
? t("singleRolePerUserPlanNotice")
: t("singleRolePerUserEditionNotice");
async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) {
if (values.roles.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: t("accessRoleSelectPlease")
});
return;
}
setLoading(true);
try {
// Execute both API calls simultaneously
const [roleRes, userRes] = await Promise.all([
api.post<AxiosResponse<InviteUserResponse>>(
`/role/${values.roleId}/add/${user.userId}`
),
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const updateRoleRequest = supportsMultipleRolesPerUser
? api.post(`/user/${user.userId}/org/${orgId}/roles`, {
roleIds
})
: api.post(`/role/${roleIds[0]}/add/${user.userId}`);
await Promise.all([
updateRoleRequest,
api.post(`/org/${orgId}/user/${user.userId}`, {
autoProvisioned: values.autoProvisioned
})
]);
if (roleRes.status === 200 && userRes.status === 200) {
toast({
variant: "default",
title: t("userSaved"),
description: t("userSavedDescription")
});
}
updateOrgUser({
roleIds,
roles: values.roles.map((r) => ({
roleId: parseInt(r.id, 10),
name: r.text
})),
autoProvisioned: values.autoProvisioned
});
toast({
variant: "default",
title: t("userSaved"),
description: t("userSavedDescription")
});
} catch (e) {
toast({
variant: "destructive",
@@ -130,7 +184,6 @@ export default function AccessControlsPage() {
)
});
}
setLoading(false);
}
@@ -154,7 +207,6 @@ export default function AccessControlsPage() {
className="space-y-4"
id="access-controls-form"
>
{/* IDP Type Display */}
{user.type !== UserType.Internal &&
user.idpType && (
<div className="flex items-center space-x-2 mb-4">
@@ -171,48 +223,22 @@ export default function AccessControlsPage() {
</div>
)}
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("role")}</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
// If auto provision is enabled, set it to false when role changes
if (user.idpAutoProvision) {
form.setValue(
"autoProvisioned",
false
);
}
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
<OrgRolesTagField
form={form}
name="roles"
label={t("roles")}
placeholder={t("accessRoleSelect2")}
allRoleOptions={allRoleOptions}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={paywallMessage}
loading={loading}
activeTagIndex={activeRoleTagIndex}
setActiveTagIndex={setActiveRoleTagIndex}
/>
{user.idpAutoProvision && (

View File

@@ -32,7 +32,7 @@ import {
} from "@app/components/ui/select";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
import { InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -49,6 +49,7 @@ import { build } from "@server/build";
import Image from "next/image";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
type UserType = "internal" | "oidc";
@@ -76,7 +77,14 @@ export default function Page() {
const api = createApiClient({ env });
const t = useTranslations();
const { hasSaasSubscription } = usePaidStatus();
const { hasSaasSubscription, isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.fullRbac);
const supportsMultipleRolesPerUser = isPaid;
const showMultiRolePaywallMessage =
!env.flags.disableEnterpriseFeatures &&
((build === "saas" && !isPaid) ||
(build === "enterprise" && !isPaid) ||
(build === "oss" && !isPaid));
const [selectedOption, setSelectedOption] = useState<string | null>(
"internal"
@@ -89,19 +97,34 @@ export default function Page() {
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [userOptions, setUserOptions] = useState<UserOption[]>([]);
const [dataLoaded, setDataLoaded] = useState(false);
const [activeInviteRoleTagIndex, setActiveInviteRoleTagIndex] = useState<
number | null
>(null);
const [activeOidcRoleTagIndex, setActiveOidcRoleTagIndex] = useState<
number | null
>(null);
const roleTagsFieldSchema = z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.min(1, { message: t("accessRoleSelectPlease") });
const internalFormSchema = z.object({
email: z.email({ message: t("emailInvalid") }),
validForHours: z
.string()
.min(1, { message: t("inviteValidityDuration") }),
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
roles: roleTagsFieldSchema
});
const googleAzureFormSchema = z.object({
email: z.email({ message: t("emailInvalid") }),
name: z.string().optional(),
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
roles: roleTagsFieldSchema
});
const genericOidcFormSchema = z.object({
@@ -111,7 +134,7 @@ export default function Page() {
.optional()
.or(z.literal("")),
name: z.string().optional(),
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
roles: roleTagsFieldSchema
});
const formatIdpType = (type: string) => {
@@ -166,12 +189,22 @@ export default function Page() {
{ hours: 168, name: t("day", { count: 7 }) }
];
const allRoleOptions = roles.map((role) => ({
id: role.roleId.toString(),
text: role.name
}));
const invitePaywallMessage =
build === "saas"
? t("singleRolePerUserPlanNotice")
: t("singleRolePerUserEditionNotice");
const internalForm = useForm({
resolver: zodResolver(internalFormSchema),
defaultValues: {
email: "",
validForHours: "72",
roleId: ""
roles: [] as { id: string; text: string }[]
}
});
@@ -180,7 +213,7 @@ export default function Page() {
defaultValues: {
email: "",
name: "",
roleId: ""
roles: [] as { id: string; text: string }[]
}
});
@@ -190,7 +223,7 @@ export default function Page() {
username: "",
email: "",
name: "",
roleId: ""
roles: [] as { id: string; text: string }[]
}
});
@@ -305,16 +338,17 @@ export default function Page() {
) {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/org/${orgId}/create-invite`,
{
email: values.email,
roleId: parseInt(values.roleId),
validHours: parseInt(values.validForHours),
sendEmail: sendEmail
} as InviteUserBody
)
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api.post<AxiosResponse<InviteUserResponse>>(
`/org/${orgId}/create-invite`,
{
email: values.email,
roleIds,
validHours: parseInt(values.validForHours),
sendEmail
}
)
.catch((e) => {
if (e.response?.status === 409) {
toast({
@@ -358,6 +392,8 @@ export default function Page() {
setLoading(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api
.put(`/org/${orgId}/user`, {
username: values.email, // Use email as username for Google/Azure
@@ -365,7 +401,7 @@ export default function Page() {
name: values.name,
type: "oidc",
idpId: selectedUserOption.idpId,
roleId: parseInt(values.roleId)
roleIds
})
.catch((e) => {
toast({
@@ -400,6 +436,8 @@ export default function Page() {
setLoading(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api
.put(`/org/${orgId}/user`, {
username: values.username,
@@ -407,7 +445,7 @@ export default function Page() {
name: values.name,
type: "oidc",
idpId: selectedUserOption.idpId,
roleId: parseInt(values.roleId)
roleIds
})
.catch((e) => {
toast({
@@ -575,52 +613,32 @@ export default function Page() {
)}
/>
<FormField
control={
internalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
<OrgRolesTagField
form={internalForm}
name="roles"
label={t("roles")}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={
invitePaywallMessage
}
loading={loading}
activeTagIndex={
activeInviteRoleTagIndex
}
setActiveTagIndex={
setActiveInviteRoleTagIndex
}
/>
{env.email.emailEnabled && (
@@ -764,52 +782,32 @@ export default function Page() {
)}
/>
<FormField
control={
googleAzureForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
<OrgRolesTagField
form={googleAzureForm}
name="roles"
label={t("roles")}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={
invitePaywallMessage
}
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/>
</form>
</Form>
@@ -909,52 +907,32 @@ export default function Page() {
)}
/>
<FormField
control={
genericOidcForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
<OrgRolesTagField
form={genericOidcForm}
name="roles"
label={t("roles")}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={
invitePaywallMessage
}
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/>
</form>
</Form>

View File

@@ -86,9 +86,14 @@ export default async function UsersPage(props: UsersPageProps) {
idpId: user.idpId,
idpName: user.idpName || t("idpNameInternal"),
status: t("userConfirmed"),
role: user.isOwner
? t("accessRoleOwner")
: user.roleName || t("accessRoleMember"),
roleLabels: user.isOwner
? [t("accessRoleOwner")]
: (() => {
const names = (user.roles ?? [])
.map((r) => r.roleName)
.filter((n): n is string => Boolean(n?.length));
return names.length ? names : [t("accessRoleMember")];
})(),
isOwner: user.isOwner || false
};
});

View File

@@ -129,12 +129,13 @@ export default function ResourceAuthenticationPage() {
orgId: org.org.orgId
})
);
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
orgQueries.identityProviders({
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery({
...orgQueries.identityProviders({
orgId: org.org.orgId,
useOrgOnlyIdp: env.app.identityProviderMode === "org"
})
);
}),
enabled: isPaidUser(tierMatrix.orgOidc)
});
const pageLoading =
isLoadingOrgRoles ||

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,
@@ -24,16 +25,14 @@ import {
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter,
SettingsSectionGrid
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState, useEffect } from "react";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import {
InfoSection,
InfoSectionContent,
@@ -41,8 +40,7 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { useTranslations } from "next-intl";
export default function GeneralPage() {
@@ -52,12 +50,12 @@ export default function GeneralPage() {
const { idpId } = useParams();
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const { isUnlocked } = useLicenseStatusContext();
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
const t = useTranslations();
const GeneralFormSchema = z.object({
const OidcFormSchema = z.object({
name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
clientSecret: z
@@ -72,10 +70,46 @@ export default function GeneralPage() {
autoProvision: z.boolean().default(false)
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
const GoogleFormSchema = z.object({
name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
clientSecret: z
.string()
.min(1, { message: t("idpClientSecretRequired") }),
autoProvision: z.boolean().default(false)
});
const form = useForm({
resolver: zodResolver(GeneralFormSchema),
const AzureFormSchema = z.object({
name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
clientSecret: z
.string()
.min(1, { message: t("idpClientSecretRequired") }),
tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }),
autoProvision: z.boolean().default(false)
});
type OidcFormValues = z.infer<typeof OidcFormSchema>;
type GoogleFormValues = z.infer<typeof GoogleFormSchema>;
type AzureFormValues = z.infer<typeof AzureFormSchema>;
type GeneralFormValues =
| OidcFormValues
| GoogleFormValues
| AzureFormValues;
const getFormSchema = () => {
switch (variant) {
case "google":
return GoogleFormSchema;
case "azure":
return AzureFormSchema;
default:
return OidcFormSchema;
}
};
const form = useForm<GeneralFormValues>({
resolver: zodResolver(getFormSchema()) as never,
defaultValues: {
name: "",
clientId: "",
@@ -86,28 +120,60 @@ export default function GeneralPage() {
emailPath: "email",
namePath: "name",
scopes: "openid profile email",
autoProvision: true
autoProvision: true,
tenantId: ""
}
});
useEffect(() => {
form.clearErrors();
}, [variant, form]);
useEffect(() => {
const loadIdp = async () => {
try {
const res = await api.get(`/idp/${idpId}`);
if (res.status === 200) {
const data = res.data.data;
form.reset({
const idpVariant =
(data.idpOidcConfig?.variant as
| "oidc"
| "google"
| "azure") || "oidc";
setVariant(idpVariant);
let tenantId = "";
if (idpVariant === "azure" && data.idpOidcConfig?.authUrl) {
const tenantMatch = data.idpOidcConfig.authUrl.match(
/login\.microsoftonline\.com\/([^/]+)\/oauth2/
);
if (tenantMatch) {
tenantId = tenantMatch[1];
}
}
const formData: Record<string, unknown> = {
name: data.idp.name,
clientId: data.idpOidcConfig.clientId,
clientSecret: data.idpOidcConfig.clientSecret,
authUrl: data.idpOidcConfig.authUrl,
tokenUrl: data.idpOidcConfig.tokenUrl,
identifierPath: data.idpOidcConfig.identifierPath,
emailPath: data.idpOidcConfig.emailPath,
namePath: data.idpOidcConfig.namePath,
scopes: data.idpOidcConfig.scopes,
autoProvision: data.idp.autoProvision
});
};
if (idpVariant === "oidc") {
formData.authUrl = data.idpOidcConfig.authUrl;
formData.tokenUrl = data.idpOidcConfig.tokenUrl;
formData.identifierPath =
data.idpOidcConfig.identifierPath;
formData.emailPath =
data.idpOidcConfig.emailPath ?? undefined;
formData.namePath =
data.idpOidcConfig.namePath ?? undefined;
formData.scopes = data.idpOidcConfig.scopes;
} else if (idpVariant === "azure") {
formData.tenantId = tenantId;
}
form.reset(formData as GeneralFormValues);
}
} catch (e) {
toast({
@@ -122,25 +188,76 @@ export default function GeneralPage() {
};
loadIdp();
}, [idpId, api, form, router]);
}, [idpId]);
async function onSubmit(data: GeneralFormValues) {
setLoading(true);
try {
const payload = {
const schema = getFormSchema();
const validationResult = schema.safeParse(data);
if (!validationResult.success) {
const errors = validationResult.error.flatten().fieldErrors;
Object.keys(errors).forEach((key) => {
const fieldName = key as keyof GeneralFormValues;
const errorMessage =
(errors as Record<string, string[] | undefined>)[
key
]?.[0] || t("invalidValue");
form.setError(fieldName, {
type: "manual",
message: errorMessage
});
});
setLoading(false);
return;
}
let payload: Record<string, unknown> = {
name: data.name,
clientId: data.clientId,
clientSecret: data.clientSecret,
authUrl: data.authUrl,
tokenUrl: data.tokenUrl,
identifierPath: data.identifierPath,
emailPath: data.emailPath,
namePath: data.namePath,
autoProvision: data.autoProvision,
scopes: data.scopes
variant
};
if (variant === "oidc") {
const oidcData = data as OidcFormValues;
payload = {
...payload,
authUrl: oidcData.authUrl,
tokenUrl: oidcData.tokenUrl,
identifierPath: oidcData.identifierPath,
emailPath: oidcData.emailPath ?? "",
namePath: oidcData.namePath ?? "",
scopes: oidcData.scopes
};
} else if (variant === "azure") {
const azureData = data as AzureFormValues;
const authUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/authorize`;
const tokenUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/token`;
payload = {
...payload,
authUrl,
tokenUrl,
identifierPath: "email",
emailPath: "email",
namePath: "name",
scopes: "openid profile email"
};
} else if (variant === "google") {
payload = {
...payload,
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
identifierPath: "email",
emailPath: "email",
namePath: "name",
scopes: "openid profile email"
};
}
const res = await api.post(`/idp/${idpId}/oidc`, payload);
if (res.status === 200) {
@@ -189,15 +306,13 @@ 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>
<div className="flex items-center space-x-2 mb-4">
<span className="text-sm font-medium text-muted-foreground">
{t("idpTypeLabel")}:
</span>
<IdpTypeBadge type={variant} />
</div>
<SettingsSectionForm>
<Form {...form}>
<form
@@ -223,39 +338,77 @@ export default function GeneralPage() {
</FormItem>
)}
/>
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label={t("idpAutoProvisionUsers")}
defaultChecked={form.getValues(
"autoProvision"
)}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
</div>
<span className="text-sm text-muted-foreground">
{t("idpAutoProvisionUsersDescription")}
</span>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpAutoProvisionUsers")}
</SettingsSectionTitle>
<SettingsSectionDescription>
<IdpAutoProvisionUsersDescription />
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label={t("idpAutoProvisionUsers")}
defaultChecked={form.getValues(
"autoProvision"
)}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
</div>
<div className="flex flex-col gap-2">
{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>
</SettingsSectionBody>
</SettingsSection>
{variant === "google" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpOidcConfigure")}
{t("idpGoogleConfiguration")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpOidcConfigureDescription")}
{t("idpGoogleConfigurationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -279,7 +432,7 @@ export default function GeneralPage() {
</FormControl>
<FormDescription>
{t(
"idpClientIdDescription"
"idpGoogleClientIdDescription"
)}
</FormDescription>
<FormMessage />
@@ -303,49 +456,7 @@ export default function GeneralPage() {
</FormControl>
<FormDescription>
{t(
"idpClientSecretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpAuthUrl")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpAuthUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpTokenUrl")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpTokenUrlDescription"
"idpGoogleClientSecretDescription"
)}
</FormDescription>
<FormMessage />
@@ -357,14 +468,16 @@ export default function GeneralPage() {
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{variant === "azure" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpToken")}
{t("idpAzureConfiguration")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpTokenDescription")}
{t("idpAzureConfigurationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -375,43 +488,20 @@ 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"
name="tenantId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpJmespathLabel")}
{t("idpTenantId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathLabelDescription"
"idpAzureTenantIdDescription"
)}
</FormDescription>
<FormMessage />
@@ -421,20 +511,18 @@ export default function GeneralPage() {
<FormField
control={form.control}
name="emailPath"
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathEmailPathOptional"
)}
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathEmailPathOptionalDescription"
"idpAzureClientIdDescription"
)}
</FormDescription>
<FormMessage />
@@ -444,43 +532,21 @@ export default function GeneralPage() {
<FormField
control={form.control}
name="namePath"
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathNamePathOptional"
)}
{t("idpClientSecret")}
</FormLabel>
<FormControl>
<Input {...field} />
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpJmespathNamePathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpOidcConfigureScopes"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpOidcConfigureScopesDescription"
"idpAzureClientSecretDescription"
)}
</FormDescription>
<FormMessage />
@@ -492,15 +558,263 @@ export default function GeneralPage() {
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsSectionGrid>
)}
{variant === "oidc" && (
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpOidcConfigure")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpOidcConfigureDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(
onSubmit
)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpClientSecret"
)}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpClientSecretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpAuthUrl")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpAuthUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpTokenUrl")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpTokenUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpToken")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpTokenDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(
onSubmit
)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="identifierPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathLabel"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathLabelDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathEmailPathOptional"
)}
</FormLabel>
<FormControl>
<Input
{...field}
value={
field.value ||
""
}
/>
</FormControl>
<FormDescription>
{t(
"idpJmespathEmailPathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namePath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathNamePathOptional"
)}
</FormLabel>
<FormControl>
<Input
{...field}
value={
field.value ||
""
}
/>
</FormControl>
<FormDescription>
{t(
"idpJmespathNamePathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpOidcConfigureScopes"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpOidcConfigureScopesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsSectionGrid>
)}
</SettingsContainer>
<div className="flex justify-end mt-8">
<Button
type="submit"
type="button"
form="general-settings-form"
loading={loading}
disabled={loading}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("saveGeneralSettings")}
</Button>

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

@@ -1,5 +1,7 @@
"use client";
import { OidcIdpProviderTypeSelect } from "@app/components/idp/OidcIdpProviderTypeSelect";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SettingsContainer,
SettingsSection,
@@ -20,70 +22,64 @@ import {
FormMessage
} from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod";
import { createElement, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { Input } from "@app/components/ui/input";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { applyOidcIdpProviderType } from "@app/lib/idp/oidcIdpProviderDefaults";
import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const [createLoading, setCreateLoading] = useState(false);
const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const templatesPaid = isPaidUser(tierMatrix.orgOidc);
const createIdpFormSchema = z.object({
name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
type: z.enum(["oidc"]),
type: z.enum(["oidc", "google", "azure"]),
clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
clientSecret: z
.string()
.min(1, { message: t("idpClientSecretRequired") }),
authUrl: z.url({ message: t("idpErrorAuthUrlInvalid") }),
tokenUrl: z.url({ message: t("idpErrorTokenUrlInvalid") }),
identifierPath: z.string().min(1, { message: t("idpPathRequired") }),
authUrl: z.url({ message: t("idpErrorAuthUrlInvalid") }).optional(),
tokenUrl: z.url({ message: t("idpErrorTokenUrlInvalid") }).optional(),
identifierPath: z
.string()
.min(1, { message: t("idpPathRequired") })
.optional(),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().min(1, { message: t("idpScopeRequired") }),
scopes: z
.string()
.min(1, { message: t("idpScopeRequired") })
.optional(),
tenantId: z.string().optional(),
autoProvision: z.boolean().default(false)
});
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
interface ProviderTypeOption {
id: "oidc";
title: string;
description: string;
}
const providerTypes: ReadonlyArray<ProviderTypeOption> = [
{
id: "oidc",
title: "OAuth2/OIDC",
description: t("idpOidcDescription")
}
];
const form = useForm({
resolver: zodResolver(createIdpFormSchema),
defaultValues: {
name: "",
type: "oidc",
type: "oidc" as const,
clientId: "",
clientSecret: "",
authUrl: "",
@@ -92,25 +88,46 @@ export default function Page() {
namePath: "name",
emailPath: "email",
scopes: "openid profile email",
tenantId: "",
autoProvision: false
}
});
const watchedType = form.watch("type");
const templatesLocked =
!templatesPaid && (watchedType === "google" || watchedType === "azure");
async function onSubmit(data: CreateIdpFormValues) {
if (
!templatesPaid &&
(data.type === "google" || data.type === "azure")
) {
return;
}
setCreateLoading(true);
try {
let authUrl = data.authUrl;
let tokenUrl = data.tokenUrl;
if (data.type === "azure" && data.tenantId) {
authUrl = authUrl?.replace("{{TENANT_ID}}", data.tenantId);
tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId);
}
const payload = {
name: data.name,
clientId: data.clientId,
clientSecret: data.clientSecret,
authUrl: data.authUrl,
tokenUrl: data.tokenUrl,
authUrl: authUrl,
tokenUrl: tokenUrl,
identifierPath: data.identifierPath,
emailPath: data.emailPath,
namePath: data.namePath,
autoProvision: data.autoProvision,
scopes: data.scopes
scopes: data.scopes,
variant: data.type
};
const res = await api.put("/idp/oidc", payload);
@@ -161,332 +178,480 @@ export default function Page() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t("idpDisplayName")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{templatesLocked ? (
<div className="mb-4">
<PaidFeaturesAlert tiers={tierMatrix.orgOidc} />
</div>
) : null}
<OidcIdpProviderTypeSelect
value={watchedType}
onTypeChange={(next) => {
applyOidcIdpProviderType(form.setValue, next);
}}
/>
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label={t("idpAutoProvisionUsers")}
defaultChecked={form.getValues(
"autoProvision"
<fieldset
disabled={templatesLocked}
className="min-w-0 border-0 p-0 m-0 disabled:pointer-events-none disabled:opacity-60"
>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t("idpDisplayName")}
</FormDescription>
<FormMessage />
</FormItem>
)}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
</div>
<span className="text-sm text-muted-foreground">
{t("idpAutoProvisionUsersDescription")}
</span>
</form>
</Form>
</SettingsSectionForm>
{/* <div> */}
{/* <div className="mb-2"> */}
{/* <span className="text-sm font-medium"> */}
{/* {t("idpType")} */}
{/* </span> */}
{/* </div> */}
{/* */}
{/* <StrategySelect */}
{/* options={providerTypes} */}
{/* defaultValue={form.getValues("type")} */}
{/* onChange={(value) => { */}
{/* form.setValue("type", value as "oidc"); */}
{/* }} */}
{/* cols={3} */}
{/* /> */}
{/* </div> */}
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label={t(
"idpAutoProvisionUsers"
)}
defaultChecked={form.getValues(
"autoProvision"
)}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
</div>
</form>
</Form>
</SettingsSectionForm>
</fieldset>
</SettingsSectionBody>
</SettingsSection>
{form.watch("type") === "oidc" && (
<SettingsSectionGrid cols={2}>
<fieldset
disabled={templatesLocked}
className="min-w-0 border-0 p-0 m-0 disabled:pointer-events-none disabled:opacity-60"
>
{watchedType === "google" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpOidcConfigure")}
{t("idpGoogleConfigurationTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpOidcConfigureDescription")}
{t("idpGoogleConfigurationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(
onSubmit
)}
/>
>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpGoogleClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientSecret")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpClientSecretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpAuthUrl")}
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/authorize"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpAuthUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpTokenUrl")}
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/token"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpTokenUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("idpOidcConfigureAlert")}
</AlertTitle>
<AlertDescription>
{t("idpOidcConfigureAlertDescription")}
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpClientSecret"
)}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpGoogleClientSecretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{watchedType === "azure" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpToken")}
{t("idpAzureConfigurationTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpTokenDescription")}
{t("idpAzureConfigurationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
className="space-y-4"
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"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpJmespathLabel")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathLabelDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(
onSubmit
)}
/>
>
<FormField
control={form.control}
name="tenantId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpTenantIdLabel"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpAzureTenantIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathEmailPathOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathEmailPathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpAzureClientIdDescription2"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namePath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathNamePathOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathNamePathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpOidcConfigureScopes"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpOidcConfigureScopesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpClientSecret"
)}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpAzureClientSecretDescription2"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsSectionGrid>
)}
)}
{watchedType === "oidc" && (
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpOidcConfigure")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpOidcConfigureDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(
onSubmit
)}
>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpClientSecret"
)}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpClientSecretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpAuthUrl")}
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/authorize"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpAuthUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpTokenUrl")}
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/token"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpTokenUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpToken")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpTokenDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(
onSubmit
)}
>
<FormField
control={form.control}
name="identifierPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathLabel"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathLabelDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathEmailPathOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathEmailPathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namePath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathNamePathOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathNamePathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpOidcConfigureScopes"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpOidcConfigureScopesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
</SettingsSectionGrid>
)}
</fieldset>
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
@@ -501,7 +666,7 @@ export default function Page() {
</Button>
<Button
type="submit"
disabled={createLoading}
disabled={createLoading || templatesLocked}
loading={createLoading}
onClick={form.handleSubmit(onSubmit)}
>

View File

@@ -12,6 +12,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { Layout } from "@app/components/Layout";
import { adminNavSections } from "../navigation";
import { pullEnv } from "@app/lib/pullEnv";
import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvider";
export const dynamic = "force-dynamic";
@@ -51,9 +52,15 @@ export default async function AdminLayout(props: LayoutProps) {
return (
<UserProvider user={user}>
<Layout orgs={orgs} navItems={adminNavSections(env)}>
{props.children}
</Layout>
<SubscriptionStatusProvider
subscriptionStatus={null}
env={env.app.environment}
sandbox_mode={env.app.sandbox_mode}
>
<Layout orgs={orgs} navItems={adminNavSections(env)}>
{props.children}
</Layout>
</SubscriptionStatusProvider>
</UserProvider>
);
}

View File

@@ -1,56 +1,51 @@
"use client";
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage
} from "@app/components/ui/form";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import { FormDescription } from "@app/components/ui/form";
import { SwitchInput } from "@app/components/SwitchInput";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Input } from "@app/components/ui/input";
import { useTranslations } from "next-intl";
import { Control, FieldValues, Path } from "react-hook-form";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping";
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
type Role = {
roleId: number;
name: string;
};
type AutoProvisionConfigWidgetProps<T extends FieldValues> = {
control: Control<T>;
type AutoProvisionConfigWidgetProps = {
autoProvision: boolean;
onAutoProvisionChange: (checked: boolean) => void;
roleMappingMode: "role" | "expression";
onRoleMappingModeChange: (mode: "role" | "expression") => void;
roleMappingMode: RoleMappingMode;
onRoleMappingModeChange: (mode: RoleMappingMode) => void;
roles: Role[];
roleIdFieldName: Path<T>;
roleMappingFieldName: Path<T>;
fixedRoleNames: string[];
onFixedRoleNamesChange: (roleNames: string[]) => void;
mappingBuilderClaimPath: string;
onMappingBuilderClaimPathChange: (claimPath: string) => void;
mappingBuilderRules: MappingBuilderRule[];
onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void;
rawExpression: string;
onRawExpressionChange: (expression: string) => void;
};
export default function AutoProvisionConfigWidget<T extends FieldValues>({
control,
export default function AutoProvisionConfigWidget({
autoProvision,
onAutoProvisionChange,
roleMappingMode,
onRoleMappingModeChange,
roles,
roleIdFieldName,
roleMappingFieldName
}: AutoProvisionConfigWidgetProps<T>) {
fixedRoleNames,
onFixedRoleNamesChange,
mappingBuilderClaimPath,
onMappingBuilderClaimPathChange,
mappingBuilderRules,
onMappingBuilderRulesChange,
rawExpression,
onRawExpressionChange
}: AutoProvisionConfigWidgetProps) {
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
return (
@@ -63,114 +58,26 @@ export default function AutoProvisionConfigWidget<T extends FieldValues>({
onCheckedChange={onAutoProvisionChange}
disabled={!isPaidUser(tierMatrix.autoProvisioning)}
/>
<span className="text-sm text-muted-foreground">
{t("idpAutoProvisionUsersDescription")}
</span>
</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 space-x-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="role" id="role-mode" />
<label
htmlFor="role-mode"
className="text-sm font-medium"
>
{t("selectRole")}
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="expression"
id="expression-mode"
/>
<label
htmlFor="expression-mode"
className="text-sm font-medium"
>
{t("roleMappingExpression")}
</label>
</div>
</RadioGroup>
</div>
{roleMappingMode === "role" ? (
<FormField
control={control}
name={roleIdFieldName}
render={({ field }) => (
<FormItem>
<Select
onValueChange={(value) =>
field.onChange(Number(value))
}
value={field.value?.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectRolePlaceholder"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{t("selectRoleDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={control}
name={roleMappingFieldName}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
defaultValue={field.value || ""}
value={field.value || ""}
placeholder={t(
"roleMappingExpressionPlaceholder"
)}
/>
</FormControl>
<FormDescription>
{t("roleMappingExpressionDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</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>
);

View File

@@ -0,0 +1,29 @@
"use client";
import { useTranslations } from "next-intl";
const AUTO_PROVISION_DOCS_URL =
"https://docs.pangolin.net/manage/identity-providers/auto-provisioning";
type IdpAutoProvisionUsersDescriptionProps = {
className?: string;
};
export default function IdpAutoProvisionUsersDescription({
className
}: IdpAutoProvisionUsersDescriptionProps) {
const t = useTranslations();
return (
<span className={className}>
{t("idpAutoProvisionUsersDescription")}{" "}
<a
href={AUTO_PROVISION_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t("learnMore")}
</a>
</span>
);
}

View File

@@ -27,6 +27,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import { StrategySelect } from "@app/components/StrategySelect";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import { SwitchInput } from "@app/components/SwitchInput";
import { Badge } from "@app/components/ui/badge";
import { useTranslations } from "next-intl";
@@ -163,9 +164,6 @@ export function IdpCreateWizard({
disabled={loading}
/>
</div>
<span className="text-sm text-muted-foreground">
{t("idpAutoProvisionUsersDescription")}
</span>
</form>
</Form>
</SettingsSectionForm>

View File

@@ -1,6 +1,5 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
@@ -21,13 +20,14 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import moment from "moment";
import { useRouter } from "next/navigation";
import UserRoleBadges from "@app/components/UserRoleBadges";
export type InvitationRow = {
id: string;
email: string;
expiresAt: string;
role: string;
roleId: number;
roleLabels: string[];
roleIds: number[];
};
type InvitationsTableProps = {
@@ -90,9 +90,13 @@ export default function InvitationsTable({
}
},
{
accessorKey: "role",
id: "roles",
accessorFn: (row) => row.roleLabels.join(", "),
friendlyName: t("role"),
header: () => <span className="p-3">{t("role")}</span>
header: () => <span className="p-3">{t("role")}</span>,
cell: ({ row }) => (
<UserRoleBadges roleLabels={row.original.roleLabels} />
)
},
{
id: "dots",

View File

@@ -0,0 +1,117 @@
"use client";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import type { Dispatch, SetStateAction } from "react";
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
export type RoleTag = {
id: string;
text: string;
};
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
form: Pick<UseFormReturn<TFieldValues>, "control" | "getValues" | "setValue">;
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
name?: Path<TFieldValues>;
label: string;
placeholder: string;
allRoleOptions: Tag[];
supportsMultipleRolesPerUser: boolean;
showMultiRolePaywallMessage: boolean;
paywallMessage: string;
loading?: boolean;
activeTagIndex: number | null;
setActiveTagIndex: Dispatch<SetStateAction<number | null>>;
};
export default function OrgRolesTagField<TFieldValues extends FieldValues>({
form,
name = "roles" as Path<TFieldValues>,
label,
placeholder,
allRoleOptions,
supportsMultipleRolesPerUser,
showMultiRolePaywallMessage,
paywallMessage,
loading = false,
activeTagIndex,
setActiveTagIndex
}: OrgRolesTagFieldProps<TFieldValues>) {
const t = useTranslations();
function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
const prev = form.getValues(name) as Tag[];
const nextValue =
typeof updater === "function" ? updater(prev) : updater;
const next = supportsMultipleRolesPerUser
? nextValue
: nextValue.length > 1
? [nextValue[nextValue.length - 1]]
: nextValue;
if (
!supportsMultipleRolesPerUser &&
next.length === 0 &&
prev.length > 0
) {
form.setValue(name, [prev[prev.length - 1]] as never, {
shouldDirty: true
});
return;
}
if (next.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: t("accessRoleSelectPlease")
});
return;
}
form.setValue(name, next as never, { shouldDirty: true });
}
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{label}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder={placeholder}
size="sm"
tags={field.value}
setTags={setRoleTags}
enableAutocomplete={true}
autocompleteOptions={allRoleOptions}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
disabled={loading}
/>
</FormControl>
{showMultiRolePaywallMessage && (
<FormDescription>{paywallMessage}</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@@ -95,7 +95,8 @@ function getActionsCategories(root: boolean) {
[t("actionListRole")]: "listRoles",
[t("actionUpdateRole")]: "updateRole",
[t("actionListAllowedRoleResources")]: "listRoleResources",
[t("actionAddUserRole")]: "addUserRole"
[t("actionAddUserRole")]: "addUserRole",
[t("actionRemoveUserRole")]: "removeUserRole"
},
"Access Token": {
[t("actionGenerateAccessToken")]: "generateAccessToken",

View File

@@ -32,15 +32,15 @@ type RegenerateInvitationFormProps = {
invitation: {
id: string;
email: string;
roleId: number;
role: string;
roleIds: number[];
roleLabels: string[];
} | null;
onRegenerate: (updatedInvitation: {
id: string;
email: string;
expiresAt: string;
role: string;
roleId: number;
roleLabels: string[];
roleIds: number[];
}) => void;
};
@@ -94,7 +94,7 @@ export default function RegenerateInvitationForm({
try {
const res = await api.post(`/org/${org.org.orgId}/create-invite`, {
email: invitation.email,
roleId: invitation.roleId,
roleIds: invitation.roleIds,
validHours,
sendEmail,
regenerate: true
@@ -127,9 +127,11 @@ export default function RegenerateInvitationForm({
onRegenerate({
id: invitation.id,
email: invitation.email,
expiresAt: res.data.data.expiresAt,
role: invitation.role,
roleId: invitation.roleId
expiresAt: new Date(
res.data.data.expiresAt
).toISOString(),
roleLabels: invitation.roleLabels,
roleIds: invitation.roleIds
});
}
} catch (error: any) {

View File

@@ -0,0 +1,471 @@
"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>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { Badge, badgeVariants } from "@app/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
const MAX_ROLE_BADGES = 3;
export default function UserRoleBadges({
roleLabels
}: {
roleLabels: string[];
}) {
const visible = roleLabels.slice(0, MAX_ROLE_BADGES);
const overflow = roleLabels.slice(MAX_ROLE_BADGES);
return (
<div className="flex flex-wrap items-center gap-1">
{visible.map((label, i) => (
<Badge key={`${label}-${i}`} variant="secondary">
{label}
</Badge>
))}
{overflow.length > 0 && (
<OverflowRolesPopover labels={overflow} />
)}
</div>
);
}
function OverflowRolesPopover({ labels }: { labels: string[] }) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
badgeVariants({ variant: "secondary" }),
"border-dashed"
)}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
+{labels.length}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="w-auto max-w-xs p-2"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<ul className="space-y-1 text-sm">
{labels.map((label, i) => (
<li key={`${label}-${i}`}>{label}</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}

View File

@@ -24,6 +24,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "./IdpTypeBadge";
import UserRoleBadges from "./UserRoleBadges";
export type UserRow = {
id: string;
@@ -36,7 +37,7 @@ export type UserRow = {
type: string;
idpVariant: string | null;
status: string;
role: string;
roleLabels: string[];
isOwner: boolean;
};
@@ -124,7 +125,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
}
},
{
accessorKey: "role",
id: "role",
accessorFn: (row) => row.roleLabels.join(", "),
friendlyName: t("role"),
header: ({ column }) => {
return (
@@ -140,13 +142,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
);
},
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-2">
<span>{userRow.role}</span>
</div>
);
return <UserRoleBadges roleLabels={row.original.roleLabels} />;
}
},
{

View File

@@ -0,0 +1,87 @@
"use client";
import {
StrategySelect,
type StrategyOption
} from "@app/components/StrategySelect";
import { useEnvContext } from "@app/hooks/useEnvContext";
import type { IdpOidcProviderType } from "@app/lib/idp/oidcIdpProviderDefaults";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { useEffect, useMemo } from "react";
type Props = {
value: IdpOidcProviderType;
onTypeChange: (type: IdpOidcProviderType) => void;
};
export function OidcIdpProviderTypeSelect({ value, onTypeChange }: Props) {
const t = useTranslations();
const { env } = useEnvContext();
const hideTemplates = env.flags.disableEnterpriseFeatures;
useEffect(() => {
if (hideTemplates && (value === "google" || value === "azure")) {
onTypeChange("oidc");
}
}, [hideTemplates, value, onTypeChange]);
const options: ReadonlyArray<StrategyOption<IdpOidcProviderType>> =
useMemo(() => {
const base: StrategyOption<IdpOidcProviderType>[] = [
{
id: "oidc",
title: "OAuth2/OIDC",
description: t("idpOidcDescription")
}
];
if (hideTemplates) {
return base;
}
return [
...base,
{
id: "google",
title: t("idpGoogleTitle"),
description: t("idpGoogleDescription"),
icon: (
<Image
src="/idp/google.png"
alt={t("idpGoogleAlt")}
width={24}
height={24}
className="rounded"
/>
)
},
{
id: "azure",
title: t("idpAzureTitle"),
description: t("idpAzureDescription"),
icon: (
<Image
src="/idp/azure.png"
alt={t("idpAzureAlt")}
width={24}
height={24}
className="rounded"
/>
)
}
];
}, [hideTemplates, t]);
return (
<div>
<div className="mb-2">
<span className="text-sm font-medium">{t("idpType")}</span>
</div>
<StrategySelect
value={value}
options={options}
onChange={onTypeChange}
cols={3}
/>
</div>
);
}

View File

@@ -1,10 +1,23 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
// import { Command, CommandList, CommandItem, CommandGroup, CommandEmpty } from '../ui/command';
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "../ui/command";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger
} from "../ui/popover";
import { Button } from "../ui/button";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
import { Check } from "lucide-react";
type AutocompleteProps = {
tags: TagType[];
@@ -20,6 +33,8 @@ type AutocompleteProps = {
inlineTags?: boolean;
classStyleProps: TagInputStyleClassesProps["autoComplete"];
usePortal?: boolean;
/** Narrows the dropdown list from the main field (cmdk search filters further). */
filterQuery?: string;
};
export const Autocomplete: React.FC<AutocompleteProps> = ({
@@ -35,10 +50,10 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
inlineTags,
children,
classStyleProps,
usePortal
usePortal,
filterQuery = ""
}) => {
const triggerContainerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const popoverContentRef = useRef<HTMLDivElement | null>(null);
const t = useTranslations();
@@ -46,17 +61,21 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
const [popoverWidth, setPopoverWidth] = useState<number>(0);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [inputFocused, setInputFocused] = useState(false);
const [popooverContentTop, setPopoverContentTop] = useState<number>(0);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const [commandResetKey, setCommandResetKey] = useState(0);
// Dynamically calculate the top position for the popover content
useEffect(() => {
if (!triggerContainerRef.current || !triggerRef.current) return;
setPopoverContentTop(
triggerContainerRef.current?.getBoundingClientRect().bottom -
triggerRef.current?.getBoundingClientRect().bottom
const visibleOptions = useMemo(() => {
const q = filterQuery.trim().toLowerCase();
if (!q) return autocompleteOptions;
return autocompleteOptions.filter((option) =>
option.text.toLowerCase().includes(q)
);
}, [tags]);
}, [autocompleteOptions, filterQuery]);
useEffect(() => {
if (isPopoverOpen) {
setCommandResetKey((k) => k + 1);
}
}, [isPopoverOpen]);
// Close the popover when clicking outside of it
useEffect(() => {
@@ -135,36 +154,6 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
if (userOnBlur) userOnBlur(event);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!isPopoverOpen) return;
switch (event.key) {
case "ArrowUp":
event.preventDefault();
setSelectedIndex((prevIndex) =>
prevIndex <= 0
? autocompleteOptions.length - 1
: prevIndex - 1
);
break;
case "ArrowDown":
event.preventDefault();
setSelectedIndex((prevIndex) =>
prevIndex === autocompleteOptions.length - 1
? 0
: prevIndex + 1
);
break;
case "Enter":
event.preventDefault();
if (selectedIndex !== -1) {
toggleTag(autocompleteOptions[selectedIndex]);
setSelectedIndex(-1);
}
break;
}
};
const toggleTag = (option: TagType) => {
// Check if the tag already exists in the array
const index = tags.findIndex((tag) => tag.text === option.text);
@@ -197,18 +186,25 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
}
}
}
setSelectedIndex(-1);
};
const childrenWithProps = React.cloneElement(
children as React.ReactElement<any>,
{
onKeyDown: handleKeyDown,
onFocus: handleInputFocus,
onBlur: handleInputBlur,
ref: inputRef
const child = children as React.ReactElement<
React.InputHTMLAttributes<HTMLInputElement> & {
ref?: React.Ref<HTMLInputElement>;
}
);
>;
const userOnKeyDown = child.props.onKeyDown;
const childrenWithProps = React.cloneElement(child, {
onKeyDown: userOnKeyDown,
onFocus: handleInputFocus,
onBlur: handleInputBlur,
ref: inputRef
} as Partial<
React.InputHTMLAttributes<HTMLInputElement> & {
ref?: React.Ref<HTMLInputElement>;
}
>);
return (
<div
@@ -222,132 +218,105 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
onOpenChange={handleOpenChange}
modal={usePortal}
>
<div
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-3"
ref={triggerContainerRef}
>
{childrenWithProps}
<PopoverTrigger asChild ref={triggerRef}>
<Button
variant="ghost"
size="icon"
role="combobox"
className={cn(
`hover:bg-transparent ${!inlineTags ? "ml-auto" : ""}`,
classStyleProps?.popoverTrigger
)}
onClick={() => {
setIsPopoverOpen(!isPopoverOpen);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
<PopoverAnchor asChild>
<div
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-3"
ref={triggerContainerRef}
>
{childrenWithProps}
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
role="combobox"
className={cn(
`hover:bg-transparent ${!inlineTags ? "ml-auto" : ""}`,
classStyleProps?.popoverTrigger
)}
onClick={() => {
setIsPopoverOpen(!isPopoverOpen);
}}
>
<path d="m6 9 6 6 6-6"></path>
</svg>
</Button>
</PopoverTrigger>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
>
<path d="m6 9 6 6 6-6"></path>
</svg>
</Button>
</PopoverTrigger>
</div>
</PopoverAnchor>
<PopoverContent
ref={popoverContentRef}
side="bottom"
align="start"
forceMount
className={cn(
`p-0 relative`,
"p-0",
classStyleProps?.popoverContent
)}
style={{
top: `${popooverContentTop}px`,
marginLeft: `calc(-${popoverWidth}px + 36px)`,
width: `${popoverWidth}px`,
minWidth: `${popoverWidth}px`,
zIndex: 9999
}}
>
<div
<Command
key={commandResetKey}
className={cn(
"max-h-[300px] overflow-y-auto overflow-x-hidden",
classStyleProps?.commandList
"rounded-lg border-0 shadow-none",
classStyleProps?.command
)}
style={{
minHeight: "68px"
}}
key={autocompleteOptions.length}
>
{autocompleteOptions.length > 0 ? (
<div
key={autocompleteOptions.length}
role="group"
className={cn(
"overflow-y-auto overflow-hidden p-1 text-foreground",
classStyleProps?.commandGroup
)}
style={{
minHeight: "68px"
}}
<CommandInput
placeholder={t("searchPlaceholder")}
className="h-9"
/>
<CommandList
className={cn(
"max-h-[300px]",
classStyleProps?.commandList
)}
>
<CommandEmpty>{t("noResults")}</CommandEmpty>
<CommandGroup
className={classStyleProps?.commandGroup}
>
<span className="text-muted-foreground font-medium text-sm py-1.5 px-2 pb-2">
Suggestions
</span>
<div role="separator" className="py-0.5" />
{autocompleteOptions.map((option, index) => {
const isSelected = index === selectedIndex;
{visibleOptions.map((option) => {
const isChosen = tags.some(
(tag) => tag.text === option.text
);
return (
<div
<CommandItem
key={option.id}
role="option"
aria-selected={isSelected}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-accent",
isSelected &&
"bg-accent text-accent-foreground",
classStyleProps?.commandItem
)}
data-value={option.text}
onClick={() => toggleTag(option)}
value={`${option.text} ${option.id}`}
onSelect={() => toggleTag(option)}
className={classStyleProps?.commandItem}
>
<div className="w-full flex items-center gap-2">
{option.text}
{tags.some(
(tag) =>
tag.text === option.text
) && (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-check"
>
<path d="M20 6 9 17l-5-5"></path>
</svg>
<Check
className={cn(
"mr-2 h-4 w-4 shrink-0",
isChosen
? "opacity-100"
: "opacity-0"
)}
</div>
</div>
/>
{option.text}
</CommandItem>
);
})}
</div>
) : (
<div className="py-6 text-center text-sm">
{t("noResults")}
</div>
)}
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useMemo } from "react";
import React from "react";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { type VariantProps } from "class-variance-authority";
@@ -434,14 +434,6 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
// const filteredAutocompleteOptions = autocompleteFilter
// ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text))
// : autocompleteOptions;
const filteredAutocompleteOptions = useMemo(() => {
return (autocompleteOptions || []).filter((option) =>
option.text
.toLowerCase()
.includes(inputValue ? inputValue.toLowerCase() : "")
);
}, [inputValue, autocompleteOptions]);
const displayedTags = sortTags ? [...tags].sort() : tags;
const truncatedTags = truncate
@@ -571,9 +563,9 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
tags={tags}
setTags={setTags}
setInputValue={setInputValue}
autocompleteOptions={
filteredAutocompleteOptions as Tag[]
}
autocompleteOptions={(autocompleteOptions ||
[]) as Tag[]}
filterQuery={inputValue}
setTagCount={setTagCount}
maxTags={maxTags}
onTagAdd={onTagAdd}

View File

@@ -1,5 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger
} from "../ui/popover";
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { TagList, TagListProps } from "./tag-list";
import { Button } from "../ui/button";
@@ -33,33 +38,27 @@ export const TagPopover: React.FC<TagPopoverProps> = ({
...tagProps
}) => {
const triggerContainerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const popoverContentRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [popoverWidth, setPopoverWidth] = useState<number>(0);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [inputFocused, setInputFocused] = useState(false);
const [sideOffset, setSideOffset] = useState<number>(0);
const t = useTranslations();
useEffect(() => {
const handleResize = () => {
if (triggerContainerRef.current && triggerRef.current) {
if (triggerContainerRef.current) {
setPopoverWidth(triggerContainerRef.current.offsetWidth);
setSideOffset(
triggerContainerRef.current.offsetWidth -
triggerRef?.current?.offsetWidth
);
}
};
handleResize(); // Call on mount and layout changes
handleResize();
window.addEventListener("resize", handleResize); // Adjust on window resize
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [triggerContainerRef, triggerRef]);
}, []);
// Close the popover when clicking outside of it
useEffect(() => {
@@ -135,52 +134,54 @@ export const TagPopover: React.FC<TagPopoverProps> = ({
onOpenChange={handleOpenChange}
modal={usePortal}
>
<div
className="relative flex items-center rounded-md border border-input bg-transparent pr-3"
ref={triggerContainerRef}
>
{React.cloneElement(children as React.ReactElement<any>, {
onFocus: handleInputFocus,
onBlur: handleInputBlur,
ref: inputRef
})}
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="ghost"
size="icon"
role="combobox"
className={cn(
`hover:bg-transparent`,
classStyleProps?.popoverClasses?.popoverTrigger
)}
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
<PopoverAnchor asChild>
<div
className="relative flex items-center rounded-md border border-input bg-transparent pr-3"
ref={triggerContainerRef}
>
{React.cloneElement(children as React.ReactElement<any>, {
onFocus: handleInputFocus,
onBlur: handleInputBlur,
ref: inputRef
})}
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
role="combobox"
className={cn(
`hover:bg-transparent`,
classStyleProps?.popoverClasses?.popoverTrigger
)}
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
>
<path d="m6 9 6 6 6-6"></path>
</svg>
</Button>
</PopoverTrigger>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
>
<path d="m6 9 6 6 6-6"></path>
</svg>
</Button>
</PopoverTrigger>
</div>
</PopoverAnchor>
<PopoverContent
ref={popoverContentRef}
align="start"
side="bottom"
className={cn(
`w-full space-y-3`,
classStyleProps?.popoverClasses?.popoverContent
)}
style={{
marginLeft: `-${sideOffset}px`,
width: `${popoverWidth}px`
}}
>

View File

@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0",
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0",
{
variants: {
variant: {

View File

@@ -0,0 +1,46 @@
import type { FieldValues, UseFormSetValue } from "react-hook-form";
export type IdpOidcProviderType = "oidc" | "google" | "azure";
export function applyOidcIdpProviderType<T extends FieldValues>(
setValue: UseFormSetValue<T>,
provider: IdpOidcProviderType
): void {
setValue("type" as never, provider as never);
if (provider === "google") {
setValue(
"authUrl" as never,
"https://accounts.google.com/o/oauth2/v2/auth" as never
);
setValue(
"tokenUrl" as never,
"https://oauth2.googleapis.com/token" as never
);
setValue("identifierPath" as never, "email" as never);
setValue("emailPath" as never, "email" as never);
setValue("namePath" as never, "name" as never);
setValue("scopes" as never, "openid profile email" as never);
} else if (provider === "azure") {
setValue(
"authUrl" as never,
"https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/authorize" as never
);
setValue(
"tokenUrl" as never,
"https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/token" as never
);
setValue("identifierPath" as never, "email" as never);
setValue("emailPath" as never, "email" as never);
setValue("namePath" as never, "name" as never);
setValue("scopes" as never, "openid profile email" as never);
setValue("tenantId" as never, "" as never);
} else {
setValue("authUrl" as never, "" as never);
setValue("tokenUrl" as never, "" as never);
setValue("identifierPath" as never, "sub" as never);
setValue("namePath" as never, "name" as never);
setValue("emailPath" as never, "email" as never);
setValue("scopes" as never, "openid profile email" as never);
}
}

266
src/lib/idpRoleMapping.ts Normal file
View File

@@ -0,0 +1,266 @@
export type RoleMappingMode = "fixedRoles" | "mappingBuilder" | "rawExpression";
export type MappingBuilderRule = {
/** Stable React list key; not used when compiling JMESPath. */
id?: string;
matchValue: string;
roleNames: string[];
};
function newMappingBuilderRuleId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `rule-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
export function createMappingBuilderRule(): MappingBuilderRule {
return {
id: newMappingBuilderRuleId(),
matchValue: "",
roleNames: []
};
}
/** Ensures every rule has a stable id (e.g. after loading from the API). */
export function ensureMappingBuilderRuleIds(
rules: MappingBuilderRule[]
): MappingBuilderRule[] {
return rules.map((rule) =>
rule.id ? rule : { ...rule, id: newMappingBuilderRuleId() }
);
}
export type MappingBuilderConfig = {
claimPath: string;
rules: MappingBuilderRule[];
};
export type RoleMappingConfig = {
mode: RoleMappingMode;
fixedRoleNames: string[];
mappingBuilder: MappingBuilderConfig;
rawExpression: string;
};
const SINGLE_QUOTED_ROLE_REGEX = /^'([^']+)'$/;
const QUOTED_ROLE_ARRAY_REGEX = /^\[(.*)\]$/;
/** Stored role mappings created by the mapping builder are prefixed so the UI can restore the builder. */
export const PANGOLIN_ROLE_MAP_BUILDER_PREFIX = "__PANGOLIN_ROLE_MAP_BUILDER_V1__";
const BUILDER_METADATA_SEPARATOR = "\n---\n";
export type UnwrappedRoleMapping = {
/** Expression passed to JMESPath (no builder wrapper). */
evaluationExpression: string;
/** Present when the stored value was saved from the mapping builder. */
builderState: { claimPath: string; rules: MappingBuilderRule[] } | null;
};
/**
* Split stored DB value into evaluation expression and optional builder metadata.
* Legacy values (no prefix) are returned as-is for evaluation.
*/
export function unwrapRoleMapping(
stored: string | null | undefined
): UnwrappedRoleMapping {
const trimmed = stored?.trim() ?? "";
if (!trimmed.startsWith(PANGOLIN_ROLE_MAP_BUILDER_PREFIX)) {
return {
evaluationExpression: trimmed,
builderState: null
};
}
let rest = trimmed.slice(PANGOLIN_ROLE_MAP_BUILDER_PREFIX.length);
if (rest.startsWith("\n")) {
rest = rest.slice(1);
}
const sepIdx = rest.indexOf(BUILDER_METADATA_SEPARATOR);
if (sepIdx === -1) {
return {
evaluationExpression: trimmed,
builderState: null
};
}
const jsonPart = rest.slice(0, sepIdx).trim();
const inner = rest.slice(sepIdx + BUILDER_METADATA_SEPARATOR.length).trim();
try {
const meta = JSON.parse(jsonPart) as {
claimPath?: unknown;
rules?: unknown;
};
if (
typeof meta.claimPath === "string" &&
Array.isArray(meta.rules)
) {
const rules: MappingBuilderRule[] = meta.rules.map(
(r: unknown) => {
const row = r as {
matchValue?: unknown;
roleNames?: unknown;
};
return {
matchValue:
typeof row.matchValue === "string"
? row.matchValue
: "",
roleNames: Array.isArray(row.roleNames)
? row.roleNames.filter(
(n): n is string => typeof n === "string"
)
: []
};
}
);
return {
evaluationExpression: inner,
builderState: {
claimPath: meta.claimPath,
rules: ensureMappingBuilderRuleIds(rules)
}
};
}
} catch {
/* fall through */
}
return {
evaluationExpression: inner.length ? inner : trimmed,
builderState: null
};
}
function escapeSingleQuotes(value: string): string {
return value.replace(/'/g, "\\'");
}
export function compileRoleMappingExpression(config: RoleMappingConfig): string {
if (config.mode === "rawExpression") {
return config.rawExpression.trim();
}
if (config.mode === "fixedRoles") {
const roleNames = dedupeNonEmpty(config.fixedRoleNames);
if (!roleNames.length) {
return "";
}
if (roleNames.length === 1) {
return `'${escapeSingleQuotes(roleNames[0])}'`;
}
return `[${roleNames.map((name) => `'${escapeSingleQuotes(name)}'`).join(", ")}]`;
}
const claimPath = config.mappingBuilder.claimPath.trim();
const rules = config.mappingBuilder.rules
.map((rule) => ({
matchValue: rule.matchValue.trim(),
roleNames: dedupeNonEmpty(rule.roleNames)
}))
.filter((rule) => Boolean(rule.matchValue) && rule.roleNames.length > 0);
if (!claimPath || !rules.length) {
return "";
}
const compiledRules = rules.map((rule) => {
const mappedRoles = `[${rule.roleNames
.map((name) => `'${escapeSingleQuotes(name)}'`)
.join(", ")}]`;
return `contains(${claimPath}, '${escapeSingleQuotes(rule.matchValue)}') && ${mappedRoles} || []`;
});
const inner = `[${compiledRules.join(", ")}][]`;
const metadata = {
claimPath,
rules: rules.map((r) => ({
matchValue: r.matchValue,
roleNames: r.roleNames
}))
};
return `${PANGOLIN_ROLE_MAP_BUILDER_PREFIX}\n${JSON.stringify(metadata)}${BUILDER_METADATA_SEPARATOR}${inner}`;
}
export function detectRoleMappingConfig(
expression: string | null | undefined
): RoleMappingConfig {
const stored = expression?.trim() || "";
if (!stored) {
return defaultRoleMappingConfig();
}
const { evaluationExpression, builderState } = unwrapRoleMapping(stored);
if (builderState) {
return {
mode: "mappingBuilder",
fixedRoleNames: [],
mappingBuilder: {
claimPath: builderState.claimPath,
rules: builderState.rules
},
rawExpression: evaluationExpression
};
}
const tail = evaluationExpression.trim();
const singleMatch = tail.match(SINGLE_QUOTED_ROLE_REGEX);
if (singleMatch?.[1]) {
return {
mode: "fixedRoles",
fixedRoleNames: [singleMatch[1]],
mappingBuilder: defaultRoleMappingConfig().mappingBuilder,
rawExpression: tail
};
}
const arrayMatch = tail.match(QUOTED_ROLE_ARRAY_REGEX);
if (arrayMatch?.[1]) {
const roleNames = arrayMatch[1]
.split(",")
.map((entry) => entry.trim())
.map((entry) => entry.match(SINGLE_QUOTED_ROLE_REGEX)?.[1] || "")
.filter(Boolean);
if (roleNames.length > 0) {
return {
mode: "fixedRoles",
fixedRoleNames: roleNames,
mappingBuilder: defaultRoleMappingConfig().mappingBuilder,
rawExpression: tail
};
}
}
return {
mode: "rawExpression",
fixedRoleNames: [],
mappingBuilder: defaultRoleMappingConfig().mappingBuilder,
rawExpression: tail
};
}
export function defaultRoleMappingConfig(): RoleMappingConfig {
return {
mode: "fixedRoles",
fixedRoleNames: [],
mappingBuilder: {
claimPath: "groups",
rules: [createMappingBuilderRule()]
},
rawExpression: ""
};
}
function dedupeNonEmpty(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}