From bb886317c7ef90a2a1d24d7c8a1c06bfa1e41f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Og=C3=B3rek?= Date: Wed, 20 Aug 2025 07:53:51 +0200 Subject: [PATCH] feat(studio): Add expiration date and last used to personal access tokens (#37908) * feat(studio): Add expiration date and last used to personal access tokens * Nit polish * ref: Mark expired tokens as such * chore: run prettier * Clean up * Nit --------- Co-authored-by: Joshen Lim Co-authored-by: kemal --- .../Account/AccessTokens/AccessTokenList.tsx | 82 ++++-- .../AccessTokens/AccessTokens.constants.ts | 31 +++ .../NewAccessTokenButton.test.tsx | 2 +- .../AccessTokens/NewAccessTokenDialog.tsx | 132 +++++++++- .../Account/AccessTokens/NewTokenBanner.tsx | 50 ++-- .../Account/AccessTokens/TokensDatePicker.tsx | 239 ++++++++++++++++++ .../Account/NewAccessTokenButton.tsx | 220 ++++++++++++++++ .../Settings/Logs/Logs.DatePickers.tsx | 8 +- .../components/ui/DatePicker/DatePicker.tsx | 2 +- .../ui/DatePicker/TimeSplitInput.tsx | 86 ++++--- .../access-tokens-create-mutation.ts | 4 +- apps/studio/pages/account/tokens.tsx | 3 +- 12 files changed, 757 insertions(+), 102 deletions(-) create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessTokens.constants.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/TokensDatePicker.tsx create mode 100644 apps/studio/components/interfaces/Account/NewAccessTokenButton.tsx diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx index d6ccd3fe1b0..82de89e4f24 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx @@ -1,15 +1,15 @@ +import AlertError from 'components/ui/AlertError' +import { useAccessTokenDeleteMutation } from 'data/access-tokens/access-tokens-delete-mutation' +import { AccessToken, useAccessTokensQuery } from 'data/access-tokens/access-tokens-query' import dayjs from 'dayjs' import { MoreVertical, Trash } from 'lucide-react' import { useMemo, useState } from 'react' import { toast } from 'sonner' -import AlertError from 'components/ui/AlertError' -import { useAccessTokenDeleteMutation } from 'data/access-tokens/access-tokens-delete-mutation' -import { AccessToken, useAccessTokensQuery } from 'data/access-tokens/access-tokens-query' -import { DATETIME_FORMAT } from 'lib/constants' import { Button, Card, CardContent, + cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -32,36 +32,35 @@ import { const RowLoading = () => ( - + - + - + + + + ) +const tableHeaderClass = 'text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2' const TableContainer = ({ children }: { children: React.ReactNode }) => ( - - Name - - - Token - - - Created - - + Name + Token + Last used + Expires + {children} @@ -159,16 +158,45 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo {x.token_alias} - - -

- {dayjs(x.created_at).format('D MMM YYYY')} -

-
- -

Created on {dayjs(x.created_at).format(DATETIME_FORMAT)}

-
-
+

+ {x.last_used_at ? ( + + {dayjs(x.last_used_at).format('DD MMM YYYY')} + + Last used on {dayjs(x.last_used_at).format('DD MMM, YYYY HH:mm')} + + + ) : ( + 'Never used' + )} +

+
+ + {x.expires_at ? ( + dayjs(x.expires_at).isBefore(dayjs()) ? ( + + +

Expired

+
+ + Expired on {dayjs(x.expires_at).format('DD MMM, YYYY HH:mm')} + +
+ ) : ( + + +

+ {dayjs(x.expires_at).format('DD MMM YYYY')} +

+
+ + Expires on {dayjs(x.expires_at).format('DD MMM, YYYY HH:mm')} + +
+ ) + ) : ( +

Never

+ )}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokens.constants.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokens.constants.ts new file mode 100644 index 00000000000..745c2f89ca7 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokens.constants.ts @@ -0,0 +1,31 @@ +import dayjs from 'dayjs' + +export const NON_EXPIRING_TOKEN_VALUE = 'never' +export const CUSTOM_EXPIRY_VALUE = 'custom' + +export const ExpiresAtOptions: Record = { + hour: { + value: dayjs().add(1, 'hour').toISOString(), + label: '1 hour', + }, + day: { + value: dayjs().add(1, 'days').toISOString(), + label: '1 day', + }, + week: { + value: dayjs().add(7, 'days').toISOString(), + label: '7 days', + }, + month: { + value: dayjs().add(30, 'days').toISOString(), + label: '30 days', + }, + never: { + value: NON_EXPIRING_TOKEN_VALUE, + label: 'Never', + }, + custom: { + value: CUSTOM_EXPIRY_VALUE, + label: 'Custom', + }, +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.test.tsx index b289ad15db4..41a45511e54 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.test.tsx @@ -18,9 +18,9 @@ describe(`NewAccessTokenButton`, () => { created_at: faker.date.past().toISOString(), expires_at: null, id: faker.number.int(), - last_used_at: null, token_alias: faker.lorem.words(), token: faker.lorem.words(), + last_used_at: faker.date.recent().toISOString(), }, }) }) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenDialog.tsx b/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenDialog.tsx index 81140f80beb..c6946b0c2d7 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenDialog.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenDialog.tsx @@ -1,5 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' +import dayjs from 'dayjs' import { ExternalLink } from 'lucide-react' +import { useState } from 'react' import { type SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod' @@ -18,13 +20,30 @@ import { FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + WarningIcon, } from 'ui' import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { + CUSTOM_EXPIRY_VALUE, + ExpiresAtOptions, + NON_EXPIRING_TOKEN_VALUE, +} from './AccessTokens.constants' +import { TokenDatePickerValue, TokensDatePicker } from './TokensDatePicker' const formId = 'new-access-token-form' + const TokenSchema = z.object({ tokenName: z.string().min(1, 'Please enter a name for the token'), + expiresAt: z.preprocess( + (val) => (val === NON_EXPIRING_TOKEN_VALUE ? undefined : val), + z.string().optional() + ), }) export interface NewAccessTokenDialogProps { @@ -40,16 +59,29 @@ export const NewAccessTokenDialog = ({ onOpenChange, onCreateToken, }: NewAccessTokenDialogProps) => { + const [customExpiryDate, setCustomExpiryDate] = useState( + undefined + ) + const [isCustomExpiry, setIsCustomExpiry] = useState(false) + const form = useForm>({ resolver: zodResolver(TokenSchema), - defaultValues: { tokenName: '' }, + defaultValues: { tokenName: '', expiresAt: ExpiresAtOptions['month'].value }, mode: 'onChange', }) const { mutate: createAccessToken, isLoading } = useAccessTokenCreateMutation() const onSubmit: SubmitHandler> = async (values) => { + // Use custom date if custom option is selected + let expiresAt = values.expiresAt + + if (isCustomExpiry && customExpiryDate) { + // Use the date from the TokensDatePicker + expiresAt = customExpiryDate.date + } + createAccessToken( - { name: values.tokenName, scope: tokenScope }, + { name: values.tokenName, scope: tokenScope, expires_at: expiresAt }, { onSuccess: (data) => { toast.success('Access token created successfully') @@ -62,14 +94,40 @@ export const NewAccessTokenDialog = ({ const handleClose = () => { form.reset({ tokenName: '' }) + setCustomExpiryDate(undefined) + setIsCustomExpiry(false) onOpenChange(false) } + const handleExpiryChange = (value: string) => { + if (value === CUSTOM_EXPIRY_VALUE) { + setIsCustomExpiry(true) + // Set a default custom date (today at 23:59:59) + const defaultCustomDate: TokenDatePickerValue = { + date: dayjs().endOf('day').toISOString(), + } + setCustomExpiryDate(defaultCustomDate) + form.setValue('expiresAt', value) + } else { + setIsCustomExpiry(false) + setCustomExpiryDate(undefined) + form.setValue('expiresAt', value) + } + } + + const handleCustomDateChange = (value: TokenDatePickerValue) => { + setCustomExpiryDate(value) + } + return ( { - if (!open) form.reset() + if (!open) { + form.reset() + setCustomExpiryDate(undefined) + setIsCustomExpiry(false) + } onOpenChange(open) }} > @@ -132,6 +190,72 @@ export const NewAccessTokenDialog = ({ )} /> + ( + +
+ + + + + + + {Object.values(ExpiresAtOptions).map((option) => ( + + {option.label} + + ))} + + + + {isCustomExpiry && ( + { + const today = new Date() + const maxDate = new Date() + maxDate.setDate(today.getDate() + 364) // 364 days instead of 365 + + // Normalize dates to ignore time + const dateOnly = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + ) + const todayOnly = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + ) + const maxDateOnly = new Date( + maxDate.getFullYear(), + maxDate.getMonth(), + maxDate.getDate() + ) + + return dateOnly < todayOnly || dateOnly > maxDateOnly + }} + buttonTriggerProps={{ + size: 'small', + }} + /> + )} +
+ {field.value === NON_EXPIRING_TOKEN_VALUE && ( +
+ + + Make sure to keep your non-expiring token safe and secure. + +
+ )} +
+ )} + /> @@ -141,6 +265,8 @@ export const NewAccessTokenDialog = ({ disabled={isLoading} onClick={() => { form.reset() + setCustomExpiryDate(undefined) + setIsCustomExpiry(false) onOpenChange(false) }} > diff --git a/apps/studio/components/interfaces/Account/AccessTokens/NewTokenBanner.tsx b/apps/studio/components/interfaces/Account/AccessTokens/NewTokenBanner.tsx index 2c484e6d293..ccdb05bcd83 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/NewTokenBanner.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/NewTokenBanner.tsx @@ -1,40 +1,48 @@ +import { X } from 'lucide-react' import { toast } from 'sonner' import { NewAccessToken } from 'data/access-tokens/access-tokens-create-mutation' +import { Button } from 'ui' import { Admonition } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' interface NewTokenBannerProps { token: NewAccessToken + onClose: () => void } -export const NewTokenBanner = ({ token }: NewTokenBannerProps) => { +export const NewTokenBanner = ({ token, onClose }: NewTokenBannerProps) => { return ( -
-

- Do copy this access token and store it in a secure place - you will not be able to see - it again. -

-
- {}} - onCopy={() => toast.success('Token copied to clipboard')} - /> -
+
+

+ Do copy this access token and store it in a secure place - you will not be able to see + it again. +

+
+ {}} + onCopy={() => toast.success('Token copied to clipboard')} + />
- +
} - /> + > + + + +
+
+
+ +
+
+
+ 'cursor-pointer hover:bg-accent'} + filterDate={(date) => { + if (!disabled) return true + return !disabled(date) + }} + todayButton={ +
+ +
+ } + renderCustomHeader={({ + date, + decreaseMonth, + increaseMonth, + prevMonthButtonDisabled, + nextMonthButtonDisabled, + }) => ( +
+
+
+
+ )} + /> +
+
+ + +
+
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Account/NewAccessTokenButton.tsx b/apps/studio/components/interfaces/Account/NewAccessTokenButton.tsx new file mode 100644 index 00000000000..3ed160fdfa2 --- /dev/null +++ b/apps/studio/components/interfaces/Account/NewAccessTokenButton.tsx @@ -0,0 +1,220 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { ChevronDown, ExternalLink } from 'lucide-react' +import { useState } from 'react' +import { type SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' + +import { useAccessTokenCreateMutation } from 'data/access-tokens/access-tokens-create-mutation' +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { ExpiresAtOptions, NON_EXPIRING_TOKEN_VALUE } from './AccessTokens/AccessTokens.constants' + +export interface NewAccessTokenButtonProps { + onCreateToken: (token: any) => void +} + +const TokenSchema = z.object({ + tokenName: z.string().min(1, 'Please enter a name for the token'), + expiresAt: z.preprocess( + (val) => (val === NON_EXPIRING_TOKEN_VALUE ? undefined : val), + z.string().datetime().optional() + ), +}) + +const formId = 'new-access-token-form' + +export const NewAccessTokenButton = ({ onCreateToken }: NewAccessTokenButtonProps) => { + const [visible, setVisible] = useState(false) + const [tokenScope, setTokenScope] = useState<'V0' | undefined>(undefined) + + const form = useForm>({ + resolver: zodResolver(TokenSchema), + defaultValues: { tokenName: '', expiresAt: ExpiresAtOptions['month'].value }, + mode: 'onSubmit', + }) + const { mutate: createAccessToken, isLoading } = useAccessTokenCreateMutation() + + const onSubmit: SubmitHandler> = async (values) => { + createAccessToken( + { name: values.tokenName, scope: tokenScope, expires_at: values.expiresAt }, + { + onSuccess: (data) => { + toast.success(`Your access token "${data.name}" is ready.`) + form.reset() + onCreateToken(data) + setVisible(false) + }, + } + ) + } + + return ( + <> +
+ + + +
+ + { + if (!open) form.reset() + setVisible(open) + }} + > + + + + {tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'} + + + + + {tokenScope === 'V0' && ( + +

+ These include deleting organizations and projects which cannot be undone. As + such, be very careful when using this API. +

+ + + } + /> + )} + +
+ ( + + + + + + )} + /> + ( + + + + + + + + {Object.values(ExpiresAtOptions).map((option) => ( + + {option.label} + + ))} + + + + {field.value === NON_EXPIRING_TOKEN_VALUE && ( + + )} + + )} + /> + +
+
+ + + + +
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx index 258a65f1dfb..b81e86ba18d 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx @@ -1,13 +1,14 @@ /* eslint-disable react-hooks/exhaustive-deps */ import dayjs from 'dayjs' -import { ChevronLeft, ChevronRight, Clock, HistoryIcon, XIcon } from 'lucide-react' +import { ChevronLeft, ChevronRight, Clock, HistoryIcon } from 'lucide-react' import { PropsWithChildren, useEffect, useRef, useState } from 'react' import DatePicker from 'react-datepicker' -import { Label } from '@ui/components/shadcn/ui/label' import { Badge } from '@ui/components/shadcn/ui/badge' +import { Label } from '@ui/components/shadcn/ui/label' import { RadioGroup, RadioGroupItem } from '@ui/components/shadcn/ui/radio-group' -import TimeSplitInput from 'components/ui/DatePicker/TimeSplitInput' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { TimeSplitInput } from 'components/ui/DatePicker/TimeSplitInput' import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import { Button, @@ -20,7 +21,6 @@ import { } from 'ui' import { LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD } from './Logs.constants' import type { DatetimeHelper } from './Logs.types' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' export type DatePickerValue = { to: string diff --git a/apps/studio/components/ui/DatePicker/DatePicker.tsx b/apps/studio/components/ui/DatePicker/DatePicker.tsx index 85e722f88e7..64feb2cf4df 100644 --- a/apps/studio/components/ui/DatePicker/DatePicker.tsx +++ b/apps/studio/components/ui/DatePicker/DatePicker.tsx @@ -13,7 +13,7 @@ import { Popover_Shadcn_, } from 'ui' import { ButtonProps } from 'ui/src/components/Button/Button' -import TimeSplitInput from './TimeSplitInput' +import { TimeSplitInput } from './TimeSplitInput' export interface DatePickerProps { onChange?: (args: DatePickerToFrom) => void diff --git a/apps/studio/components/ui/DatePicker/TimeSplitInput.tsx b/apps/studio/components/ui/DatePicker/TimeSplitInput.tsx index 7b115776d76..51653255d02 100644 --- a/apps/studio/components/ui/DatePicker/TimeSplitInput.tsx +++ b/apps/studio/components/ui/DatePicker/TimeSplitInput.tsx @@ -2,13 +2,19 @@ import { format } from 'date-fns' import { Clock } from 'lucide-react' import { useEffect, useState } from 'react' -import type { TimeSplitInputProps, TimeType } from './DatePicker.types' import { isUnixMicro, unixMicroToIsoTimestamp, } from 'components/interfaces/Settings/Logs/Logs.utils' +import { cn } from 'ui' +import type { TimeSplitInputProps, TimeType } from './DatePicker.types' -const TimeSplitInput = ({ +const inputStyle = cn( + 'w-6 p-0 text-center text-xs text-foreground outline-none cursor-text', + 'ring-0 focus:ring-0 ring-none border-none bg-transparent' +) + +export const TimeSplitInput = ({ type, time, setTime, @@ -154,11 +160,31 @@ const TimeSplitInput = ({ const handleFocus = (event: React.FocusEvent) => { event.target.select() setFocus(true) + // Prevent parent dialog from stealing focus + event.stopPropagation() } - useEffect(() => { - handleOnBlur() - }, [startDate, endDate]) + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation() + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + // Allow only numbers and navigation keys + const allowedKeys = ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Enter'] + const isNumber = /^[0-9]$/.test(event.key) + + if (!isNumber && !allowedKeys.includes(event.key)) { + event.preventDefault() + } + + // Prevent parent dialog from stealing focus on keydown + event.stopPropagation() + } + + const handleInput = (event: React.FormEvent) => { + // Prevent parent dialog from stealing focus on input + event.stopPropagation() + } function handlePaste(event: ClipboardEvent) { event.preventDefault() @@ -206,6 +232,7 @@ const TimeSplitInput = ({ flex h-7 items-center justify-center gap-0 rounded border border-strong bg-surface-100 text-xs text-foreground-light ${focus && ' border-stronger outline outline-2 outline-border'} + hover:border-stronger transition-colors `} >
@@ -216,21 +243,14 @@ const TimeSplitInput = ({ type="text" onBlur={() => handleOnBlur()} onFocus={handleFocus} + onClick={handleClick} + onKeyDown={handleKeyDown} + onInput={handleInput} pattern="[0-23]*" placeholder="00" onChange={(e) => handleOnChange(e.target.value, 'HH')} aria-label="Hours" - className=" - ring-none - w-4 - border-none - bg-transparent - p-0 text-center text-xs - text-foreground - outline-none - ring-0 - focus:ring-0 - " + className={inputStyle} value={time.HH} /> : @@ -238,21 +258,14 @@ const TimeSplitInput = ({ type="text" onBlur={() => handleOnBlur()} onFocus={handleFocus} - pattern="[0-12]*" + onClick={handleClick} + onKeyDown={handleKeyDown} + onInput={handleInput} + pattern="[0-59]*" placeholder="00" onChange={(e) => handleOnChange(e.target.value, 'mm')} aria-label="Minutes" - className=" - ring-none - w-4 - border-none - bg-transparent - p-0 text-center text-xs - text-foreground - outline-none - ring-0 - focus:ring-0 - " + className={inputStyle} value={time.mm} /> : @@ -260,25 +273,16 @@ const TimeSplitInput = ({ type="text" onBlur={() => handleOnBlur()} onFocus={handleFocus} + onClick={handleClick} + onKeyDown={handleKeyDown} + onInput={handleInput} pattern="[0-59]*" placeholder="00" onChange={(e) => handleOnChange(e.target.value, 'ss')} aria-label="Seconds" - className=" - ring-none - w-4 - border-none - bg-transparent - p-0 text-center text-xs - text-foreground - outline-none - ring-0 - focus:ring-0 - " + className={inputStyle} value={time.ss} />
) } - -export default TimeSplitInput diff --git a/apps/studio/data/access-tokens/access-tokens-create-mutation.ts b/apps/studio/data/access-tokens/access-tokens-create-mutation.ts index f7aa9910e92..c4f2d182383 100644 --- a/apps/studio/data/access-tokens/access-tokens-create-mutation.ts +++ b/apps/studio/data/access-tokens/access-tokens-create-mutation.ts @@ -8,9 +8,9 @@ import { accessTokenKeys } from './keys' export type AccessTokenCreateVariables = components['schemas']['CreateAccessTokenBody'] -export async function createAccessToken({ name, scope }: AccessTokenCreateVariables) { +export async function createAccessToken({ name, scope, expires_at }: AccessTokenCreateVariables) { const { data, error } = await post('/platform/profile/access-tokens', { - body: { name, scope }, + body: { name, scope, expires_at }, }) if (error) handleError(error) diff --git a/apps/studio/pages/account/tokens.tsx b/apps/studio/pages/account/tokens.tsx index 89f6e224111..ab003a53b82 100644 --- a/apps/studio/pages/account/tokens.tsx +++ b/apps/studio/pages/account/tokens.tsx @@ -1,6 +1,5 @@ import { ExternalLink, Search } from 'lucide-react' import { useState } from 'react' - import { AccessTokenList } from 'components/interfaces/Account/AccessTokens/AccessTokenList' import { NewAccessTokenButton } from 'components/interfaces/Account/AccessTokens/NewAccessTokenButton' import { NewTokenBanner } from 'components/interfaces/Account/AccessTokens/NewTokenBanner' @@ -32,7 +31,7 @@ const UserAccessTokens: NextPageWithLayout = () => {
- {newToken && } + {newToken && setNewToken(undefined)} />}