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 { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox"; 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 { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Checkbox } from "@app/components/ui/checkbox"; import { Checkbox } from "@app/components/ui/checkbox";
import { ListIdpsResponse } from "@server/routers/idp";
type UserType = "internal" | "external"; type UserType = "internal" | "oidc";
interface UserTypeOption { interface UserTypeOption {
id: UserType; id: UserType;
@@ -53,12 +53,39 @@ interface UserTypeOption {
description: string; 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" }), email: z.string().email({ message: "Invalid email address" }),
validForHours: z.string().min(1, { message: "Please select a duration" }), validForHours: z.string().min(1, { message: "Please select a duration" }),
roleId: z.string().min(1, { message: "Please select a role" }) 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() { export default function Page() {
const { orgId } = useParams(); const { orgId } = useParams();
const router = useRouter(); const router = useRouter();
@@ -70,7 +97,10 @@ export default function Page() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1); const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [idps, setIdps] = useState<IdpOption[]>([]);
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(null);
const [dataLoaded, setDataLoaded] = useState(false);
const validFor = [ const validFor = [
{ hours: 24, name: "1 day" }, { hours: 24, name: "1 day" },
@@ -82,8 +112,8 @@ export default function Page() {
{ hours: 168, name: "7 days" } { hours: 168, name: "7 days" }
]; ];
const form = useForm<z.infer<typeof formSchema>>({ const internalForm = useForm<z.infer<typeof internalFormSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(internalFormSchema),
defaultValues: { defaultValues: {
email: "", email: "",
validForHours: "72", 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(() => { useEffect(() => {
if (userType === "internal") { if (userType === "internal") {
setSendEmail(env.email.emailEnabled); setSendEmail(env.email.emailEnabled);
form.reset(); internalForm.reset();
setInviteLink(null); setInviteLink(null);
setExpiresInDays(1); setExpiresInDays(1);
} else if (userType === "oidc") {
externalForm.reset();
} }
}, [userType, env.email.emailEnabled, form]); }, [userType, env.email.emailEnabled, internalForm, externalForm]);
useEffect(() => { useEffect(() => {
if (userType !== "internal") { if (!userType) {
return; return;
} }
@@ -122,13 +165,43 @@ export default function Page() {
if (res?.status === 200) { if (res?.status === 200) {
setRoles(res.data.data.roles); 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(); fetchRoles();
if (userType !== "internal") {
fetchIdps();
}
}, [userType]); }, [userType]);
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmitInternal(
values: z.infer<typeof internalFormSchema>
) {
setLoading(true); setLoading(true);
const res = await api const res = await api
@@ -175,6 +248,43 @@ export default function Page() {
setLoading(false); 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> = [ const userTypes: ReadonlyArray<UserTypeOption> = [
{ {
id: "internal", id: "internal",
@@ -182,10 +292,9 @@ export default function Page() {
description: "Invite a user to join your organization directly." description: "Invite a user to join your organization directly."
}, },
{ {
id: "external", id: "oidc",
title: "External User", title: "External User",
description: description: "Create a user with an external identity provider."
"Provision a user with an external identity provider (IdP)."
} }
]; ];
@@ -223,13 +332,20 @@ export default function Page() {
defaultValue={userType || undefined} defaultValue={userType || undefined}
onChange={(value) => { onChange={(value) => {
setUserType(value as UserType); setUserType(value as UserType);
if (value === "internal") {
internalForm.reset();
} else if (value === "oidc") {
externalForm.reset();
setSelectedIdp(null);
}
}} }}
cols={2} cols={2}
/> />
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
{userType === "internal" && ( {userType === "internal" && dataLoaded && (
<>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -241,16 +357,18 @@ export default function Page() {
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...internalForm}>
<form <form
onSubmit={form.handleSubmit( onSubmit={internalForm.handleSubmit(
onSubmit onSubmitInternal
)} )}
className="space-y-4" className="space-y-4"
id="invite-user-form" id="create-user-form"
> >
<FormField <FormField
control={form.control} control={
internalForm.control
}
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
@@ -258,7 +376,9 @@ export default function Page() {
Email Email
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -270,7 +390,9 @@ export default function Page() {
<Checkbox <Checkbox
id="send-email" id="send-email"
checked={sendEmail} checked={sendEmail}
onCheckedChange={(e) => onCheckedChange={(
e
) =>
setSendEmail( setSendEmail(
e as boolean e as boolean
) )
@@ -287,47 +409,9 @@ export default function Page() {
)} )}
<FormField <FormField
control={form.control} control={
name="roleId" internalForm.control
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>
)}
/>
<FormField
control={form.control}
name="validForHours" name="validForHours"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
@@ -338,7 +422,9 @@ export default function Page() {
onValueChange={ onValueChange={
field.onChange field.onChange
} }
defaultValue={field.value.toString()} defaultValue={
field.value
}
> >
<FormControl> <FormControl>
<SelectTrigger> <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 && ( {inviteLink && (
<div className="space-y-4"> <div className="max-w-md space-y-4">
{sendEmail && ( {sendEmail && (
<p> <p>
An email has been An email has
sent to the user been sent to the
with the access link user with the
access link
below. They must below. They must
access the link to access the link
accept the to accept the
invitation. invitation.
</p> </p>
)} )}
{!sendEmail && ( {!sendEmail && (
<p> <p>
The user has been The user has
invited. They must been invited.
access the link They must access
below to accept the the link below
to accept the
invitation. invitation.
</p> </p>
)} )}
<p> <p>
The invite will expire The invite will
in{" "} expire in{" "}
<b> <b>
{expiresInDays}{" "} {expiresInDays}{" "}
{expiresInDays === 1 {expiresInDays ===
1
? "day" ? "day"
: "days"} : "days"}
</b> </b>
@@ -413,6 +546,220 @@ export default function Page() {
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </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> </SettingsContainer>
@@ -426,12 +773,15 @@ export default function Page() {
> >
Cancel Cancel
</Button> </Button>
{userType === "internal" && ( {userType && dataLoaded && (
<Button <Button
type="submit" type="submit"
form="invite-user-form" form="create-user-form"
loading={loading} loading={loading}
disabled={inviteLink !== null || loading} disabled={
loading ||
(userType === "internal" && inviteLink !== null)
}
> >
Create User Create User
</Button> </Button>

View File

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

View File

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