ScopedTokenList.tsx190 lines · main
1import { Key, MoreVertical, Trash } from 'lucide-react'
2import { parseAsStringLiteral, useQueryState } from 'nuqs'
3import { useMemo, useState } from 'react'
4import { toast } from 'sonner'
5import {
6 Button,
7 DropdownMenu,
8 DropdownMenuContent,
9 DropdownMenuItem,
10 DropdownMenuTrigger,
11} from 'ui'
12import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
13import { TableCell, TableRow } from 'ui/src/components/shadcn/ui/table'
14
15import {
16 ACCESS_TOKEN_SORT_VALUES,
17 AccessTokenSort,
18 AccessTokenSortColumn,
19} from '../AccessToken.types'
20import { filterAndSortTokens, handleSortChange } from '../AccessToken.utils'
21import { RowLoading } from '../AccessTokenTable/RowLoading'
22import { TableContainer } from '../AccessTokenTable/TableContainer'
23import { ExpiresCell, LastUsedCell, TokenNameCell } from '../AccessTokenTable/TokenCells'
24import { ViewTokenSheet } from './ViewTokenSheet'
25import AlertError from '@/components/ui/AlertError'
26import {
27 ScopedAccessToken,
28 useScopedAccessTokensQuery,
29} from '@/data/scoped-access-tokens/scoped-access-token-query'
30import { useScopedAccessTokenDeleteMutation } from '@/data/scoped-access-tokens/scoped-access-tokens-delete-mutation'
31import { useTrack } from '@/lib/telemetry/track'
32
33export interface ScopedTokenListProps {
34 searchString?: string
35 onDeleteSuccess: (id: string | number) => void
36}
37
38export const ScopedTokenList = ({ searchString = '', onDeleteSuccess }: ScopedTokenListProps) => {
39 const track = useTrack()
40 const [isOpen, setIsOpen] = useState(false)
41 const [token, setToken] = useState<ScopedAccessToken | undefined>(undefined)
42 const [viewToken, setViewToken] = useState<ScopedAccessToken | undefined>(undefined)
43 const [isViewSheetOpen, setIsViewSheetOpen] = useState(false)
44 const [sort, setSort] = useQueryState(
45 'sort',
46 parseAsStringLiteral<AccessTokenSort>(ACCESS_TOKEN_SORT_VALUES).withDefault('created_at:desc')
47 )
48
49 const { data: tokensData, error, isPending: isLoading, isError } = useScopedAccessTokensQuery()
50
51 const tokens = tokensData?.tokens
52
53 const { mutate: deleteToken } = useScopedAccessTokenDeleteMutation({
54 onSuccess: (_, vars) => {
55 track('access_token_removed', { tokenType: 'scoped' })
56 onDeleteSuccess(vars.id)
57 toast.success('Successfully deleted access token')
58 setIsOpen(false)
59 },
60 onError: (error) => {
61 toast.error(`Failed to delete access token: ${error.message}`)
62 },
63 })
64
65 const onSortChange = (column: AccessTokenSortColumn) => {
66 handleSortChange(sort, column, setSort)
67 }
68
69 const filteredTokens = useMemo(
70 () => filterAndSortTokens(tokens, searchString, sort),
71 [tokens, searchString, sort]
72 )
73
74 const empty = filteredTokens?.length === 0 && !isLoading
75
76 if (isError) {
77 return (
78 <TableContainer sort={sort} onSortChange={onSortChange}>
79 <TableRow>
80 <TableCell colSpan={4} className="p-0">
81 <AlertError
82 error={error}
83 subject="Failed to retrieve access tokens"
84 className="rounded-none border-0"
85 />
86 </TableCell>
87 </TableRow>
88 </TableContainer>
89 )
90 }
91
92 if (isLoading) {
93 return (
94 <TableContainer sort={sort} onSortChange={onSortChange}>
95 <RowLoading />
96 <RowLoading />
97 </TableContainer>
98 )
99 }
100
101 if (empty) {
102 return (
103 <TableContainer sort={sort} onSortChange={onSortChange}>
104 <TableRow>
105 <TableCell colSpan={4} className="py-12">
106 <p className="text-sm text-center text-foreground">No scoped access tokens found</p>
107 <p className="text-sm text-center text-foreground-light">
108 You do not have any scoped tokens created yet
109 </p>
110 </TableCell>
111 </TableRow>
112 </TableContainer>
113 )
114 }
115
116 return (
117 <>
118 <TableContainer sort={sort} onSortChange={onSortChange}>
119 {filteredTokens?.map((x) => (
120 <TableRow key={x.id}>
121 <TokenNameCell name={x.name} tokenAlias={x.token_alias} />
122 <LastUsedCell lastUsedAt={x.last_used_at} />
123 <ExpiresCell expiresAt={x.expires_at} />
124 <TableCell>
125 <div className="flex items-center justify-end gap-x-2">
126 <DropdownMenu>
127 <DropdownMenuTrigger asChild>
128 <Button
129 type="default"
130 title="More options"
131 className="w-7"
132 icon={<MoreVertical />}
133 />
134 </DropdownMenuTrigger>
135 <DropdownMenuContent side="bottom" align="end" className="w-40">
136 <DropdownMenuItem
137 className="gap-x-2"
138 onClick={() => {
139 setViewToken(x)
140 setIsViewSheetOpen(true)
141 }}
142 >
143 <Key size={12} />
144 <p>View permissions</p>
145 </DropdownMenuItem>
146 <DropdownMenuItem
147 className="gap-x-2"
148 onClick={() => {
149 setToken(x)
150 setIsOpen(true)
151 }}
152 >
153 <Trash size={12} />
154 <p>Delete token</p>
155 </DropdownMenuItem>
156 </DropdownMenuContent>
157 </DropdownMenu>
158 </div>
159 </TableCell>
160 </TableRow>
161 ))}
162 </TableContainer>
163
164 <ConfirmationModal
165 visible={isOpen}
166 variant="destructive"
167 title="Confirm to delete"
168 confirmLabel="Delete"
169 confirmLabelLoading="Deleting"
170 onCancel={() => setIsOpen(false)}
171 onConfirm={() => {
172 if (token) deleteToken({ id: token.id as string })
173 }}
174 >
175 <p className="py-4 text-sm text-foreground-light">
176 This action cannot be undone. Are you sure you want to delete "{token?.name}" token?
177 </p>
178 </ConfirmationModal>
179
180 <ViewTokenSheet
181 visible={isViewSheetOpen}
182 tokenId={viewToken ? String(viewToken.id) : undefined}
183 onClose={() => {
184 setIsViewSheetOpen(false)
185 setViewToken(undefined)
186 }}
187 />
188 </>
189 )
190}