add option to pre provision idp user

This commit is contained in:
miloschwartz
2025-04-23 15:44:27 -04:00
parent 960eb34c7d
commit 97af632c61
3 changed files with 840 additions and 470 deletions

View File

@@ -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<IdpOption[]>([]);
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
const internalForm = useForm<z.infer<typeof internalFormSchema>>({
resolver: zodResolver(internalFormSchema),
defaultValues: {
email: "",
validForHours: "72",
@@ -91,17 +121,30 @@ export default function Page() {
}
});
const externalForm = useForm<z.infer<typeof externalFormSchema>>({
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<AxiosResponse<ListIdpsResponse>>("/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<typeof formSchema>) {
async function onSubmitInternal(
values: z.infer<typeof internalFormSchema>
) {
setLoading(true);
const res = await api
@@ -175,6 +248,43 @@ export default function Page() {
setLoading(false);
}
async function onSubmitExternal(
values: z.infer<typeof externalFormSchema>
) {
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<UserTypeOption> = [
{
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,13 +332,20 @@ 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}
/>
</SettingsSectionBody>
</SettingsSection>
{userType === "internal" && (
{userType === "internal" && dataLoaded && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -241,16 +357,18 @@ export default function Page() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<Form {...internalForm}>
<form
onSubmit={form.handleSubmit(
onSubmit
onSubmit={internalForm.handleSubmit(
onSubmitInternal
)}
className="space-y-4"
id="invite-user-form"
id="create-user-form"
>
<FormField
control={form.control}
control={
internalForm.control
}
name="email"
render={({ field }) => (
<FormItem>
@@ -258,7 +376,9 @@ export default function Page() {
Email
</FormLabel>
<FormControl>
<Input {...field} />
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -270,7 +390,9 @@ export default function Page() {
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
onCheckedChange={(
e
) =>
setSendEmail(
e as boolean
)
@@ -287,47 +409,9 @@ export default function Page() {
)}
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
Role
</FormLabel>
<Select
onValueChange={
field.onChange
control={
internalForm.control
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(role) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="validForHours"
render={({ field }) => (
<FormItem>
@@ -338,7 +422,9 @@ export default function Page() {
onValueChange={
field.onChange
}
defaultValue={field.value.toString()}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger>
@@ -369,34 +455,81 @@ export default function Page() {
)}
/>
<FormField
control={
internalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
Role
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{inviteLink && (
<div className="space-y-4">
<div className="max-w-md space-y-4">
{sendEmail && (
<p>
An email has been
sent to the user
with the access link
An email has
been sent to the
user with the
access link
below. They must
access the link to
accept the
access the link
to accept the
invitation.
</p>
)}
{!sendEmail && (
<p>
The user has been
invited. They must
access the link
below to accept the
The user has
been invited.
They must access
the link below
to accept the
invitation.
</p>
)}
<p>
The invite will expire
in{" "}
The invite will
expire in{" "}
<b>
{expiresInDays}{" "}
{expiresInDays === 1
{expiresInDays ===
1
? "day"
: "days"}
</b>
@@ -413,6 +546,220 @@ export default function Page() {
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</>
)}
{userType !== "internal" && dataLoaded && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Identity Provider
</SettingsSectionTitle>
<SettingsSectionDescription>
Select the identity provider for the
external user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{idps.length === 0 ? (
<p className="text-muted-foreground">
No identity providers are
configured. Please configure an
identity provider before creating
external users.
</p>
) : (
<Form {...externalForm}>
<FormField
control={externalForm.control}
name="idpId"
render={({ field }) => (
<FormItem>
<StrategySelect
options={idps.map(
(idp) => ({
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}
/>
<FormMessage />
</FormItem>
)}
/>
</Form>
)}
</SettingsSectionBody>
</SettingsSection>
{idps.length > 0 && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
User Information
</SettingsSectionTitle>
<SettingsSectionDescription>
Enter the details for the new user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...externalForm}>
<form
onSubmit={externalForm.handleSubmit(
onSubmitExternal
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={
externalForm.control
}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
Username
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<p className="text-sm text-muted-foreground mt-1">
This must
match the
unique
username
that exists
in the
selected
identity
provider.
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email
(Optional)
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Name
(Optional)
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
Role
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
</>
)}
</SettingsContainer>
@@ -426,12 +773,15 @@ export default function Page() {
>
Cancel
</Button>
{userType === "internal" && (
{userType && dataLoaded && (
<Button
type="submit"
form="invite-user-form"
form="create-user-form"
loading={loading}
disabled={inviteLink !== null || loading}
disabled={
loading ||
(userType === "internal" && inviteLink !== null)
}
>
Create User
</Button>

View File

@@ -44,6 +44,7 @@ export default function IdpTable({ idps }: Props) {
title: "Success",
description: "Identity provider deleted successfully"
});
setIsDeleteModalOpen(false);
router.refresh();
} catch (e) {
toast({

View File

@@ -162,6 +162,7 @@ export default function GeneralPage() {
}
return (
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
@@ -176,7 +177,9 @@ export default function GeneralPage() {
<SettingsSectionBody>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>Redirect URL</InfoSectionTitle>
<InfoSectionTitle>
Redirect URL
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={redirectUrl} />
</InfoSectionContent>
@@ -189,9 +192,10 @@ export default function GeneralPage() {
About Redirect URL
</AlertTitle>
<AlertDescription>
This is the URL to which users will be redirected
after authentication. You need to configure this URL
in your identity provider settings.
This is the URL to which users will be
redirected after authentication. You need to
configure this URL in your identity provider
settings.
</AlertDescription>
</Alert>
<SettingsSectionForm>
@@ -211,8 +215,8 @@ export default function GeneralPage() {
<Input {...field} />
</FormControl>
<FormDescription>
A display name for this identity
provider
A display name for this
identity provider
</FormDescription>
<FormMessage />
</FormItem>
@@ -234,13 +238,15 @@ export default function GeneralPage() {
);
}}
/>
<Badge className="ml-2">Enterprise</Badge>
<Badge className="ml-2">
Enterprise
</Badge>
</div>
<span className="text-sm text-muted-foreground">
When enabled, users will be automatically
created in the system upon first login with
the ability to map users to roles and
organizations.
When enabled, users will be
automatically created in the system upon
first login with the ability to map
users to roles and organizations.
</span>
</form>
</Form>
@@ -272,13 +278,16 @@ export default function GeneralPage() {
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormLabel>
Client ID
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 client ID from
your identity provider
The OAuth2 client ID
from your identity
provider
</FormDescription>
<FormMessage />
</FormItem>
@@ -301,7 +310,8 @@ export default function GeneralPage() {
</FormControl>
<FormDescription>
The OAuth2 client secret
from your identity provider
from your identity
provider
</FormDescription>
<FormMessage />
</FormItem>
@@ -333,13 +343,15 @@ export default function GeneralPage() {
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Token URL</FormLabel>
<FormLabel>
Token URL
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 token endpoint
URL
The OAuth2 token
endpoint URL
</FormDescription>
<FormMessage />
</FormItem>
@@ -357,8 +369,8 @@ export default function GeneralPage() {
Token Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how to extract user information from the
ID token
Configure how to extract user information from
the ID token
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -375,8 +387,9 @@ export default function GeneralPage() {
About JMESPath
</AlertTitle>
<AlertDescription>
The paths below use JMESPath syntax
to extract values from the ID token.
The paths below use JMESPath
syntax to extract values from
the ID token.
<a
href="https://jmespath.org"
target="_blank"
@@ -402,7 +415,8 @@ export default function GeneralPage() {
</FormControl>
<FormDescription>
The JMESPath to the user
identifier in the ID token
identifier in the ID
token
</FormDescription>
<FormMessage />
</FormItem>
@@ -421,8 +435,9 @@ export default function GeneralPage() {
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the user's
email in the ID token
The JMESPath to the
user's email in the ID
token
</FormDescription>
<FormMessage />
</FormItem>
@@ -441,8 +456,9 @@ export default function GeneralPage() {
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the user's
name in the ID token
The JMESPath to the
user's name in the ID
token
</FormDescription>
<FormMessage />
</FormItem>
@@ -454,7 +470,9 @@ export default function GeneralPage() {
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>Scopes</FormLabel>
<FormLabel>
Scopes
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -470,8 +488,11 @@ export default function GeneralPage() {
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsSectionGrid>
</SettingsContainer>
<SettingsSectionFooter>
<div className="flex justify-end mt-8">
<Button
type="submit"
form="general-settings-form"
@@ -480,9 +501,7 @@ export default function GeneralPage() {
>
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsSectionGrid>
</SettingsContainer>
</div>
</>
);
}