diff --git a/server/lib/crypto.ts b/server/lib/crypto.ts index db248e8d..e1e9c2b1 100644 --- a/server/lib/crypto.ts +++ b/server/lib/crypto.ts @@ -4,7 +4,9 @@ const ALGORITHM = "aes-256-gcm"; export function encrypt(value: string, key: string): string { const iv = crypto.randomBytes(12); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const keyBuffer = Buffer.from(key, "base64"); // assuming base64 input + + const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv); const encrypted = Buffer.concat([ cipher.update(value, "utf8"), @@ -25,8 +27,9 @@ export function decrypt(encryptedValue: string, key: string): string { const iv = Buffer.from(ivB64, "base64"); const encrypted = Buffer.from(encryptedB64, "base64"); const authTag = Buffer.from(authTagB64, "base64"); + const keyBuffer = Buffer.from(key, "base64"); - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv); decipher.setAuthTag(authTag); const decrypted = Buffer.concat([ diff --git a/server/routers/idp/listIdps.ts b/server/routers/idp/listIdps.ts index 7b6f84e3..a723ee05 100644 --- a/server/routers/idp/listIdps.ts +++ b/server/routers/idp/listIdps.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { domains, idp, orgDomains, users } from "@server/db/schemas"; +import { domains, idp, orgDomains, users, idpOrg } from "@server/db/schemas"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -28,13 +28,33 @@ const querySchema = z .strict(); async function query(limit: number, offset: number) { - const res = await db.select().from(orgDomains).limit(limit).offset(offset); + const res = await db + .select({ + idpId: idp.idpId, + name: idp.name, + type: idp.type, + orgCount: sql`count(${idpOrg.orgId})` + }) + .from(idp) + .leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`) + .groupBy(idp.idpId) + .limit(limit) + .offset(offset); return res; } -export type ListIdpResponse = { - idps: NonNullable>>; - pagination: { total: number; limit: number; offset: number }; +export type ListIdpsResponse = { + idps: Array<{ + idpId: number; + name: string; + type: string; + orgCount: number; + }>; + pagination: { + total: number; + limit: number; + offset: number; + }; }; registry.registerPath({ @@ -71,7 +91,7 @@ export async function listIdps( .select({ count: sql`count(*)` }) .from(idp); - return response(res, { + return response(res, { data: { idps: list, pagination: { @@ -82,7 +102,7 @@ export async function listIdps( }, success: true, error: false, - message: "Users retrieved successfully", + message: "Idps retrieved successfully", status: HttpCode.OK }); } catch (error) { diff --git a/src/app/admin/idp/AdminIdpDataTable.tsx b/src/app/admin/idp/AdminIdpDataTable.tsx new file mode 100644 index 00000000..8d64ce0b --- /dev/null +++ b/src/app/admin/idp/AdminIdpDataTable.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useRouter } from "next/navigation"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function IdpDataTable({ + columns, + data +}: DataTableProps) { + const router = useRouter(); + + return ( + { + router.push("/admin/idp/create"); + }} + /> + ); +} diff --git a/src/app/admin/idp/AdminIdpTable.tsx b/src/app/admin/idp/AdminIdpTable.tsx new file mode 100644 index 00000000..59387d73 --- /dev/null +++ b/src/app/admin/idp/AdminIdpTable.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { IdpDataTable } from "./AdminIdpDataTable"; +import { Button } from "@app/components/ui/button"; +import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +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"; + +export type IdpRow = { + idpId: number; + name: string; + type: string; + orgCount: number; +}; + +type Props = { + idps: IdpRow[]; +}; + +export default function IdpTable({ idps }: Props) { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedIdp, setSelectedIdp] = useState(null); + const api = createApiClient(useEnvContext()); + + const deleteIdp = async (idpId: number) => { + try { + await api.delete(`/idp/${idpId}`); + toast({ + title: "Success", + description: "Identity provider deleted successfully" + }); + // Refresh the page to update the list + window.location.reload(); + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + + const getTypeDisplay = (type: string) => { + switch (type) { + case "oidc": + return "OAuth2/OIDC"; + default: + return type; + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "idpId", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const type = row.original.type; + return ( + + {getTypeDisplay(type)} + + ); + } + }, + { + accessorKey: "orgCount", + header: ({ column }) => { + return ( + + ); + } + }, + { + id: "actions", + cell: ({ row }) => { + const idp = row.original; + return ( +
+ +
+ ); + } + } + ]; + + return ( + <> + {selectedIdp && ( + { + setIsDeleteModalOpen(val); + setSelectedIdp(null); + }} + dialog={ +
+

+ Are you sure you want to permanently delete the + identity provider {selectedIdp.name}? +

+

+ + This will remove the identity provider and + all associated configurations. Users who + authenticate through this provider will no + longer be able to log in. + +

+

+ To confirm, please type the name of the identity + provider below. +

+
+ } + buttonText="Confirm Delete Identity Provider" + onConfirm={async () => deleteIdp(selectedIdp.idpId)} + string={selectedIdp.name} + title="Delete Identity Provider" + /> + )} + + + + ); +} diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx new file mode 100644 index 00000000..15b6da75 --- /dev/null +++ b/src/app/admin/idp/create/page.tsx @@ -0,0 +1,519 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { z } from "zod"; +import { createElement, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@app/components/ui/input"; +import { Button } from "@app/components/ui/button"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, ExternalLink } from "lucide-react"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { SwitchInput } from "@app/components/SwitchInput"; + +const createIdpFormSchema = z.object({ + name: z.string().min(2, { message: "Name must be at least 2 characters." }), + type: z.enum(["oidc"]), + 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 CreateIdpFormValues = z.infer; + +interface ProviderTypeOption { + id: "oidc"; + title: string; + description: string; +} + +const providerTypes: ReadonlyArray = [ + { + id: "oidc", + title: "OAuth2/OIDC", + description: "Configure an OpenID Connect identity provider" + } +]; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const [createLoading, setCreateLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(createIdpFormSchema), + defaultValues: { + name: "", + type: "oidc", + clientId: "", + clientSecret: "", + authUrl: "", + tokenUrl: "", + identifierPath: "sub", + namePath: "name", + emailPath: "email", + scopes: "openid profile email", + autoProvision: true + } + }); + + async function onSubmit(data: CreateIdpFormValues) { + setCreateLoading(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.split(" ").filter(Boolean) + }; + + const res = await api.put("/idp/oidc", payload); + + if (res.status === 201) { + toast({ + title: "Success", + description: "Identity provider created successfully" + }); + router.push("/admin/idp"); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setCreateLoading(false); + } + } + + return ( + <> +
+ + +
+ + + + + + General Information + + + Configure the basic information for your identity + provider + + + + +
+ + ( + + Name + + + + + A display name for this + identity provider + + + + )} + /> + + { + form.setValue( + "autoProvision", + checked + ); + }} + /> + + When enabled, users will be + automatically created in the system upon + first login using this identity + provider. + + + +
+
+
+ + + + + Provider Type + + + Select the type of identity provider you want to + configure + + + + { + form.setValue("type", value as "oidc"); + }} + cols={3} + /> + + + + {form.watch("type") === "oidc" && ( + <> +
+ + + + OAuth2/OIDC Configuration + + + Configure the OAuth2/OIDC provider + endpoints and credentials + + + +
+ + ( + + + Client ID + + + + + + The OAuth2 client ID + from your 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 + + + + )} + /> + + + + + + + Important Information + + + After creating the identity + provider, you will need to configure + the callback URL in your identity + provider's settings. The callback + URL will be provided after + successful creation. + + +
+
+ + + + + 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 + + + + )} + /> + + +
+
+
+ + )} +
+ +
+ + +
+ + ); +} diff --git a/src/app/admin/idp/page.tsx b/src/app/admin/idp/page.tsx new file mode 100644 index 00000000..54657c2d --- /dev/null +++ b/src/app/admin/idp/page.tsx @@ -0,0 +1,28 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import IdpTable, { IdpRow } from "./AdminIdpTable"; + +export default async function IdpPage() { + let idps: IdpRow[] = []; + try { + const res = await internal.get>( + `/idp`, + await authCookieHeader() + ); + idps = res.data.data.idps; + } catch (e) { + console.error(e); + } + + return ( + <> + + + + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index c77d665c..dcda0471 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -5,7 +5,8 @@ import { Users, Link as LinkIcon, Waypoints, - Combine + Combine, + Fingerprint } from "lucide-react"; export const rootNavItems: SidebarNavItem[] = [ @@ -66,5 +67,10 @@ export const adminNavItems: SidebarNavItem[] = [ title: "All Users", href: "/admin/users", icon: + }, + { + title: "Identity Providers", + href: "/admin/idp", + icon: } ]; diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index 9db2db2a..6d87f335 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -18,30 +18,30 @@ export function Breadcrumbs() { const href = `/${segments.slice(0, index + 1).join("/")}`; let label = segment; - // Format labels - if (segment === "settings") { - label = "Settings"; - } else if (segment === "sites") { - label = "Sites"; - } else if (segment === "resources") { - label = "Resources"; - } else if (segment === "access") { - label = "Access Control"; - } else if (segment === "general") { - label = "General"; - } else if (segment === "share-links") { - label = "Shareable Links"; - } else if (segment === "users") { - label = "Users"; - } else if (segment === "roles") { - label = "Roles"; - } else if (segment === "invitations") { - label = "Invitations"; - } else if (segment === "connectivity") { - label = "Connectivity"; - } else if (segment === "authentication") { - label = "Authentication"; - } + // // Format labels + // if (segment === "settings") { + // label = "Settings"; + // } else if (segment === "sites") { + // label = "Sites"; + // } else if (segment === "resources") { + // label = "Resources"; + // } else if (segment === "access") { + // label = "Access Control"; + // } else if (segment === "general") { + // label = "General"; + // } else if (segment === "share-links") { + // label = "Shareable Links"; + // } else if (segment === "users") { + // label = "Users"; + // } else if (segment === "roles") { + // label = "Roles"; + // } else if (segment === "invitations") { + // label = "Invitations"; + // } else if (segment === "connectivity") { + // label = "Connectivity"; + // } else if (segment === "authentication") { + // label = "Authentication"; + // } return { label, href }; }); diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index effdaf85..99c52d18 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -106,8 +106,7 @@ export function SidebarNav({