mirror of
https://github.com/fosrl/pangolin.git
synced 2025-05-12 21:30:35 +01:00
edit oidc idp general tab
This commit is contained in:
parent
3e94384cde
commit
8c0e4d2d8c
10 changed files with 779 additions and 22 deletions
|
@ -501,6 +501,12 @@ authenticated.put(
|
|||
idp.createOidcIdp
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/idp/:idpId/oidc",
|
||||
verifyUserIsServerAdmin,
|
||||
idp.updateOidcIdp
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/idp/:idpId",
|
||||
verifyUserIsServerAdmin,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export * from "./createOidcIdp";
|
||||
export * from "./updateOidcIdp";
|
||||
export * from "./deleteIdp";
|
||||
export * from "./listIdps";
|
||||
export * from "./generateOidcUrl";
|
||||
|
|
162
server/routers/idp/updateOidcIdp.ts
Normal file
162
server/routers/idp/updateOidcIdp.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
</Button>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
479
src/app/admin/idp/[idpId]/general/page.tsx
Normal file
479
src/app/admin/idp/[idpId]/general/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
57
src/app/admin/idp/[idpId]/layout.tsx
Normal file
57
src/app/admin/idp/[idpId]/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
8
src/app/admin/idp/[idpId]/page.tsx
Normal file
8
src/app/admin/idp/[idpId]/page.tsx
Normal 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`);
|
||||
}
|
|
@ -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({
|
||||
|
|
Loading…
Reference in a new issue