From 97af632c61161007321224731aed1b3b7ccaa2b6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 23 Apr 2025 15:44:27 -0400 Subject: [PATCH] add option to pre provision idp user --- .../settings/access/users/create/page.tsx | 726 +++++++++++++----- src/app/admin/idp/AdminIdpTable.tsx | 1 + src/app/admin/idp/[idpId]/general/page.tsx | 583 +++++++------- 3 files changed, 840 insertions(+), 470 deletions(-) diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index c2e9374d..c270b350 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -38,14 +38,14 @@ import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import CopyTextBox from "@app/components/CopyTextBox"; -import { useOrgContext } from "@app/hooks/useOrgContext"; +import { useEnvContext } from "@app/hooks/useEnvContext"; import { ListRolesResponse } from "@server/routers/role"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; import { Checkbox } from "@app/components/ui/checkbox"; +import { ListIdpsResponse } from "@server/routers/idp"; -type UserType = "internal" | "external"; +type UserType = "internal" | "oidc"; interface UserTypeOption { id: UserType; @@ -53,12 +53,39 @@ interface UserTypeOption { description: string; } -const formSchema = z.object({ +interface IdpOption { + idpId: number; + name: string; + type: string; +} + +const internalFormSchema = z.object({ email: z.string().email({ message: "Invalid email address" }), validForHours: z.string().min(1, { message: "Please select a duration" }), roleId: z.string().min(1, { message: "Please select a role" }) }); +const externalFormSchema = z.object({ + username: z.string().min(1, { message: "Username is required" }), + email: z + .string() + .email({ message: "Invalid email address" }) + .optional() + .or(z.literal("")), + name: z.string().optional(), + roleId: z.string().min(1, { message: "Please select a role" }), + idpId: z.string().min(1, { message: "Please select an identity provider" }) +}); + +const formatIdpType = (type: string) => { + switch (type.toLowerCase()) { + case "oidc": + return "Generic OAuth2/OIDC provider."; + default: + return type; + } +}; + export default function Page() { const { orgId } = useParams(); const router = useRouter(); @@ -70,7 +97,10 @@ export default function Page() { const [loading, setLoading] = useState(false); const [expiresInDays, setExpiresInDays] = useState(1); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); + const [idps, setIdps] = useState([]); const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); + const [selectedIdp, setSelectedIdp] = useState(null); + const [dataLoaded, setDataLoaded] = useState(false); const validFor = [ { hours: 24, name: "1 day" }, @@ -82,8 +112,8 @@ export default function Page() { { hours: 168, name: "7 days" } ]; - const form = useForm>({ - resolver: zodResolver(formSchema), + const internalForm = useForm>({ + resolver: zodResolver(internalFormSchema), defaultValues: { email: "", validForHours: "72", @@ -91,17 +121,30 @@ export default function Page() { } }); + const externalForm = useForm>({ + resolver: zodResolver(externalFormSchema), + defaultValues: { + username: "", + email: "", + name: "", + roleId: "", + idpId: "" + } + }); + useEffect(() => { if (userType === "internal") { setSendEmail(env.email.emailEnabled); - form.reset(); + internalForm.reset(); setInviteLink(null); setExpiresInDays(1); + } else if (userType === "oidc") { + externalForm.reset(); } - }, [userType, env.email.emailEnabled, form]); + }, [userType, env.email.emailEnabled, internalForm, externalForm]); useEffect(() => { - if (userType !== "internal") { + if (!userType) { return; } @@ -122,13 +165,43 @@ export default function Page() { if (res?.status === 200) { setRoles(res.data.data.roles); + if (userType === "internal") { + setDataLoaded(true); + } } } + async function fetchIdps() { + const res = await api + .get>("/idp") + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: "Failed to fetch identity providers", + description: formatAxiosError( + e, + "An error occurred while fetching identity providers" + ) + }); + }); + + if (res?.status === 200) { + setIdps(res.data.data.idps); + setDataLoaded(true); + } + } + + setDataLoaded(false); fetchRoles(); + if (userType !== "internal") { + fetchIdps(); + } }, [userType]); - async function onSubmit(values: z.infer) { + async function onSubmitInternal( + values: z.infer + ) { setLoading(true); const res = await api @@ -175,6 +248,43 @@ export default function Page() { setLoading(false); } + async function onSubmitExternal( + values: z.infer + ) { + setLoading(true); + + const res = await api + .put(`/org/${orgId}/user`, { + username: values.username, + email: values.email, + name: values.name, + type: "oidc", + idpId: parseInt(values.idpId), + roleId: parseInt(values.roleId) + }) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to create user", + description: formatAxiosError( + e, + "An error occurred while creating the user" + ) + }); + }); + + if (res && res.status === 201) { + toast({ + variant: "default", + title: "User created", + description: "The user has been successfully created." + }); + router.push(`/${orgId}/settings/access/users`); + } + + setLoading(false); + } + const userTypes: ReadonlyArray = [ { id: "internal", @@ -182,10 +292,9 @@ export default function Page() { description: "Invite a user to join your organization directly." }, { - id: "external", + id: "oidc", title: "External User", - description: - "Provision a user with an external identity provider (IdP)." + description: "Create a user with an external identity provider." } ]; @@ -223,196 +332,434 @@ export default function Page() { defaultValue={userType || undefined} onChange={(value) => { setUserType(value as UserType); + if (value === "internal") { + internalForm.reset(); + } else if (value === "oidc") { + externalForm.reset(); + setSelectedIdp(null); + } }} cols={2} /> - {userType === "internal" && ( - - - - User Information - - - Enter the details for the new user - - - - -
- + {userType === "internal" && dataLoaded && ( + <> + + + + User Information + + + Enter the details for the new user + + + + + + + ( + + + Email + + + + + + + )} + /> + + {env.email.emailEnabled && ( +
+ + setSendEmail( + e as boolean + ) + } + /> + +
+ )} + + ( + + + Valid For + + + + + )} + /> + + ( + + + Role + + + + + )} + /> + + {inviteLink && ( +
+ {sendEmail && ( +

+ An email has + been sent to the + user with the + access link + below. They must + access the link + to accept the + invitation. +

+ )} + {!sendEmail && ( +

+ The user has + been invited. + They must access + the link below + to accept the + invitation. +

+ )} +

+ The invite will + expire in{" "} + + {expiresInDays}{" "} + {expiresInDays === + 1 + ? "day" + : "days"} + + . +

+ +
+ )} + + +
+
+
+ + )} + + {userType !== "internal" && dataLoaded && ( + <> + + + + Identity Provider + + + Select the identity provider for the + external user + + + + {idps.length === 0 ? ( +

+ No identity providers are + configured. Please configure an + identity provider before creating + external users. +

+ ) : ( +
( - - Email - - - - + ({ + id: idp.idpId.toString(), + title: idp.name, + description: + formatIdpType( + idp.type + ) + }) + )} + defaultValue={ + field.value + } + onChange={( + value + ) => { + field.onChange( + value + ); + const idp = + idps.find( + (idp) => + idp.idpId.toString() === + value + ); + setSelectedIdp( + idp || null + ); + }} + cols={3} + /> )} /> + + )} +
+
- {env.email.emailEnabled && ( -
- - setSendEmail( - e as boolean - ) + {idps.length > 0 && ( + + + + User Information + + + Enter the details for the new user + + + + +
+ + ( + + + Username + + + + +

+ This must + match the + unique + username + that exists + in the + selected + identity + provider. +

+ +
+ )} /> - -
- )} - ( - - - Role - - - - - )} - /> - - ( - - - Valid For - - - - - )} - /> - - {inviteLink && ( -
- {sendEmail && ( -

- An email has been - sent to the user - with the access link - below. They must - access the link to - accept the - invitation. -

- )} - {!sendEmail && ( -

- The user has been - invited. They must - access the link - below to accept the - invitation. -

- )} -

- The invite will expire - in{" "} - - {expiresInDays}{" "} - {expiresInDays === 1 - ? "day" - : "days"} - - . -

- ( + + + Email + (Optional) + + + + + + + )} /> -
- )} - - -
-
-
+ + ( + + + Name + (Optional) + + + + + + + )} + /> + + ( + + + Role + + + + + )} + /> + + + + + + )} + )} @@ -426,12 +773,15 @@ export default function Page() { > Cancel - {userType === "internal" && ( + {userType && dataLoaded && ( diff --git a/src/app/admin/idp/AdminIdpTable.tsx b/src/app/admin/idp/AdminIdpTable.tsx index ee3104bd..b2415280 100644 --- a/src/app/admin/idp/AdminIdpTable.tsx +++ b/src/app/admin/idp/AdminIdpTable.tsx @@ -44,6 +44,7 @@ export default function IdpTable({ idps }: Props) { title: "Success", description: "Identity provider deleted successfully" }); + setIsDeleteModalOpen(false); router.refresh(); } catch (e) { toast({ diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx index 4f4ad613..7e23db1b 100644 --- a/src/app/admin/idp/[idpId]/general/page.tsx +++ b/src/app/admin/idp/[idpId]/general/page.tsx @@ -162,104 +162,42 @@ export default function GeneralPage() { } return ( - - - - - General Information - - - Configure the basic information for your identity - provider - - - - - - Redirect URL - - - - - - - - - - About Redirect URL - - - This is the URL to which users will be redirected - after authentication. You need to configure this URL - in your identity provider settings. - - - -
- - ( - - Name - - - - - A display name for this identity - provider - - - - )} - /> - -
- { - form.setValue( - "autoProvision", - checked - ); - }} - /> - Enterprise -
- - When enabled, users will be automatically - created in the system upon first login with - the ability to map users to roles and - organizations. - - - -
-
-
- - + <> + - OAuth2/OIDC Configuration + General Information - Configure the OAuth2/OIDC provider endpoints and - credentials + Configure the basic information for your identity + provider + + + + Redirect URL + + + + + + + + + + + About Redirect URL + + + This is the URL to which users will be + redirected after authentication. You need to + configure this URL in your identity provider + settings. + +
( - Client ID + Name - The OAuth2 client ID from - your identity provider + A display name for this + identity provider )} /> - ( - - - Client Secret - - - - - - The OAuth2 client secret - from your identity provider - - - - )} - /> - - ( - - - Authorization URL - - - - - - The OAuth2 authorization - endpoint URL - - - - )} - /> - - ( - - Token URL - - - - - The OAuth2 token endpoint - URL - - - - )} - /> +
+ { + form.setValue( + "autoProvision", + checked + ); + }} + /> + + Enterprise + +
+ + When enabled, users will be + automatically created in the system upon + first login with the ability to map + users to roles and organizations. +
- - - - Token Configuration - - - Configure how to extract user information from the - ID token - - - - -
- - - - - About JMESPath - - - The paths below use JMESPath syntax - to extract values from the ID token. - - Learn more about JMESPath{" "} - - - - + + + + + OAuth2/OIDC Configuration + + + Configure the OAuth2/OIDC provider endpoints and + credentials + + + + + + + ( + + + Client ID + + + + + + The OAuth2 client ID + from your identity + provider + + + + )} + /> - ( - - - Identifier Path - - - - - - The JMESPath to the user - identifier in the ID token - - - - )} - /> + ( + + + Client Secret + + + + + + The OAuth2 client secret + from your identity + provider + + + + )} + /> - ( - - - Email Path (Optional) - - - - - - The JMESPath to the user's - email in the ID token - - - - )} - /> + ( + + + Authorization URL + + + + + + The OAuth2 authorization + endpoint URL + + + + )} + /> - ( - - - Name Path (Optional) - - - - - - The JMESPath to the user's - name in the ID token - - - - )} - /> + ( + + + Token URL + + + + + + The OAuth2 token + endpoint URL + + + + )} + /> + + + + + - ( - - Scopes - - - - - Space-separated list of - OAuth2 scopes to request - - - - )} - /> - - -
-
+ + + + Token Configuration + + + Configure how to extract user information from + the ID token + + + + +
+ + + + + About JMESPath + + + The paths below use JMESPath + syntax to extract values from + the ID token. + + Learn more about JMESPath{" "} + + + + - - - - - - + ( + + + Identifier Path + + + + + + The JMESPath to the user + identifier in the ID + token + + + + )} + /> + + ( + + + Email Path (Optional) + + + + + + The JMESPath to the + user's email in the ID + token + + + + )} + /> + + ( + + + Name Path (Optional) + + + + + + The JMESPath to the + user's name in the ID + token + + + + )} + /> + + ( + + + Scopes + + + + + + Space-separated list of + OAuth2 scopes to request + + + + )} + /> + + +
+
+
+
+
+ +
+ +
+ ); }