mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-01 07:26:38 +00:00
Merge branch 'multi-role' into dev
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
href: `/admin/idp/${params.idpId}/general`
|
||||
},
|
||||
{
|
||||
title: t("orgPolicies"),
|
||||
title: t("autoProvisionSettings"),
|
||||
href: `/admin/idp/${params.idpId}/policies`
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
@@ -34,6 +34,7 @@ import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react";
|
||||
import PolicyTable, { PolicyRow } from "../../../../../components/PolicyTable";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ListOrgsResponse } from "@server/routers/org";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -50,8 +51,6 @@ import {
|
||||
} from "@app/components/ui/command";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import { Textarea } from "@app/components/ui/textarea";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { GetIdpResponse } from "@server/routers/idp";
|
||||
import {
|
||||
SettingsContainer,
|
||||
@@ -64,16 +63,40 @@ import {
|
||||
SettingsSectionForm
|
||||
} from "@app/components/Settings";
|
||||
import { useTranslations } from "next-intl";
|
||||
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
|
||||
import {
|
||||
compileRoleMappingExpression,
|
||||
createMappingBuilderRule,
|
||||
defaultRoleMappingConfig,
|
||||
detectRoleMappingConfig,
|
||||
MappingBuilderRule,
|
||||
RoleMappingMode
|
||||
} from "@app/lib/idpRoleMapping";
|
||||
|
||||
type Organization = {
|
||||
orgId: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
function resetRoleMappingStateFromDetected(
|
||||
setMode: (m: RoleMappingMode) => void,
|
||||
setFixed: (v: string[]) => void,
|
||||
setClaim: (v: string) => void,
|
||||
setRules: (v: MappingBuilderRule[]) => void,
|
||||
setRaw: (v: string) => void,
|
||||
stored: string | null | undefined
|
||||
) {
|
||||
const d = detectRoleMappingConfig(stored);
|
||||
setMode(d.mode);
|
||||
setFixed(d.fixedRoleNames);
|
||||
setClaim(d.mappingBuilder.claimPath);
|
||||
setRules(d.mappingBuilder.rules);
|
||||
setRaw(d.rawExpression);
|
||||
}
|
||||
|
||||
export default function PoliciesPage() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const { idpId } = useParams();
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -88,14 +111,39 @@ export default function PoliciesPage() {
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [editingPolicy, setEditingPolicy] = useState<PolicyRow | null>(null);
|
||||
|
||||
const [defaultRoleMappingMode, setDefaultRoleMappingMode] =
|
||||
useState<RoleMappingMode>("fixedRoles");
|
||||
const [defaultFixedRoleNames, setDefaultFixedRoleNames] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [defaultMappingBuilderClaimPath, setDefaultMappingBuilderClaimPath] =
|
||||
useState("groups");
|
||||
const [defaultMappingBuilderRules, setDefaultMappingBuilderRules] =
|
||||
useState<MappingBuilderRule[]>([createMappingBuilderRule()]);
|
||||
const [defaultRawRoleExpression, setDefaultRawRoleExpression] =
|
||||
useState("");
|
||||
|
||||
const [policyRoleMappingMode, setPolicyRoleMappingMode] =
|
||||
useState<RoleMappingMode>("fixedRoles");
|
||||
const [policyFixedRoleNames, setPolicyFixedRoleNames] = useState<string[]>(
|
||||
[]
|
||||
);
|
||||
const [policyMappingBuilderClaimPath, setPolicyMappingBuilderClaimPath] =
|
||||
useState("groups");
|
||||
const [policyMappingBuilderRules, setPolicyMappingBuilderRules] = useState<
|
||||
MappingBuilderRule[]
|
||||
>([createMappingBuilderRule()]);
|
||||
const [policyRawRoleExpression, setPolicyRawRoleExpression] = useState("");
|
||||
const [policyOrgRoles, setPolicyOrgRoles] = useState<
|
||||
{ roleId: number; name: string }[]
|
||||
>([]);
|
||||
|
||||
const policyFormSchema = z.object({
|
||||
orgId: z.string().min(1, { message: t("orgRequired") }),
|
||||
roleMapping: z.string().optional(),
|
||||
orgMapping: z.string().optional()
|
||||
});
|
||||
|
||||
const defaultMappingsSchema = z.object({
|
||||
defaultRoleMapping: z.string().optional(),
|
||||
defaultOrgMapping: z.string().optional()
|
||||
});
|
||||
|
||||
@@ -106,15 +154,15 @@ export default function PoliciesPage() {
|
||||
resolver: zodResolver(policyFormSchema),
|
||||
defaultValues: {
|
||||
orgId: "",
|
||||
roleMapping: "",
|
||||
orgMapping: ""
|
||||
}
|
||||
});
|
||||
|
||||
const policyFormOrgId = form.watch("orgId");
|
||||
|
||||
const defaultMappingsForm = useForm({
|
||||
resolver: zodResolver(defaultMappingsSchema),
|
||||
defaultValues: {
|
||||
defaultRoleMapping: "",
|
||||
defaultOrgMapping: ""
|
||||
}
|
||||
});
|
||||
@@ -127,9 +175,16 @@ export default function PoliciesPage() {
|
||||
if (res.status === 200) {
|
||||
const data = res.data.data;
|
||||
defaultMappingsForm.reset({
|
||||
defaultRoleMapping: data.idp.defaultRoleMapping || "",
|
||||
defaultOrgMapping: data.idp.defaultOrgMapping || ""
|
||||
});
|
||||
resetRoleMappingStateFromDetected(
|
||||
setDefaultRoleMappingMode,
|
||||
setDefaultFixedRoleNames,
|
||||
setDefaultMappingBuilderClaimPath,
|
||||
setDefaultMappingBuilderRules,
|
||||
setDefaultRawRoleExpression,
|
||||
data.idp.defaultRoleMapping
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
@@ -184,11 +239,67 @@ export default function PoliciesPage() {
|
||||
load();
|
||||
}, [idpId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAddDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orgId = editingPolicy?.orgId || policyFormOrgId;
|
||||
if (!orgId) {
|
||||
setPolicyOrgRoles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const res = await api
|
||||
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleErrorFetch"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("accessRoleErrorFetchDescription")
|
||||
)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (!cancelled && res?.status === 200) {
|
||||
setPolicyOrgRoles(res.data.data.roles);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showAddDialog, editingPolicy?.orgId, policyFormOrgId, api, t]);
|
||||
|
||||
function resetPolicyDialogRoleMappingState() {
|
||||
const d = defaultRoleMappingConfig();
|
||||
setPolicyRoleMappingMode(d.mode);
|
||||
setPolicyFixedRoleNames(d.fixedRoleNames);
|
||||
setPolicyMappingBuilderClaimPath(d.mappingBuilder.claimPath);
|
||||
setPolicyMappingBuilderRules(d.mappingBuilder.rules);
|
||||
setPolicyRawRoleExpression(d.rawExpression);
|
||||
}
|
||||
|
||||
const onAddPolicy = async (data: PolicyFormValues) => {
|
||||
const roleMappingExpression = compileRoleMappingExpression({
|
||||
mode: policyRoleMappingMode,
|
||||
fixedRoleNames: policyFixedRoleNames,
|
||||
mappingBuilder: {
|
||||
claimPath: policyMappingBuilderClaimPath,
|
||||
rules: policyMappingBuilderRules
|
||||
},
|
||||
rawExpression: policyRawRoleExpression
|
||||
});
|
||||
|
||||
setAddPolicyLoading(true);
|
||||
try {
|
||||
const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, {
|
||||
roleMapping: data.roleMapping,
|
||||
roleMapping: roleMappingExpression,
|
||||
orgMapping: data.orgMapping
|
||||
});
|
||||
if (res.status === 201) {
|
||||
@@ -197,7 +308,7 @@ export default function PoliciesPage() {
|
||||
name:
|
||||
organizations.find((org) => org.orgId === data.orgId)
|
||||
?.name || "",
|
||||
roleMapping: data.roleMapping,
|
||||
roleMapping: roleMappingExpression,
|
||||
orgMapping: data.orgMapping
|
||||
};
|
||||
setPolicies([...policies, newPolicy]);
|
||||
@@ -207,6 +318,7 @@ export default function PoliciesPage() {
|
||||
});
|
||||
setShowAddDialog(false);
|
||||
form.reset();
|
||||
resetPolicyDialogRoleMappingState();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
@@ -222,12 +334,22 @@ export default function PoliciesPage() {
|
||||
const onEditPolicy = async (data: PolicyFormValues) => {
|
||||
if (!editingPolicy) return;
|
||||
|
||||
const roleMappingExpression = compileRoleMappingExpression({
|
||||
mode: policyRoleMappingMode,
|
||||
fixedRoleNames: policyFixedRoleNames,
|
||||
mappingBuilder: {
|
||||
claimPath: policyMappingBuilderClaimPath,
|
||||
rules: policyMappingBuilderRules
|
||||
},
|
||||
rawExpression: policyRawRoleExpression
|
||||
});
|
||||
|
||||
setEditPolicyLoading(true);
|
||||
try {
|
||||
const res = await api.post(
|
||||
`/idp/${idpId}/org/${editingPolicy.orgId}`,
|
||||
{
|
||||
roleMapping: data.roleMapping,
|
||||
roleMapping: roleMappingExpression,
|
||||
orgMapping: data.orgMapping
|
||||
}
|
||||
);
|
||||
@@ -237,7 +359,7 @@ export default function PoliciesPage() {
|
||||
policy.orgId === editingPolicy.orgId
|
||||
? {
|
||||
...policy,
|
||||
roleMapping: data.roleMapping,
|
||||
roleMapping: roleMappingExpression,
|
||||
orgMapping: data.orgMapping
|
||||
}
|
||||
: policy
|
||||
@@ -250,6 +372,7 @@ export default function PoliciesPage() {
|
||||
setShowAddDialog(false);
|
||||
setEditingPolicy(null);
|
||||
form.reset();
|
||||
resetPolicyDialogRoleMappingState();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
@@ -287,10 +410,20 @@ export default function PoliciesPage() {
|
||||
};
|
||||
|
||||
const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => {
|
||||
const defaultRoleMappingExpression = compileRoleMappingExpression({
|
||||
mode: defaultRoleMappingMode,
|
||||
fixedRoleNames: defaultFixedRoleNames,
|
||||
mappingBuilder: {
|
||||
claimPath: defaultMappingBuilderClaimPath,
|
||||
rules: defaultMappingBuilderRules
|
||||
},
|
||||
rawExpression: defaultRawRoleExpression
|
||||
});
|
||||
|
||||
setUpdateDefaultMappingsLoading(true);
|
||||
try {
|
||||
const res = await api.post(`/idp/${idpId}/oidc`, {
|
||||
defaultRoleMapping: data.defaultRoleMapping,
|
||||
defaultRoleMapping: defaultRoleMappingExpression,
|
||||
defaultOrgMapping: data.defaultOrgMapping
|
||||
});
|
||||
if (res.status === 200) {
|
||||
@@ -317,25 +450,36 @@ export default function PoliciesPage() {
|
||||
return (
|
||||
<>
|
||||
<SettingsContainer>
|
||||
<Alert variant="neutral" className="mb-6">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("orgPoliciesAbout")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{/*TODO(vlalx): Validate replacing */}
|
||||
{t("orgPoliciesAboutDescription")}{" "}
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/identity-providers/auto-provisioning"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t("orgPoliciesAboutDescriptionLink")}
|
||||
<ExternalLink className="ml-1 h-4 w-4 inline" />
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<PolicyTable
|
||||
policies={policies}
|
||||
onDelete={onDeletePolicy}
|
||||
onAdd={() => {
|
||||
loadOrganizations();
|
||||
form.reset({
|
||||
orgId: "",
|
||||
orgMapping: ""
|
||||
});
|
||||
setEditingPolicy(null);
|
||||
resetPolicyDialogRoleMappingState();
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
onEdit={(policy) => {
|
||||
setEditingPolicy(policy);
|
||||
form.reset({
|
||||
orgId: policy.orgId,
|
||||
orgMapping: policy.orgMapping || ""
|
||||
});
|
||||
resetRoleMappingStateFromDetected(
|
||||
setPolicyRoleMappingMode,
|
||||
setPolicyFixedRoleNames,
|
||||
setPolicyMappingBuilderClaimPath,
|
||||
setPolicyMappingBuilderRules,
|
||||
setPolicyRawRoleExpression,
|
||||
policy.roleMapping
|
||||
);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
@@ -353,51 +497,58 @@ export default function PoliciesPage() {
|
||||
onUpdateDefaultMappings
|
||||
)}
|
||||
id="policy-default-mappings-form"
|
||||
className="space-y-4"
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<FormField
|
||||
control={defaultMappingsForm.control}
|
||||
name="defaultRoleMapping"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("defaultMappingsRole")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"defaultMappingsRoleDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<RoleMappingConfigFields
|
||||
fieldIdPrefix="admin-idp-default-role"
|
||||
showFreeformRoleNamesHint={true}
|
||||
roleMappingMode={defaultRoleMappingMode}
|
||||
onRoleMappingModeChange={
|
||||
setDefaultRoleMappingMode
|
||||
}
|
||||
roles={[]}
|
||||
fixedRoleNames={defaultFixedRoleNames}
|
||||
onFixedRoleNamesChange={
|
||||
setDefaultFixedRoleNames
|
||||
}
|
||||
mappingBuilderClaimPath={
|
||||
defaultMappingBuilderClaimPath
|
||||
}
|
||||
onMappingBuilderClaimPathChange={
|
||||
setDefaultMappingBuilderClaimPath
|
||||
}
|
||||
mappingBuilderRules={
|
||||
defaultMappingBuilderRules
|
||||
}
|
||||
onMappingBuilderRulesChange={
|
||||
setDefaultMappingBuilderRules
|
||||
}
|
||||
rawExpression={defaultRawRoleExpression}
|
||||
onRawExpressionChange={
|
||||
setDefaultRawRoleExpression
|
||||
}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={defaultMappingsForm.control}
|
||||
name="defaultOrgMapping"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("defaultMappingsOrg")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"defaultMappingsOrgDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={defaultMappingsForm.control}
|
||||
name="defaultOrgMapping"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("defaultMappingsOrg")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"defaultMappingsOrgDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<SettingsSectionFooter>
|
||||
@@ -411,41 +562,20 @@ export default function PoliciesPage() {
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<PolicyTable
|
||||
policies={policies}
|
||||
onDelete={onDeletePolicy}
|
||||
onAdd={() => {
|
||||
loadOrganizations();
|
||||
form.reset({
|
||||
orgId: "",
|
||||
roleMapping: "",
|
||||
orgMapping: ""
|
||||
});
|
||||
setEditingPolicy(null);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
onEdit={(policy) => {
|
||||
setEditingPolicy(policy);
|
||||
form.reset({
|
||||
orgId: policy.orgId,
|
||||
roleMapping: policy.roleMapping || "",
|
||||
orgMapping: policy.orgMapping || ""
|
||||
});
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
|
||||
<Credenza
|
||||
open={showAddDialog}
|
||||
onOpenChange={(val) => {
|
||||
setShowAddDialog(val);
|
||||
setEditingPolicy(null);
|
||||
form.reset();
|
||||
if (!val) {
|
||||
setEditingPolicy(null);
|
||||
form.reset();
|
||||
resetPolicyDialogRoleMappingState();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaContent className="max-w-4xl 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
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user