edit oidc idp general tab

This commit is contained in:
miloschwartz 2025-04-17 22:30:02 -04:00
parent 3e94384cde
commit 8c0e4d2d8c
No known key found for this signature in database
10 changed files with 779 additions and 22 deletions

View file

@ -501,6 +501,12 @@ authenticated.put(
idp.createOidcIdp
);
authenticated.post(
"/idp/:idpId/oidc",
verifyUserIsServerAdmin,
idp.updateOidcIdp
);
authenticated.delete(
"/idp/:idpId",
verifyUserIsServerAdmin,

View file

@ -24,7 +24,7 @@ const bodySchema = z
identifierPath: z.string().nonempty(),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.array(z.string().nonempty()),
scopes: z.string().nonempty(),
autoProvision: z.boolean().optional()
})
.strict();
@ -104,7 +104,7 @@ export async function createOidcIdp(
authUrl,
tokenUrl,
autoProvision,
scopes: JSON.stringify(scopes),
scopes,
identifierPath,
emailPath,
namePath

View file

@ -76,7 +76,14 @@ export async function generateOidcUrl(
);
}
const parsedScopes = JSON.parse(existingIdp.idpOidcConfig.scopes);
const parsedScopes = existingIdp.idpOidcConfig.scopes
.split(" ")
.map((scope) => {
return scope.trim();
})
.filter((scope) => {
return scope.length > 0;
});
const key = config.getRawConfig().server.secret;

View file

@ -1,4 +1,5 @@
export * from "./createOidcIdp";
export * from "./updateOidcIdp";
export * from "./deleteIdp";
export * from "./listIdps";
export * from "./generateOidcUrl";

View file

@ -0,0 +1,162 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { idp, idpOidcConfig } from "@server/db/schemas";
import { eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const paramsSchema = z
.object({
idpId: z.coerce.number()
})
.strict();
const bodySchema = z
.object({
name: z.string().nonempty(),
clientId: z.string().nonempty(),
clientSecret: z.string().nonempty(),
authUrl: z.string().url(),
tokenUrl: z.string().url(),
identifierPath: z.string().nonempty(),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().optional(),
autoProvision: z.boolean().optional()
})
.strict();
export type UpdateIdpResponse = {
idpId: number;
};
registry.registerPath({
method: "post",
path: "/idp/:idpId/oidc",
description: "Update an OIDC IdP.",
tags: [OpenAPITags.Idp],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function updateOidcIdp(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { idpId } = parsedParams.data;
const {
clientId,
clientSecret,
authUrl,
tokenUrl,
scopes,
identifierPath,
emailPath,
namePath,
name,
autoProvision
} = parsedBody.data;
// Check if IDP exists and is of type OIDC
const [existingIdp] = await db
.select()
.from(idp)
.where(eq(idp.idpId, idpId));
if (!existingIdp) {
return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found"));
}
if (existingIdp.type !== "oidc") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IdP is not an OIDC provider"
)
);
}
const key = config.getRawConfig().server.secret;
const encryptedSecret = encrypt(clientSecret, key);
const encryptedClientId = encrypt(clientId, key);
await db.transaction(async (trx) => {
// Update IDP name
await trx
.update(idp)
.set({
name
})
.where(eq(idp.idpId, idpId));
// Update OIDC config
await trx
.update(idpOidcConfig)
.set({
clientId: encryptedClientId,
clientSecret: encryptedSecret,
authUrl,
tokenUrl,
autoProvision,
scopes,
identifierPath,
emailPath,
namePath
})
.where(eq(idpOidcConfig.idpId, idpId));
});
return response<UpdateIdpResponse>(res, {
data: {
idpId
},
success: true,
error: false,
message: "IdP updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -3,7 +3,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { IdpDataTable } from "./AdminIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
@ -11,6 +11,14 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "@app/components/ui/badge";
import { useRouter } from "next/navigation";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import Link from "next/link";
export type IdpRow = {
idpId: number;
@ -27,6 +35,7 @@ export default function IdpTable({ idps }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedIdp, setSelectedIdp] = useState<IdpRow | null>(null);
const api = createApiClient(useEnvContext());
const router = useRouter();
const deleteIdp = async (idpId: number) => {
try {
@ -35,8 +44,7 @@ export default function IdpTable({ idps }: Props) {
title: "Success",
description: "Identity provider deleted successfully"
});
// Refresh the page to update the list
window.location.reload();
router.refresh();
} catch (e) {
toast({
title: "Error",
@ -56,6 +64,41 @@ export default function IdpTable({ idps }: Props) {
};
const columns: ColumnDef<IdpRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const r = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/admin/idp/${r.idpId}/general`}
>
<DropdownMenuItem>
View settings
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedIdp(r);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "idpId",
header: ({ column }) => {
@ -106,9 +149,7 @@ export default function IdpTable({ idps }: Props) {
cell: ({ row }) => {
const type = row.original.type;
return (
<Badge variant="secondary">
{getTypeDisplay(type)}
</Badge>
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
);
}
},
@ -131,19 +172,15 @@ export default function IdpTable({ idps }: Props) {
{
id: "actions",
cell: ({ row }) => {
const idp = row.original;
const siteRow = row.original;
return (
<div className="flex items-center justify-end">
<Button
variant="outline"
className="ml-2"
onClick={() => {
setSelectedIdp(idp);
setIsDeleteModalOpen(true);
}}
>
Delete
<Link href={`/admin/idp/${siteRow.idpId}/general`}>
<Button variant={"outlinePrimary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}

View file

@ -0,0 +1,479 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
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 {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
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 { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
const GeneralFormSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
clientId: z.string().min(1, { message: "Client ID is required." }),
clientSecret: z.string().min(1, { message: "Client Secret is required." }),
authUrl: z.string().url({ message: "Auth URL must be a valid URL." }),
tokenUrl: z.string().url({ message: "Token URL must be a valid URL." }),
identifierPath: z
.string()
.min(1, { message: "Identifier Path is required." }),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().min(1, { message: "Scopes are required." }),
autoProvision: z.boolean().default(false)
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralPage() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const { idpId } = useParams();
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: "",
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
identifierPath: "sub",
emailPath: "email",
namePath: "name",
scopes: "openid profile email",
autoProvision: true
}
});
useEffect(() => {
const loadIdp = async () => {
try {
const res = await api.get(`/idp/${idpId}`);
if (res.status === 200) {
const data = res.data.data;
form.reset({
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.idpOidcConfig.autoProvision
});
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
router.push("/admin/idp");
} finally {
setInitialLoading(false);
}
};
loadIdp();
}, [idpId, api, form, router]);
async function onSubmit(data: GeneralFormValues) {
setLoading(true);
try {
const payload = {
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
};
const res = await api.post(`/idp/${idpId}/oidc`, payload);
if (res.status === 200) {
toast({
title: "Success",
description: "Identity provider updated successfully"
});
router.refresh();
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setLoading(false);
}
}
if (initialLoading) {
return null;
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Information
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the basic information for your identity
provider
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>Redirect URL</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={redirectUrl} />
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
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.
</AlertDescription>
</Alert>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
A display name for this identity
provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<SwitchInput
id="auto-provision-toggle"
label="Auto Provision Users"
defaultChecked={form.getValues(
"autoProvision"
)}
onCheckedChange={(checked) => {
form.setValue("autoProvision", checked);
}}
/>
<span className="text-sm text-muted-foreground">
When enabled, users will be automatically
created in the system upon first login using
this identity provider.
</span>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
OAuth2/OIDC Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the OAuth2/OIDC provider endpoints and
credentials
</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>Client ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 client ID from
your identity provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
Client Secret
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
The OAuth2 client secret
from your identity provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
Authorization URL
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 authorization
endpoint URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Token URL</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 token endpoint
URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Token Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how to extract user information from the
ID token
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About JMESPath
</AlertTitle>
<AlertDescription>
The paths below use JMESPath syntax
to extract values from the ID token.
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
Learn more about JMESPath{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="identifierPath"
render={({ field }) => (
<FormItem>
<FormLabel>
Identifier Path
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the user
identifier in the ID token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPath"
render={({ field }) => (
<FormItem>
<FormLabel>
Email Path (Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the user's
email in the ID token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namePath"
render={({ field }) => (
<FormItem>
<FormLabel>
Name Path (Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the user's
name in the ID token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>Scopes</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Space-separated list of
OAuth2 scopes to request
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="general-settings-form"
loading={loading}
disabled={loading}
>
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsSectionGrid>
</SettingsContainer>
);
}

View file

@ -0,0 +1,57 @@
import { internal } from "@app/lib/api";
import { GetIdpResponse } from "@server/routers/idp";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
interface SettingsLayoutProps {
children: React.ReactNode;
params: Promise<{ idpId: string }>;
}
export default async function SettingsLayout(props: SettingsLayoutProps) {
const params = await props.params;
const { children } = props;
let idp = null;
try {
const res = await internal.get<AxiosResponse<GetIdpResponse>>(
`/idp/${params.idpId}`,
await authCookieHeader()
);
idp = res.data.data;
} catch {
redirect("/admin/idp");
}
const navItems = [
{
title: "General",
href: `/admin/idp/${params.idpId}/general`
}
];
return (
<>
<SettingsSectionTitle
title={`${idp.idp.name} Settings`}
description="Configure the settings for your identity provider"
/>
<div className="space-y-6">
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>
</>
);
}

View file

@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
export default async function IdpPage(props: {
params: Promise<{ idpId: string }>;
}) {
const params = await props.params;
redirect(`/admin/idp/${params.idpId}/general`);
}

View file

@ -105,7 +105,7 @@ export default function Page() {
emailPath: data.emailPath,
namePath: data.namePath,
autoProvision: data.autoProvision,
scopes: data.scopes.split(" ").filter(Boolean)
scopes: data.scopes
};
const res = await api.put("/idp/oidc", payload);
@ -115,7 +115,7 @@ export default function Page() {
title: "Success",
description: "Identity provider created successfully"
});
router.push("/admin/idp");
router.push(`/admin/idp/${res.data.data.idpId}`);
}
} catch (e) {
toast({