From 9ebe0b6645c9ff886f3f57a2adef0657bf5bd742 Mon Sep 17 00:00:00 2001 From: Matthew Pereira Date: Mon, 23 Sep 2024 14:34:32 -0300 Subject: [PATCH] WIP client management. Needs typescript work. --- app/dashboard/organization/clients/actions.ts | 135 ++++++++++++++++++ .../organization/clients/clients-list.tsx | 123 ++++++++++++++++ .../clients/create-client-form.tsx | 87 +++++++++++ .../organization/clients/invitations-list.tsx | 121 ++++++++++++++++ app/dashboard/organization/clients/page.tsx | 38 +++++ app/dashboard/organization/layout.tsx | 4 + package-lock.json | 2 +- 7 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 app/dashboard/organization/clients/actions.ts create mode 100644 app/dashboard/organization/clients/clients-list.tsx create mode 100644 app/dashboard/organization/clients/create-client-form.tsx create mode 100644 app/dashboard/organization/clients/invitations-list.tsx create mode 100644 app/dashboard/organization/clients/page.tsx diff --git a/app/dashboard/organization/clients/actions.ts b/app/dashboard/organization/clients/actions.ts new file mode 100644 index 0000000..50a84c6 --- /dev/null +++ b/app/dashboard/organization/clients/actions.ts @@ -0,0 +1,135 @@ +import { revalidatePath } from "next/cache" +import { Session } from "@auth0/nextjs-auth0" +import { ClientCreateAppTypeEnum } from "auth0" + +import { managementClient } from "@/lib/auth0" +import { withServerActionAuth } from "@/lib/with-server-action-auth" + +type CreateApiClientResult = + | { error: string } + | { clientId: string; clientSecret: string } + +export const createApiClient = withServerActionAuth( + async function createApiClient(formData: FormData, session: Session): Promise { + const name = formData.get("name") + const appType = formData.get("app_type") + + if (!name || typeof name !== "string") { + return { + error: "Client name is required.", + } + } + + if (!appType || typeof appType !== "string" || !isValidAppType(appType)) { + return { + error: "Application type is required and must be valid.", + } + } + + try { + const newClient = await managementClient.clients.create({ + name, + app_type: appType as ClientCreateAppTypeEnum, + is_first_party: true, + }) + + revalidatePath("/dashboard/organization/api-clients") + return { + clientId: newClient.client_id!, + clientSecret: newClient.client_secret! + } + } catch (error) { + console.error("Failed to create API client", error) + return { + error: "Failed to create API client.", + } + } + }, + { + role: "admin", + } +) + +function isValidAppType(appType: string): appType is ClientCreateAppTypeEnum { + return ['native', 'spa', 'regular_web', 'non_interactive'].includes(appType) +} + +export const deleteApiClient = withServerActionAuth( + async function deleteApiClient(clientId: string, session: Session) { + try { + await managementClient.clients.delete({ client_id: clientId }) + + revalidatePath("/dashboard/organization/api-clients") + } catch (error) { + console.error("Failed to delete API client", error) + return { + error: "Failed to delete API client.", + } + } + + return {} + }, + { + role: "admin", + } +) + +export const updateApiClient = withServerActionAuth( + async function updateApiClient(clientId: string, formData: FormData, session: Session) { + const name = formData.get("name") + const appType = formData.get("app_type") + + if (!name || typeof name !== "string") { + return { + error: "Client name is required.", + } + } + + if (!appType || typeof appType !== "string" || !["native", "spa", "regular_web", "non_interactive"].includes(appType)) { + return { + error: "Application type is required and must be valid.", + } + } + + try { + await managementClient.clients.update( + { client_id: clientId }, + { + name, + app_type: appType, + } + ) + + revalidatePath("/dashboard/organization/api-clients") + } catch (error) { + console.error("Failed to update API client", error) + return { + error: "Failed to update API client.", + } + } + + return {} + }, + { + role: "admin", + } +) + +export const rotateApiClientSecret = withServerActionAuth( + async function rotateApiClientSecret(clientId: string, session: Session) { + try { + const result = await managementClient.clients.rotateSecret({ client_id: clientId }) + + revalidatePath("/dashboard/organization/api-clients") + return { clientSecret: result.client_secret } + } catch (error) { + console.error("Failed to rotate API client secret", error) + return { + error: "Failed to rotate API client secret.", + } + } + }, + { + role: "admin", + } +) \ No newline at end of file diff --git a/app/dashboard/organization/clients/clients-list.tsx b/app/dashboard/organization/clients/clients-list.tsx new file mode 100644 index 0000000..140e8da --- /dev/null +++ b/app/dashboard/organization/clients/clients-list.tsx @@ -0,0 +1,123 @@ +"use client" + +import { DotsVerticalIcon, TrashIcon, ReloadIcon } from "@radix-ui/react-icons" +import { toast } from "sonner" + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +import { deleteApiClient, rotateApiClientSecret } from "./actions" + +interface Props { + clients: { + id: string + name: string + type: string + }[] +} + +export function ApiClientsList({ clients }: Props) { + return ( + + + API Clients + The current API clients for your organization. + + + + + + Client + Type + + + + + {clients.map((client) => ( + + +
+ + + {client.name.charAt(0).toUpperCase()} + + +
+

+ {client.name} +

+

+ {client.id} +

+
+
+
+ + {client.type} + + + + + + + + { + const result = await rotateApiClientSecret(client.id) + if (result.error) { + return toast.error(result.error) + } + toast.success(`Rotated secret for client: ${client.name}`) + // You might want to display the new secret here, or copy it to clipboard + }} + > + + Rotate Secret + + { + const result = await deleteApiClient(client.id) + if (result?.error) { + return toast.error(result.error) + } + toast.success(`Deleted client: ${client.name}`) + }} + > + + Delete + + + + +
+ ))} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/dashboard/organization/clients/create-client-form.tsx b/app/dashboard/organization/clients/create-client-form.tsx new file mode 100644 index 0000000..fe2fb0e --- /dev/null +++ b/app/dashboard/organization/clients/create-client-form.tsx @@ -0,0 +1,87 @@ +"use client" + +import { useRef } from "react" +import { toast } from "sonner" + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { SubmitButton } from "@/components/submit-button" + +import { createApiClient } from "./actions" + +export function CreateApiClientForm() { + const ref = useRef(null) + + return ( + +
{ + const result = await createApiClient(formData) + + if ('error' in result) { + toast.error(result.error) + } else { + toast.success(`API Client created: ${formData.get("name")}`) + toast.info(`Client ID: ${result.clientId}`) + toast.info(`Client Secret: ${result.clientSecret}`) + ref.current?.reset() + } + }} + > + + Create API Client + + Create a new API client for your application to access this organization's resources. + + + +
+
+ + +
+ +
+ + +
+
+
+ + Create Client + +
+
+ ) +} \ No newline at end of file diff --git a/app/dashboard/organization/clients/invitations-list.tsx b/app/dashboard/organization/clients/invitations-list.tsx new file mode 100644 index 0000000..ec110e2 --- /dev/null +++ b/app/dashboard/organization/clients/invitations-list.tsx @@ -0,0 +1,121 @@ +"use client" + +import { CopyIcon, DotsVerticalIcon, TrashIcon } from "@radix-ui/react-icons" +import { toast } from "sonner" + +import { Role } from "@/lib/roles" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +import { revokeInvitation } from "./actions" + +interface Props { + invitations: { + id: string + inviter: { + name: string + } + invitee: { + email: string + } + role: Role + url: string + }[] +} + +export function InvitationsList({ invitations }: Props) { + return ( + + + Pending Invitations + + Invitations that have been sent out but have not yet been redeemed. + + + + + + + Email + Role + Invited By + + + + + {invitations.map((invitation) => ( + + + {invitation.invitee.email} + + + {invitation.role} + + {invitation.inviter.name} + + + + + + + { + await navigator.clipboard.writeText(invitation.url) + toast.success("Invitation link copied to clipboard.") + }} + > + + Copy invitation link + + { + const { error } = await revokeInvitation( + invitation.id + ) + if (error) { + return toast.error(error) + } + + toast.success( + `Invitation has been revoked for: ${invitation.invitee.email}` + ) + }} + > + + Delete + + + + + + ))} + +
+
+
+ ) +} diff --git a/app/dashboard/organization/clients/page.tsx b/app/dashboard/organization/clients/page.tsx new file mode 100644 index 0000000..260c683 --- /dev/null +++ b/app/dashboard/organization/clients/page.tsx @@ -0,0 +1,38 @@ +import { appClient, managementClient } from "@/lib/auth0" +import { PageHeader } from "@/components/page-header" + +import { CreateApiClientForm } from "./create-client-form" +import { ApiClientsList } from "./clients-list" + +interface ApiClient { + client_id: string + name: string + app_type: string + client_secret?: string +} + +export default async function ApiClients() { + const session = await appClient.getSession() + const { data: apiClients } = await managementClient.clients.getAll({ + app_type: ['native', 'spa', 'regular_web', 'non_interactive'], + }) + + return ( +
+ + + ({ + id: client.client_id, + name: client.name, + type: client.app_type, + }))} + /> + + +
+ ) +} \ No newline at end of file diff --git a/app/dashboard/organization/layout.tsx b/app/dashboard/organization/layout.tsx index cbc5174..9002dfa 100644 --- a/app/dashboard/organization/layout.tsx +++ b/app/dashboard/organization/layout.tsx @@ -23,6 +23,10 @@ const sidebarNavItems = [ title: "Members", href: "/dashboard/organization/members", }, + { + title: "API Clients", + href: "/dashboard/organization/clients", + }, { title: "SSO", href: "/dashboard/organization/sso", diff --git a/package-lock.json b/package-lock.json index 211fc7c..3dd2583 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "date-fns": "^3.6.0", "execa": "^9.1.0", "lucide-react": "^0.379.0", - "next": "^14.2.5", + "next": "^14.2.3", "next-themes": "^0.3.0", "ora": "^8.0.1", "react": "^18.3.1",