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

Feat personal token redesign #150

Draft
wants to merge 7 commits 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
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { PageHeading } from '@repo/ui/page-heading'
import type { Metadata } from 'next/types'
import { OrganizationNameForm } from '@/components/pages/protected/organization/general-settings/organization-name-form'
import { AvatarUpload } from '@/components/shared/avatar-upload/avatar-upload'
import { pageStyles } from './page.styles'
import { OrganizationEmailForm } from '@/components/pages/protected/organization/general-settings/organization-email-form'
import { OrganizationDelete } from '@/components/pages/protected/organization/general-settings/organization-delete'

export const metadata: Metadata = {
Expand All @@ -17,7 +15,6 @@ const Page: React.FC = () => {
<PageHeading eyebrow="Organization settings" heading="General" />
<div className={wrapper()}>
<OrganizationNameForm />
<OrganizationEmailForm />
<OrganizationDelete />
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const metadata: Metadata = {
const Page: React.FC = () => {
return (
<>
<PageHeading heading="Developers" eyebrow="User Settings" />
<PageHeading heading="Developers" />
<DevelopersPage />
</>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { MoreHorizontal, Trash2 } from 'lucide-react'
import { useToast } from '@repo/ui/use-toast'
import { pageStyles } from '../page.styles'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger } from '@repo/ui/dropdown-menu'
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from '@repo/ui/dialog'
import { Button } from '@repo/ui/button'
import { useDeletePersonalAccessTokenMutation } from '@repo/codegen/src/schema'
import { type UseQueryExecute } from 'urql'

Expand Down Expand Up @@ -39,15 +41,37 @@ export const TokenAction = ({ tokenId, refetchTokens }: TokenActionProps) => {
})
}
}

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<MoreHorizontal className={actionIcon()} />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-10">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={handleDeleteToken}>
<Trash2 width={ICON_SIZE} /> Delete token
<DropdownMenuItem asChild>
<Dialog>
<DialogTrigger asChild>
<div className="flex items-center cursor-pointer">
<Trash2 width={ICON_SIZE} className="mr-2" />
Delete token
</div>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Deletion</DialogTitle>
<DialogDescription>Are you sure you want to delete this token? This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="filled" onClick={handleDeleteToken}>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
'use client'

import React from 'react'
import { pageStyles } from './page.styles'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@repo/ui/tabs'
import { useState } from 'react'
import { PersonalAccessTokenForm } from './personal-access-token-form'
import { Panel, PanelHeader } from '@repo/ui/panel'
import PersonalApiKeyDialog from './personal-access-token-create-dialog'
import { PersonalAccessTokenTable } from './personal-access-tokens-table'

const DevelopersPage: React.FC = () => {
const { wrapper } = pageStyles()
const defaultTab = 'pat'
const [activeTab, setActiveTab] = useState(defaultTab)

return (
<>
<div className={wrapper()}>
<PersonalAccessTokenForm />
<div className={wrapper()}>
<Panel>
<div className="flex justify-between items-center mb-4">
<PanelHeader heading="Personal Access Tokens" noBorder />
<PersonalApiKeyDialog />
</div>
<PersonalAccessTokenTable />
</div>
</>
</Panel>
</div>
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
'use client'

import React, { useState } from 'react'
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@repo/ui/dialog'
import { Input } from '@repo/ui/input'
import { Label } from '@repo/ui/label'
import { Button } from '@repo/ui/button'
import { Checkbox } from '@repo/ui/checkbox'
import { CirclePlusIcon } from 'lucide-react'
import { toast } from '@repo/ui/use-toast'
import { useCreatePersonalAccessTokenMutation, useGetPersonalAccessTokensQuery } from '@repo/codegen/src/schema'
import { useSession } from 'next-auth/react'
import { useOrganization } from '@/hooks/useOrganization'
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@repo/ui/form'
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '@repo/ui/dropdown-menu'
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/avatar'
import { useForm } from 'react-hook-form'
import { z, infer as zInfer } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { UseQueryExecute } from 'urql'

const formSchema = z
.object({
name: z.string().min(3, { message: 'Token name is required' }),
description: z.string().optional(),
organizationIDs: z.array(z.string()).optional(),
expiryDate: z.date().optional(),
noExpire: z.boolean().optional(),
})
.refine((data) => data.expiryDate || data.noExpire, {
message: 'Please specify an expiry date or select the Never expires checkbox',
path: ['expiryDate'],
})

type FormData = zInfer<typeof formSchema>

type PersonalApiKeyDialogProps = {
triggerText?: boolean
}

const PersonalApiKeyDialog = ({ triggerText }: PersonalApiKeyDialogProps) => {
const { data: sessionData } = useSession()
const { allOrgs: orgs } = useOrganization()
const [isSubmitting, setIsSubmitting] = useState(false)
const [result, createToken] = useCreatePersonalAccessTokenMutation()

const [{ data, fetching, error }, refetch] = useGetPersonalAccessTokensQuery({ requestPolicy: 'network-only' })

const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
description: '',
expiryDate: undefined,
organizationIDs: [],
noExpire: false,
},
})

const handleSubmit = async (values: FormData) => {
try {
setIsSubmitting(true)
const response = await createToken({
input: {
name: values.name,
description: values.description,
expiresAt: values.noExpire ? null : values.expiryDate,
ownerID: sessionData?.user.userId,
organizationIDs: values.organizationIDs || [],
},
})

const createdToken = response.data?.createPersonalAccessToken.personalAccessToken.token

if (response.data && createdToken) {
toast({
title: 'Token created successfully!',
description: 'Copy your access token now, as you will not be able to see it again.',
variant: 'success',
})
refetch()
console.log('Generated Token:', createdToken) // Show this in a modal/dialog if needed
} else {
throw new Error('Failed to create token')
}
} catch (error) {
toast({
title: 'Error creating API Key!',
description: 'Something went wrong. Please try again.',
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}

return (
<Dialog
onOpenChange={() => {
console.log('first')
}}
>
<DialogTrigger asChild>
{triggerText ? (
<div className="flex cursor-pointer">
<p className="text-brand ">Create token</p>
<p>?</p>
</div>
) : (
<Button iconPosition="left" icon={<CirclePlusIcon />}>
Create Token
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[455px]">
<DialogHeader>
<DialogTitle className="text-2xl font-semibold">Create new token</DialogTitle>
</DialogHeader>
<Form {...form}>
<form className="space-y-4" onSubmit={form.handleSubmit(handleSubmit)}>
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Token name*</FormLabel>
<FormControl>
<Input placeholder="Enter token name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
name="description"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input placeholder="Enter a description (optional)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
name="organizationIDs"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Authorized organization(s)</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outlineInput" full>
{field.value && field.value.length > 0
? Object.entries(orgs)
.filter(([key, value]) => field?.value?.includes(value?.node?.id ?? ''))
.map(([key, value]) => value?.node?.name)
.join(', ')
: 'Select organization(s)'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{Object.entries(orgs).map(([key, value]) => {
const image = value?.node?.avatarFile?.presignedURL ?? value?.node?.avatarRemoteURL
return (
<DropdownMenuCheckboxItem
key={value?.node?.id}
checked={field?.value?.includes(value?.node?.id ?? '')}
onCheckedChange={(checked) => {
const newValue = checked ? [...(field?.value ?? []), value?.node?.id!] : field?.value?.filter((id) => id !== value?.node?.id)
field.onChange(newValue)
}}
>
<Avatar variant="medium">
{image && <AvatarImage src={image} />}
<AvatarFallback>{value?.node?.name?.substring(0, 2)}</AvatarFallback>
</Avatar>
{value?.node?.name}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
name="expiryDate"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Expiration*</FormLabel>
{!form.watch('noExpire') && (
<>
<FormControl>
<Input
type="date"
disabled={form.watch('noExpire')}
value={field.value ? field.value.toISOString().split('T')[0] : ''}
onChange={(e) => field.onChange(e.target.value ? new Date(e.target.value) : undefined)}
/>
</FormControl>
<FormMessage />
</>
)}
</FormItem>
)}
/>

<FormField
name="noExpire"
control={form.control}
render={({ field }) => (
<FormItem>
<div className="flex items-center mt-2">
<Checkbox id="no-expire" checked={field.value} onCheckedChange={(checked) => field.onChange(checked)} />
<Label htmlFor="no-expire" className="ml-2 font-medium">
Never expires
</Label>
</div>
</FormItem>
)}
/>

<DialogFooter>
<Button className="w-full mt-4" type="submit" variant="filled" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Token'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

export default PersonalApiKeyDialog
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { tv, type VariantProps } from 'tailwind-variants'

const personalAccessTokenTableStyles = tv({
slots: {
tableRow: 'h-64 text-center',
keyIcon: 'text-accent-primary cursor-pointer text-4xl',
message: ' text-sm mt-5',
createLink: 'text-accent-primary text-sm font-medium mt-2 underline cursor-pointer',
},
})

export type PersonalAccessTokenTableVariants = VariantProps<typeof personalAccessTokenTableStyles>

export { personalAccessTokenTableStyles }
Loading
Loading