Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP client management. Needs typescript work. #57

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions app/dashboard/organization/clients/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { revalidatePath } from "next/cache"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import { revalidatePath } from "next/cache"
"use server"
import { revalidatePath } from "next/cache"

import { Session } from "@auth0/nextjs-auth0"
import { ClientCreateAppTypeEnum } from "auth0"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import { ClientCreateAppTypeEnum } from "auth0"


import { managementClient } from "@/lib/auth0"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import { managementClient } from "@/lib/auth0"
import { managementClient } from "@/lib/auth0"
import { Client } from "@/lib/clients"

import { withServerActionAuth } from "@/lib/with-server-action-auth"

type CreateApiClientResult =
| { error: string }
| { clientId: string; clientSecret: string }
Comment on lines +8 to +10

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
type CreateApiClientResult =
| { error: string }
| { clientId: string; clientSecret: string }
interface CreateApiClientError {
error: string
}
export interface CreateApiClientSuccess {
clientId: string
clientSecret: string
}


export const createApiClient = withServerActionAuth(
async function createApiClient(formData: FormData, session: Session): Promise<CreateApiClientResult> {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable. Change to _ for TypeScript so that it doesn't get annoyed. Should probably refactor to be able to remove it altogether if it is not being used. This is a quick fix.

Suggested change
async function createApiClient(formData: FormData, session: Session): Promise<CreateApiClientResult> {
async function createApiClient(formData: FormData, _: Session): Promise<CreateApiClientResult> {

const name = formData.get("name")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const name = formData.get("name")
const name = formData.get("name") as Client["name"]

const appType = formData.get("app_type")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const appType = formData.get("app_type")
const app_type = formData.get("app_type") as Client["app_type"]


if (!name || typeof name !== "string") {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!name || typeof name !== "string") {
if (!name) {

return {
error: "Client name is required.",
}
}

if (!appType || typeof appType !== "string" || !isValidAppType(appType)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would recommend letting the server handle the actual enum check. This makes the client more flexible in case the server ever changes or adds types. Then the client simply has to have proper error handling.

Suggested change
if (!appType || typeof appType !== "string" || !isValidAppType(appType)) {
if (!appType) {

return {
error: "Application type is required and must be valid.",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
error: "Application type is required and must be valid.",
error: "Application type is required.",

}
}

try {
const newClient = await managementClient.clients.create({

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

managementClient responses have a data object containing the actual response.

Suggested change
const newClient = await managementClient.clients.create({
const { data: newClient } = await managementClient.clients.create({

name,
app_type: appType as ClientCreateAppTypeEnum,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
app_type: appType as ClientCreateAppTypeEnum,
app_type,

is_first_party: true,
})

revalidatePath("/dashboard/organization/api-clients")
return {
clientId: newClient.client_id!,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unnecessary once you get the response object corrected.

Suggested change
clientId: newClient.client_id!,
clientId: newClient.client_id,

clientSecret: newClient.client_secret!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
clientSecret: newClient.client_secret!
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)
}

Comment on lines +53 to +56

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary function if you let the server handle the enum check.

Suggested change
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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async function deleteApiClient(clientId: string, session: Session) {
async function deleteApiClient(client_id: string, _: Session) {

try {
await managementClient.clients.delete({ client_id: clientId })

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
await managementClient.clients.delete({ client_id: clientId })
await managementClient.clients.delete({ client_id })


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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async function updateApiClient(clientId: string, formData: FormData, session: Session) {
async function updateApiClient(client_id: string, formData: FormData, _: Session) {

const name = formData.get("name")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const name = formData.get("name")
const name = formData.get("name") as Client["name"]

const appType = formData.get("app_type")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const appType = formData.get("app_type")
const app_type = formData.get("app_type") as Client["app_type"]


if (!name || typeof name !== "string") {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!name || typeof name !== "string") {
if (!name) {

return {
error: "Client name is required.",
}
}

if (!appType || typeof appType !== "string" || !["native", "spa", "regular_web", "non_interactive"].includes(appType)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!appType || typeof appType !== "string" || !["native", "spa", "regular_web", "non_interactive"].includes(appType)) {
if (!app_type) {

return {
error: "Application type is required and must be valid.",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
error: "Application type is required and must be valid.",
error: "Application type is required.",

}
}

try {
await managementClient.clients.update(
{ client_id: clientId },
{
name,
app_type: appType,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
app_type: appType,
app_type,

}
)

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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async function rotateApiClientSecret(clientId: string, session: Session) {
async function rotateApiClientSecret(client_id: string, _: Session) {

try {
const result = await managementClient.clients.rotateSecret({ client_id: clientId })

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const result = await managementClient.clients.rotateSecret({ client_id: clientId })
const { data: result } = await managementClient.clients.rotateClientSecret({ client_id })


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",
}
)
123 changes: 123 additions & 0 deletions app/dashboard/organization/clients/clients-list.tsx
Original file line number Diff line number Diff line change
@@ -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"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Avatar, AvatarFallback } 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 (
<Card>
<CardHeader>
<CardTitle>API Clients</CardTitle>
<CardDescription>The current API clients for your organization.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Client</TableHead>
<TableHead>Type</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{clients.map((client) => (
<TableRow key={client.id}>
<TableCell>
<div className="flex items-center space-x-4">
<Avatar>
<AvatarFallback>
{client.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium leading-none">
{client.name}
</p>
<p className="text-sm text-muted-foreground">
{client.id}
</p>
</div>
</div>
</TableCell>
<TableCell>
<span className="capitalize">{client.type}</span>
</TableCell>
<TableCell className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="outline">
<DotsVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={async () => {
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
}}
>
<ReloadIcon className="mr-1 size-4" />
Rotate Secret
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onSelect={async () => {
const result = await deleteApiClient(client.id)
if (result?.error) {
return toast.error(result.error)
}
toast.success(`Deleted client: ${client.name}`)
}}
>
<TrashIcon className="mr-1 size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
87 changes: 87 additions & 0 deletions app/dashboard/organization/clients/create-client-form.tsx
Original file line number Diff line number Diff line change
@@ -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"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import { createApiClient } from "./actions"
import { createApiClient, CreateApiClientSuccess } from "./actions"


export function CreateApiClientForm() {
const ref = useRef<HTMLFormElement>(null)

return (
<Card>
<form
ref={ref}
action={async (formData: FormData) => {
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()
}
Comment on lines +35 to +44

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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()
}
toast.promise(createApiClient(formData), {
loading: "Creating client...",
success: (result) => {
if ("error" in result) {
throw result.error
}
toast(() => <h5>Your client secret is:</h5>, {
description: () => (
<code>
{
(result as unknown as CreateApiClientSuccess)
?.clientSecret
}
</code>
),
duration: Infinity,
action: {
label: "Dismiss",
onClick: () => {},
},
})
return `API Client created: ${formData.get("name")}`
},
error: (err) => err,
})

}}
>
<CardHeader>
<CardTitle>Create API Client</CardTitle>
<CardDescription>
Create a new API client for your application to access this organization's resources.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Create a new API client for your application to access this organization's resources.
Create a new API client for your application to access this organization&apos;s resources.

</CardDescription>
</CardHeader>
<CardContent>
<div className="flex space-x-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">Client Name</Label>
<Input
type="text"
id="name"
name="name"
placeholder="My Application"
/>
</div>

<div className="grid w-full items-center gap-1.5">
<Label htmlFor="app_type">Application Type</Label>
<Select defaultValue="regular_web" name="app_type">
<SelectTrigger id="app_type">
<SelectValue placeholder="Select an application type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="native">Native</SelectItem>
<SelectItem value="spa">Single Page App</SelectItem>
<SelectItem value="regular_web">Regular Web App</SelectItem>
<SelectItem value="non_interactive">Non Interactive</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end">
<SubmitButton>Create Client</SubmitButton>
</CardFooter>
</form>
</Card>
)
}
Loading