mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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 <joshenlimek@gmail.com> Co-authored-by: kemal <hello@kemal.earth>
This commit is contained in:
12 files changed
+757
-102
No files matched your search
@@ -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 = () => (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Skeleton className="max-w-60 h-4 rounded-full" />
|
||||
<Skeleton className="w-40 max-w-40 h-4 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="max-w-40 h-4 rounded-full" />
|
||||
<Skeleton className="w-60 max-w-60 h-4 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="max-w-32 h-4 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="w-20 h-8 rounded-md" />
|
||||
<Skeleton className="max-w-32 h-4 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="w-4 h-4 rounded-md" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
const tableHeaderClass = 'text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2'
|
||||
const TableContainer = ({ children }: { children: React.ReactNode }) => (
|
||||
<Card className="w-full overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<Table className="p-5 table-auto">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-200">
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
|
||||
Name
|
||||
</TableHead>
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
|
||||
Token
|
||||
</TableHead>
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
|
||||
Created
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-mono uppercase text-xs text-foreground-lighter h-auto py-2" />
|
||||
<TableHead className={tableHeaderClass}>Name</TableHead>
|
||||
<TableHead className={tableHeaderClass}>Token</TableHead>
|
||||
<TableHead className={tableHeaderClass}>Last used</TableHead>
|
||||
<TableHead className={tableHeaderClass}>Expires</TableHead>
|
||||
<TableHead className={cn(tableHeaderClass, '!text-right')} />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{children}</TableBody>
|
||||
@@ -159,16 +158,45 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
|
||||
<span className="font-mono text-foreground-light">{x.token_alias}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-foreground-light">
|
||||
{dayjs(x.created_at).format('D MMM YYYY')}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Created on {dayjs(x.created_at).format(DATETIME_FORMAT)}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<p className="text-foreground-light">
|
||||
{x.last_used_at ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{dayjs(x.last_used_at).format('DD MMM YYYY')}</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Last used on {dayjs(x.last_used_at).format('DD MMM, YYYY HH:mm')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
'Never used'
|
||||
)}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{x.expires_at ? (
|
||||
dayjs(x.expires_at).isBefore(dayjs()) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<p className="text-foreground-light">Expired</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Expired on {dayjs(x.expires_at).format('DD MMM, YYYY HH:mm')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<p className="text-foreground-light">
|
||||
{dayjs(x.expires_at).format('DD MMM YYYY')}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Expires on {dayjs(x.expires_at).format('DD MMM, YYYY HH:mm')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
) : (
|
||||
<p className="text-foreground-light">Never</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
|
||||
@@ -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<string, { value: string; label: string }> = {
|
||||
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',
|
||||
},
|
||||
}
|
||||
+1
-1
@@ -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(),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<TokenDatePickerValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
const [isCustomExpiry, setIsCustomExpiry] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof TokenSchema>>({
|
||||
resolver: zodResolver(TokenSchema),
|
||||
defaultValues: { tokenName: '' },
|
||||
defaultValues: { tokenName: '', expiresAt: ExpiresAtOptions['month'].value },
|
||||
mode: 'onChange',
|
||||
})
|
||||
const { mutate: createAccessToken, isLoading } = useAccessTokenCreateMutation()
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof TokenSchema>> = 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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) form.reset()
|
||||
if (!open) {
|
||||
form.reset()
|
||||
setCustomExpiryDate(undefined)
|
||||
setIsCustomExpiry(false)
|
||||
}
|
||||
onOpenChange(open)
|
||||
}}
|
||||
>
|
||||
@@ -132,6 +190,72 @@ export const NewAccessTokenDialog = ({
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
key="expiresAt"
|
||||
name="expiresAt"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="expiresAt" label="Expires in">
|
||||
<div className="flex gap-2">
|
||||
<FormControl_Shadcn_ className="flex-grow">
|
||||
<Select_Shadcn_ value={field.value} onValueChange={handleExpiryChange}>
|
||||
<SelectTrigger_Shadcn_>
|
||||
<SelectValue_Shadcn_ placeholder="Expires at" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{Object.values(ExpiresAtOptions).map((option) => (
|
||||
<SelectItem_Shadcn_ key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
{isCustomExpiry && (
|
||||
<TokensDatePicker
|
||||
value={customExpiryDate || { date: '' }}
|
||||
onSubmit={handleCustomDateChange}
|
||||
disabled={(date) => {
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{field.value === NON_EXPIRING_TOKEN_VALUE && (
|
||||
<div className="w-full flex gap-x-2 items-center mt-3 mx-0.5">
|
||||
<WarningIcon />
|
||||
<span className="text-xs text-left text-foreground-lighter">
|
||||
Make sure to keep your non-expiring token safe and secure.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</DialogSection>
|
||||
@@ -141,6 +265,8 @@ export const NewAccessTokenDialog = ({
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
form.reset()
|
||||
setCustomExpiryDate(undefined)
|
||||
setIsCustomExpiry(false)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<Admonition
|
||||
type="default"
|
||||
className="relative mb-6"
|
||||
title="Successfully generated a new token!"
|
||||
className="mb-6"
|
||||
description={
|
||||
<>
|
||||
<div className="w-full space-y-2">
|
||||
<p className="text-sm">
|
||||
Do copy this access token and store it in a secure place - you will not be able to see
|
||||
it again.
|
||||
</p>
|
||||
<div className="max-w-xl">
|
||||
<Input
|
||||
copy
|
||||
readOnly
|
||||
size="small"
|
||||
className="input-mono"
|
||||
value={token.token}
|
||||
onChange={() => {}}
|
||||
onCopy={() => toast.success('Token copied to clipboard')}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-2">
|
||||
<p className="text-sm">
|
||||
Do copy this access token and store it in a secure place - you will not be able to see
|
||||
it again.
|
||||
</p>
|
||||
<div className="max-w-xl">
|
||||
<Input
|
||||
copy
|
||||
readOnly
|
||||
size="small"
|
||||
className="max-w-xl input-mono"
|
||||
value={token.token}
|
||||
onChange={() => {}}
|
||||
onCopy={() => toast.success('Token copied to clipboard')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X />}
|
||||
className="w-7 h-7 absolute top-3 right-3"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { ChevronLeft, ChevronRight, Clock } from 'lucide-react'
|
||||
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
|
||||
import DatePicker from 'react-datepicker'
|
||||
|
||||
import { TimeSplitInput } from 'components/ui/DatePicker/TimeSplitInput'
|
||||
import {
|
||||
Button,
|
||||
ButtonProps,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
} from 'ui'
|
||||
|
||||
export type TokenDatePickerValue = {
|
||||
date: string
|
||||
}
|
||||
|
||||
interface TokensDatePickerProps {
|
||||
value: TokenDatePickerValue
|
||||
buttonTriggerProps?: ButtonProps
|
||||
popoverContentProps?: typeof PopoverContent_Shadcn_
|
||||
onSubmit: (value: TokenDatePickerValue) => void
|
||||
disabled?: (date: Date) => boolean
|
||||
}
|
||||
|
||||
export const TokensDatePicker = ({
|
||||
value,
|
||||
buttonTriggerProps,
|
||||
popoverContentProps,
|
||||
onSubmit,
|
||||
disabled,
|
||||
}: PropsWithChildren<TokensDatePickerProps>) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const todayButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const timeInputRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Reset the state when the popover closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const dateToSet = value.date ? new Date(value.date) : new Date()
|
||||
setSelectedDate(dateToSet)
|
||||
|
||||
// If there's an existing value, use its time, otherwise default to 23:59:59
|
||||
if (value.date) {
|
||||
setSelectedTime({
|
||||
HH: dateToSet.getHours().toString().padStart(2, '0'),
|
||||
mm: dateToSet.getMinutes().toString().padStart(2, '0'),
|
||||
ss: dateToSet.getSeconds().toString().padStart(2, '0'),
|
||||
})
|
||||
} else {
|
||||
setSelectedTime({
|
||||
HH: '23',
|
||||
mm: '59',
|
||||
ss: '59',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [open, value.date])
|
||||
|
||||
// Focus the time input when popover opens
|
||||
useEffect(() => {
|
||||
if (open && timeInputRef.current) {
|
||||
// Small delay to ensure the popover is fully rendered
|
||||
setTimeout(() => {
|
||||
const firstInput = timeInputRef.current?.querySelector('input')
|
||||
if (firstInput) {
|
||||
firstInput.focus()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Prevent parent dialog from stealing focus when popover is open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const handleFocusIn = (event: FocusEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
const popover = timeInputRef.current?.closest('[data-radix-popper-content-wrapper]')
|
||||
|
||||
// If focus is moving outside the popover, prevent it
|
||||
if (popover && !popover.contains(target)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// Refocus the first time input
|
||||
const firstInput = timeInputRef.current?.querySelector('input')
|
||||
if (firstInput) {
|
||||
firstInput.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('focusin', handleFocusIn, true)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('focusin', handleFocusIn, true)
|
||||
}
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(
|
||||
value.date ? new Date(value.date) : new Date()
|
||||
)
|
||||
const [selectedTime, setSelectedTime] = useState(() => {
|
||||
if (value.date) {
|
||||
const date = new Date(value.date)
|
||||
return {
|
||||
HH: date.getHours().toString().padStart(2, '0'),
|
||||
mm: date.getMinutes().toString().padStart(2, '0'),
|
||||
ss: date.getSeconds().toString().padStart(2, '0'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
HH: '23',
|
||||
mm: '59',
|
||||
ss: '59',
|
||||
}
|
||||
})
|
||||
|
||||
function handleDatePickerChange(date: Date | null) {
|
||||
setSelectedDate(date)
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
if (!selectedDate) return
|
||||
|
||||
// Add Time to the date
|
||||
const finalDate = new Date(
|
||||
selectedDate.setHours(+selectedTime.HH, +selectedTime.mm, +selectedTime.ss)
|
||||
)
|
||||
|
||||
onSubmit({
|
||||
date: finalDate.toISOString(),
|
||||
})
|
||||
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={true}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button type="default" icon={<Clock />} {...buttonTriggerProps}>
|
||||
{selectedDate ? `${dayjs(selectedDate).format('DD MMM, HH:mm')}` : 'Select date'}
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="flex w-auto p-0 z-50"
|
||||
side="bottom"
|
||||
align="end"
|
||||
portal={true}
|
||||
{...popoverContentProps}
|
||||
>
|
||||
<div>
|
||||
<div className="flex p-2 gap-2 items-center">
|
||||
<div ref={timeInputRef} className="flex flex-grow *:flex-grow gap-2 font-mono">
|
||||
<TimeSplitInput
|
||||
type="start"
|
||||
startTime={selectedTime}
|
||||
endTime={selectedTime}
|
||||
time={selectedTime}
|
||||
setTime={setSelectedTime}
|
||||
setStartTime={setSelectedTime}
|
||||
setEndTime={setSelectedTime}
|
||||
startDate={selectedDate}
|
||||
endDate={selectedDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 border-t">
|
||||
<DatePicker
|
||||
inline
|
||||
selected={selectedDate}
|
||||
onChange={handleDatePickerChange}
|
||||
dateFormat="MMMM d, yyyy h:mm aa"
|
||||
dayClassName={() => 'cursor-pointer hover:bg-accent'}
|
||||
filterDate={(date) => {
|
||||
if (!disabled) return true
|
||||
return !disabled(date)
|
||||
}}
|
||||
todayButton={
|
||||
<div className="sr-only">
|
||||
<Button type="text" size="tiny" ref={todayButtonRef}>
|
||||
Go to today
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button
|
||||
onClick={decreaseMonth}
|
||||
disabled={prevMonthButtonDisabled}
|
||||
icon={<ChevronLeft size={14} strokeWidth={2} />}
|
||||
type="text"
|
||||
size="tiny"
|
||||
className="px-1.5"
|
||||
/>
|
||||
<span className="text-sm text-foreground-light">
|
||||
{dayjs(date).format('MMMM YYYY')}
|
||||
</span>
|
||||
<Button
|
||||
onClick={increaseMonth}
|
||||
disabled={nextMonthButtonDisabled}
|
||||
icon={<ChevronRight size={14} strokeWidth={2} />}
|
||||
type="text"
|
||||
size="tiny"
|
||||
className="px-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 p-2 border-t">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => {
|
||||
if (todayButtonRef.current) {
|
||||
todayButtonRef.current.click()
|
||||
}
|
||||
setSelectedDate(new Date())
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
<Button onClick={handleApply}>Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -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<z.infer<typeof TokenSchema>>({
|
||||
resolver: zodResolver(TokenSchema),
|
||||
defaultValues: { tokenName: '', expiresAt: ExpiresAtOptions['month'].value },
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
const { mutate: createAccessToken, isLoading } = useAccessTokenCreateMutation()
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof TokenSchema>> = 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 (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="rounded-r-none px-3"
|
||||
onClick={() => {
|
||||
setTokenScope(undefined)
|
||||
setVisible(true)
|
||||
}}
|
||||
>
|
||||
Generate new token
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="primary"
|
||||
title="Choose token scope"
|
||||
className="rounded-l-none px-[4px] py-[5px]"
|
||||
icon={<ChevronDown />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom">
|
||||
<DropdownMenuItem
|
||||
key="experimental-token"
|
||||
onClick={() => {
|
||||
setTokenScope('V0')
|
||||
setVisible(true)
|
||||
}}
|
||||
>
|
||||
Generate token for experimental API
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={visible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) form.reset()
|
||||
setVisible(open)
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection className="flex flex-col gap-4">
|
||||
{tokenScope === 'V0' && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="The experimental API provides additional endpoints which allows you to manage your organizations and projects."
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
These include deleting organizations and projects which cannot be undone. As
|
||||
such, be very careful when using this API.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<a href="https://api.supabase.com/api/v0" target="_blank" rel="noreferrer">
|
||||
Experimental API documentation
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
key="tokenName"
|
||||
name="tokenName"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="tokenName" label="Name">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
id="tokenName"
|
||||
{...field}
|
||||
placeholder="Provide a name for your token"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
key="expiresAt"
|
||||
name="expiresAt"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="expiresAt" label="Expires at">
|
||||
<FormControl_Shadcn_>
|
||||
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger_Shadcn_>
|
||||
<SelectValue_Shadcn_ placeholder="Expires at" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{Object.values(ExpiresAtOptions).map((option) => (
|
||||
<SelectItem_Shadcn_ key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
{field.value === NON_EXPIRING_TOKEN_VALUE && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Make sure to keep your non-expiring token safe and secure."
|
||||
className="mt-6 w-full"
|
||||
/>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</DialogSection>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
form.reset()
|
||||
setVisible(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button form={formId} htmlType="submit" loading={isLoading}>
|
||||
Generate token
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
event.target.select()
|
||||
setFocus(true)
|
||||
// Prevent parent dialog from stealing focus
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
handleOnBlur()
|
||||
}, [startDate, endDate])
|
||||
const handleClick = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// 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<HTMLInputElement>) => {
|
||||
// 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
|
||||
`}
|
||||
>
|
||||
<div className="mr-1 text-foreground-lighter">
|
||||
@@ -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}
|
||||
/>
|
||||
<span className="text-foreground-lighter">:</span>
|
||||
@@ -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}
|
||||
/>
|
||||
<span className="text-foreground-lighter">:</span>
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimeSplitInput
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = () => {
|
||||
</ScaffoldContainer>
|
||||
<ScaffoldContainer bottomPadding>
|
||||
<div className="space-y-4">
|
||||
{newToken && <NewTokenBanner token={newToken} />}
|
||||
{newToken && <NewTokenBanner token={newToken} onClose={() => setNewToken(undefined)} />}
|
||||
<div className="flex items-center justify-between gap-x-2 mb-3">
|
||||
<Input
|
||||
size="tiny"
|
||||
|
||||
Reference in new issue
Block a user