From 7bcb852dba81b20d3d38469c88c8cad1dba8495d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 27 Mar 2026 18:10:19 -0700 Subject: [PATCH] add google and azure templates to global idp --- messages/en-US.json | 16 +- server/routers/idp/createOidcIdp.ts | 9 +- server/routers/idp/updateOidcIdp.ts | 9 +- .../(private)/idp/[idpId]/general/page.tsx | 33 - .../settings/(private)/idp/create/page.tsx | 124 +--- src/app/admin/idp/[idpId]/general/page.tsx | 638 +++++++++++++----- src/app/admin/idp/[idpId]/policies/page.tsx | 2 +- src/app/admin/idp/create/page.tsx | 282 ++++++-- src/app/admin/layout.tsx | 13 +- src/components/RoleMappingConfigFields.tsx | 2 +- .../idp/OidcIdpProviderTypeSelect.tsx | 75 ++ src/lib/idp/oidcIdpProviderDefaults.ts | 46 ++ 12 files changed, 870 insertions(+), 379 deletions(-) create mode 100644 src/components/idp/OidcIdpProviderTypeSelect.tsx create mode 100644 src/lib/idp/oidcIdpProviderDefaults.ts diff --git a/messages/en-US.json b/messages/en-US.json index 7d00c8105..505378b7f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -890,7 +890,7 @@ "defaultMappingsRole": "Default Role Mapping", "defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.", "defaultMappingsOrg": "Default Organization Mapping", - "defaultMappingsOrgDescription": "This expression must return the org ID or true for the user to be allowed to access the organization.", + "defaultMappingsOrgDescription": "When set, this expression must return the organization ID or true for the user to access that organization. When unset, defining an organization policy for that org is enough: the user is allowed in as long as a valid role mapping can be resolved for them within the organization.", "defaultMappingsSubmit": "Save Default Mappings", "orgPoliciesEdit": "Edit Organization Policy", "org": "Organization", @@ -1942,19 +1942,19 @@ "invalidValue": "Invalid value", "idpTypeLabel": "Identity Provider Type", "roleMappingExpressionPlaceholder": "e.g., contains(groups, 'admin') && 'Admin' || 'Member'", - "roleMappingModeFixedRoles": "Fixed roles", - "roleMappingModeMappingBuilder": "Mapping builder", - "roleMappingModeRawExpression": "Raw expression", + "roleMappingModeFixedRoles": "Fixed Roles", + "roleMappingModeMappingBuilder": "Mapping Builder", + "roleMappingModeRawExpression": "Raw Expression", "roleMappingFixedRolesPlaceholderSelect": "Select one or more roles", "roleMappingFixedRolesPlaceholderFreeform": "Type role names (exact match per organization)", "roleMappingFixedRolesDescriptionSameForAll": "Assign the same role set to every auto-provisioned user.", "roleMappingFixedRolesDescriptionDefaultPolicy": "For default policies, type role names that exist in each organization where users are provisioned. Names must match exactly.", - "roleMappingClaimPath": "Claim path", + "roleMappingClaimPath": "Claim Path", "roleMappingClaimPathPlaceholder": "groups", "roleMappingClaimPathDescription": "Path in the token payload that contains source values (for example, groups).", - "roleMappingMatchValue": "Match value", - "roleMappingAssignRoles": "Assign roles", - "roleMappingAddMappingRule": "Add mapping rule", + "roleMappingMatchValue": "Match Value", + "roleMappingAssignRoles": "Assign Roles", + "roleMappingAddMappingRule": "Add Mapping Rule", "roleMappingRawExpressionResultDescription": "Expression must evaluate to a string or string array.", "roleMappingMatchValuePlaceholder": "Match value (for example: admin)", "roleMappingAssignRolesPlaceholderFreeform": "Type role names (exact per org)", diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index 5b53f6820..0f0cc0cce 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -25,7 +25,8 @@ const bodySchema = z.strictObject({ namePath: z.string().optional(), scopes: z.string().nonempty(), autoProvision: z.boolean().optional(), - tags: z.string().optional() + tags: z.string().optional(), + variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc") }); export type CreateIdpResponse = { @@ -77,7 +78,8 @@ export async function createOidcIdp( namePath, name, autoProvision, - tags + tags, + variant } = parsedBody.data; if ( @@ -121,7 +123,8 @@ export async function createOidcIdp( scopes, identifierPath, emailPath, - namePath + namePath, + variant }); }); diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts index fe32a8b08..905b32013 100644 --- a/server/routers/idp/updateOidcIdp.ts +++ b/server/routers/idp/updateOidcIdp.ts @@ -31,7 +31,8 @@ const bodySchema = z.strictObject({ autoProvision: z.boolean().optional(), defaultRoleMapping: z.string().optional(), defaultOrgMapping: z.string().optional(), - tags: z.string().optional() + tags: z.string().optional(), + variant: z.enum(["oidc", "google", "azure"]).optional() }); export type UpdateIdpResponse = { @@ -96,7 +97,8 @@ export async function updateOidcIdp( autoProvision, defaultRoleMapping, defaultOrgMapping, - tags + tags, + variant } = parsedBody.data; if (process.env.IDENTITY_PROVIDER_MODE === "org") { @@ -159,7 +161,8 @@ export async function updateOidcIdp( scopes, identifierPath, emailPath, - namePath + namePath, + variant }; keysToUpdate = Object.keys(configData).filter( diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx index 9754b07e5..37cf400a5 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -448,16 +448,6 @@ export default function GeneralPage() { - - - - {t("redirectUrlAbout")} - - - {t("redirectUrlAboutDescription")} - - - {/* IDP Type Indicator */}
@@ -843,29 +833,6 @@ export default function GeneralPage() { className="space-y-4" id="general-settings-form" > - - - - {t("idpJmespathAbout")} - - - {t( - "idpJmespathAboutDescription" - )}{" "} - - {t( - "idpJmespathAboutDescriptionLink" - )}{" "} - - - - - ; - interface ProviderTypeOption { - id: "oidc" | "google" | "azure"; - title: string; - description: string; - icon?: React.ReactNode; - } - - const providerTypes: ReadonlyArray = [ - { - id: "oidc", - title: "OAuth2/OIDC", - description: t("idpOidcDescription") - }, - { - id: "google", - title: t("idpGoogleTitle"), - description: t("idpGoogleDescription"), - icon: ( - {t("idpGoogleAlt")} - ) - }, - { - id: "azure", - title: t("idpAzureTitle"), - description: t("idpAzureDescription"), - icon: ( - {t("idpAzureAlt")} - ) - } - ]; - const form = useForm({ resolver: zodResolver(createIdpFormSchema), defaultValues: { @@ -186,47 +142,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); @@ -304,6 +219,7 @@ export default function Page() { } const disabled = !isPaidUser(tierMatrix.orgOidc); + const templatesPaid = isPaidUser(tierMatrix.orgOidc); return ( <> @@ -336,23 +252,13 @@ export default function Page() { -
-
- - {t("idpType")} - -
- { - handleProviderChange( - value as "oidc" | "google" | "azure" - ); - }} - cols={3} - /> -
+ { + applyOidcIdpProviderType(form.setValue, next); + }} + />
@@ -708,16 +614,6 @@ export default function Page() { />
- - - - - {t("idpOidcConfigureAlert")} - - - {t("idpOidcConfigureAlertDescription")} - -
diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx index a5ed14a6e..d02925976 100644 --- a/src/app/admin/idp/[idpId]/general/page.tsx +++ b/src/app/admin/idp/[idpId]/general/page.tsx @@ -25,7 +25,6 @@ import { SettingsSectionDescription, SettingsSectionBody, SettingsSectionForm, - SettingsSectionFooter, SettingsSectionGrid } from "@app/components/Settings"; import { formatAxiosError } from "@app/lib/api"; @@ -33,8 +32,6 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useState, useEffect } from "react"; import { SwitchInput } from "@app/components/SwitchInput"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon, ExternalLink } from "lucide-react"; import { InfoSection, InfoSectionContent, @@ -42,8 +39,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() { @@ -53,12 +49,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 @@ -73,10 +69,46 @@ export default function GeneralPage() { autoProvision: z.boolean().default(false) }); - type GeneralFormValues = z.infer; + 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; + type GoogleFormValues = z.infer; + type AzureFormValues = z.infer; + type GeneralFormValues = + | OidcFormValues + | GoogleFormValues + | AzureFormValues; + + const getFormSchema = () => { + switch (variant) { + case "google": + return GoogleFormSchema; + case "azure": + return AzureFormSchema; + default: + return OidcFormSchema; + } + }; + + const form = useForm({ + resolver: zodResolver(getFormSchema()) as never, defaultValues: { name: "", clientId: "", @@ -87,28 +119,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 = { 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({ @@ -123,25 +187,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)[ + key + ]?.[0] || t("invalidValue"); + form.setError(fieldName, { + type: "manual", + message: errorMessage + }); + }); + setLoading(false); + return; + } + + let payload: Record = { 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) { @@ -190,6 +305,13 @@ export default function GeneralPage() { +
+ + {t("idpTypeLabel")}: + + +
+
)} /> - -
- { - form.setValue( - "autoProvision", - checked - ); - }} - /> -
-
- - {t( - "idpAutoProvisionUsersDescription" - )} - - {form.watch("autoProvision") && ( - - {t.rich( - "idpAdminAutoProvisionPoliciesTabHint", - { - policiesTabLink: ( - chunks - ) => ( - - {chunks} - - ) - } - )} - - )} -
- + + + + {t("idpAutoProvisionUsers")} + + + {t("idpAutoProvisionUsersDescription")} + + + +
+ +
+ { + form.setValue( + "autoProvision", + checked + ); + }} + /> +
+
+ + {t("idpAutoProvisionUsersDescription")} + + {form.watch("autoProvision") && ( + + {t.rich( + "idpAdminAutoProvisionPoliciesTabHint", + { + policiesTabLink: ( + chunks + ) => ( + + {chunks} + + ) + } + )} + + )} +
+
+ +
+
+ + {variant === "google" && ( - {t("idpOidcConfigure")} + {t("idpGoogleConfiguration")} - {t("idpOidcConfigureDescription")} + {t("idpGoogleConfigurationDescription")} @@ -294,7 +434,7 @@ export default function GeneralPage() { {t( - "idpClientIdDescription" + "idpGoogleClientIdDescription" )} @@ -318,49 +458,7 @@ export default function GeneralPage() { {t( - "idpClientSecretDescription" - )} - - - - )} - /> - - ( - - - {t("idpAuthUrl")} - - - - - - {t( - "idpAuthUrlDescription" - )} - - - - )} - /> - - ( - - - {t("idpTokenUrl")} - - - - - - {t( - "idpTokenUrlDescription" + "idpGoogleClientSecretDescription" )} @@ -372,14 +470,16 @@ export default function GeneralPage() { + )} + {variant === "azure" && ( - {t("idpToken")} + {t("idpAzureConfiguration")} - {t("idpTokenDescription")} + {t("idpAzureConfigurationDescription")} @@ -392,18 +492,18 @@ export default function GeneralPage() { > ( - {t("idpJmespathLabel")} + {t("idpTenantId")} {t( - "idpJmespathLabelDescription" + "idpAzureTenantIdDescription" )} @@ -413,20 +513,18 @@ export default function GeneralPage() { ( - {t( - "idpJmespathEmailPathOptional" - )} + {t("idpClientId")} {t( - "idpJmespathEmailPathOptionalDescription" + "idpAzureClientIdDescription" )} @@ -436,43 +534,21 @@ export default function GeneralPage() { ( - {t( - "idpJmespathNamePathOptional" - )} + {t("idpClientSecret")} - + {t( - "idpJmespathNamePathOptionalDescription" - )} - - - - )} - /> - - ( - - - {t( - "idpOidcConfigureScopes" - )} - - - - - - {t( - "idpOidcConfigureScopesDescription" + "idpAzureClientSecretDescription" )} @@ -484,15 +560,263 @@ export default function GeneralPage() { -
+ )} + + {variant === "oidc" && ( + + + + + {t("idpOidcConfigure")} + + + {t("idpOidcConfigureDescription")} + + + + +
+ + ( + + + {t("idpClientId")} + + + + + + {t( + "idpClientIdDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpClientSecretDescription" + )} + + + + )} + /> + + ( + + + {t("idpAuthUrl")} + + + + + + {t( + "idpAuthUrlDescription" + )} + + + + )} + /> + + ( + + + {t("idpTokenUrl")} + + + + + + {t( + "idpTokenUrlDescription" + )} + + + + )} + /> + + +
+
+
+ + + + + {t("idpToken")} + + + {t("idpTokenDescription")} + + + + +
+ + ( + + + {t( + "idpJmespathLabel" + )} + + + + + + {t( + "idpJmespathLabelDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathEmailPathOptional" + )} + + + + + + {t( + "idpJmespathEmailPathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathNamePathOptional" + )} + + + + + + {t( + "idpJmespathNamePathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpOidcConfigureScopes" + )} + + + + + + {t( + "idpOidcConfigureScopesDescription" + )} + + + + )} + /> + + +
+
+
+
+ )}
diff --git a/src/app/admin/idp/[idpId]/policies/page.tsx b/src/app/admin/idp/[idpId]/policies/page.tsx index 086074e2d..57ee3cf7b 100644 --- a/src/app/admin/idp/[idpId]/policies/page.tsx +++ b/src/app/admin/idp/[idpId]/policies/page.tsx @@ -575,7 +575,7 @@ export default function PoliciesPage() { } }} > - + {editingPolicy diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx index 91b55da23..40d4a3b32 100644 --- a/src/app/admin/idp/create/page.tsx +++ b/src/app/admin/idp/create/page.tsx @@ -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,63 @@ 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 { 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 { useEffect, 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; - interface ProviderTypeOption { - id: "oidc"; - title: string; - description: string; - } - - const providerTypes: ReadonlyArray = [ - { - 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 +87,46 @@ export default function Page() { namePath: "name", emailPath: "email", scopes: "openid profile email", + tenantId: "", autoProvision: false } }); + const watchedType = form.watch("type"); + + useEffect(() => { + if ( + !templatesPaid && + (watchedType === "google" || watchedType === "azure") + ) { + applyOidcIdpProviderType(form.setValue, "oidc"); + } + }, [templatesPaid, watchedType, form.setValue]); + async function onSubmit(data: CreateIdpFormValues) { 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); @@ -150,6 +166,10 @@ export default function Page() {
+ {!templatesPaid ? ( + + ) : null} + @@ -161,6 +181,14 @@ export default function Page() { + { + applyOidcIdpProviderType(form.setValue, next); + }} + /> +
- - {/*
*/} - {/*
*/} - {/* */} - {/* {t("idpType")} */} - {/* */} - {/*
*/} - {/* */} - {/* { */} - {/* form.setValue("type", value as "oidc"); */} - {/* }} */} - {/* cols={3} */} - {/* /> */} - {/*
*/}
- {form.watch("type") === "oidc" && ( + {watchedType === "google" && ( + + + + {t("idpGoogleConfigurationTitle")} + + + {t("idpGoogleConfigurationDescription")} + + + + +
+ + ( + + + {t("idpClientId")} + + + + + + {t( + "idpGoogleClientIdDescription" + )} + + + + )} + /> + + ( + + + {t("idpClientSecret")} + + + + + + {t( + "idpGoogleClientSecretDescription" + )} + + + + )} + /> + + +
+
+
+ )} + + {watchedType === "azure" && ( + + + + {t("idpAzureConfigurationTitle")} + + + {t("idpAzureConfigurationDescription")} + + + + +
+ + ( + + + {t("idpTenantIdLabel")} + + + + + + {t( + "idpAzureTenantIdDescription" + )} + + + + )} + /> + + ( + + + {t("idpClientId")} + + + + + + {t( + "idpAzureClientIdDescription2" + )} + + + + )} + /> + + ( + + + {t("idpClientSecret")} + + + + + + {t( + "idpAzureClientSecretDescription2" + )} + + + + )} + /> + + +
+
+
+ )} + + {watchedType === "oidc" && ( diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 44d85b99e..5f35ee4cd 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -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 ( - - {props.children} - + + + {props.children} + + ); } diff --git a/src/components/RoleMappingConfigFields.tsx b/src/components/RoleMappingConfigFields.tsx index 4fe1a037b..deb2cc9ac 100644 --- a/src/components/RoleMappingConfigFields.tsx +++ b/src/components/RoleMappingConfigFields.tsx @@ -166,7 +166,7 @@ export default function RoleMappingConfigFields({ )} {roleMappingMode === "mappingBuilder" && ( -
+
{t("roleMappingClaimPath")} void; + templatesPaid: boolean; +}; + +export function OidcIdpProviderTypeSelect({ + value, + onTypeChange, + templatesPaid +}: Props) { + const t = useTranslations(); + + const options: ReadonlyArray> = [ + { + id: "oidc", + title: "OAuth2/OIDC", + description: t("idpOidcDescription") + }, + { + id: "google", + title: t("idpGoogleTitle"), + description: t("idpGoogleDescription"), + disabled: !templatesPaid, + icon: ( + {t("idpGoogleAlt")} + ) + }, + { + id: "azure", + title: t("idpAzureTitle"), + description: t("idpAzureDescription"), + disabled: !templatesPaid, + icon: ( + {t("idpAzureAlt")} + ) + } + ]; + + return ( +
+
+ {t("idpType")} +
+ +
+ ); +} diff --git a/src/lib/idp/oidcIdpProviderDefaults.ts b/src/lib/idp/oidcIdpProviderDefaults.ts new file mode 100644 index 000000000..3608c6882 --- /dev/null +++ b/src/lib/idp/oidcIdpProviderDefaults.ts @@ -0,0 +1,46 @@ +import type { FieldValues, UseFormSetValue } from "react-hook-form"; + +export type IdpOidcProviderType = "oidc" | "google" | "azure"; + +export function applyOidcIdpProviderType( + setValue: UseFormSetValue, + provider: IdpOidcProviderType +): void { + setValue("type" as never, provider as never); + + if (provider === "google") { + setValue( + "authUrl" as never, + "https://accounts.google.com/o/oauth2/v2/auth" as never + ); + setValue( + "tokenUrl" as never, + "https://oauth2.googleapis.com/token" as never + ); + setValue("identifierPath" as never, "email" as never); + setValue("emailPath" as never, "email" as never); + setValue("namePath" as never, "name" as never); + setValue("scopes" as never, "openid profile email" as never); + } else if (provider === "azure") { + setValue( + "authUrl" as never, + "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/authorize" as never + ); + setValue( + "tokenUrl" as never, + "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/token" as never + ); + setValue("identifierPath" as never, "email" as never); + setValue("emailPath" as never, "email" as never); + setValue("namePath" as never, "name" as never); + setValue("scopes" as never, "openid profile email" as never); + setValue("tenantId" as never, "" as never); + } else { + setValue("authUrl" as never, "" as never); + setValue("tokenUrl" as never, "" as never); + setValue("identifierPath" as never, "sub" as never); + setValue("namePath" as never, "name" as never); + setValue("emailPath" as never, "email" as never); + setValue("scopes" as never, "openid profile email" as never); + } +}