-
- 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')}
+ />
- >
+
}
- />
+ >
+
}
+ className="w-7 h-7 absolute top-3 right-3"
+ onClick={onClose}
+ />
+
)
}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/TokensDatePicker.tsx b/apps/studio/components/interfaces/Account/AccessTokens/TokensDatePicker.tsx
new file mode 100644
index 00000000000..04ac87be830
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/TokensDatePicker.tsx
@@ -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
) => {
+ const [open, setOpen] = useState(false)
+ const todayButtonRef = useRef(null)
+ const timeInputRef = useRef(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(
+ 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 (
+
+
+ } {...buttonTriggerProps}>
+ {selectedDate ? `${dayjs(selectedDate).format('DD MMM, HH:mm')}` : 'Select date'}
+
+
+
+
+
+
+
'cursor-pointer hover:bg-accent'}
+ filterDate={(date) => {
+ if (!disabled) return true
+ return !disabled(date)
+ }}
+ todayButton={
+
+
+
+ }
+ renderCustomHeader={({
+ date,
+ decreaseMonth,
+ increaseMonth,
+ prevMonthButtonDisabled,
+ nextMonthButtonDisabled,
+ }) => (
+
+
+ }
+ type="text"
+ size="tiny"
+ className="px-1.5"
+ />
+
+ {dayjs(date).format('MMMM YYYY')}
+
+ }
+ type="text"
+ size="tiny"
+ className="px-1.5"
+ />
+
+
+ )}
+ />
+
+
+
+
+
+
+
+
+ )
+}
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 (
+ <>
+
+
+
+
+ }
+ />
+
+
+ {
+ setTokenScope('V0')
+ setVisible(true)
+ }}
+ >
+ Generate token for experimental API
+
+
+
+
+
+
+ >
+ )
+}
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)} />}