Files
e8f5120dc5 feat(studio): enforce expiry scoped pat (#48811)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

Waiting on #48809


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added custom access-token expiration date limits, allowing dates from
today through one year ahead.
* Date pickers now enforce configured minimum and maximum date
boundaries.

* **Updates**
  * Removed the option to create non-expiring access tokens.
  * Expiration is now required when creating classic access tokens.
  * Improved form reset behavior and expiry tracking.

* **Tests**
* Added validation coverage for required, valid, and out-of-range custom
expiration dates.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Wen Bo Xie <wenbox323@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:09:21 +01:00

331 lines
9.8 KiB
TypeScript

import { format } from 'date-fns'
import dayjs from 'dayjs'
import { ArrowRight, Calendar } from 'lucide-react'
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import {
Button,
Calendar as CalendarPicker,
Popover,
PopoverContent,
PopoverSeparator,
PopoverTrigger,
} from 'ui'
import { ButtonProps } from 'ui/src/components/Button/Button'
import { TimeSplitInput } from './TimeSplitInput'
import type { DatePickerToFrom } from '@/components/interfaces/Settings/Logs/Logs.types'
interface DatePickerProps {
onChange?: (args: DatePickerToFrom) => void
to?: string // ISO string
from?: string // ISO string
triggerButtonVariant?: ButtonProps['variant']
triggerButtonClassName?: string
triggerButtonTitle?: string
triggerButtonSize?: 'tiny' | 'small'
contentSide?: 'bottom' | 'top'
minDate?: Date
maxDate?: Date
hideTime?: boolean
hideClear?: boolean
selectsRange?: boolean
renderFooter?: (args: DatePickerToFrom) => ReactNode | void
children?: ReactNode | ReactNode[] | null
}
const START_DATE_DEFAULT = new Date()
const END_DATE_DEFAULT = new Date()
const START_TIME_DEFAULT = { HH: '00', mm: '00', ss: '00' }
const END_TIME_DEFAULT = { HH: '23', mm: '59', ss: '59' }
const calculateDisabledDays = (minDate?: Date, maxDate?: Date) => {
const rules: Array<{ before: Date } | { after: Date }> = []
if (minDate) {
rules.push({ before: dayjs(minDate).startOf('day').toDate() })
}
if (maxDate) {
rules.push({ after: dayjs(maxDate).endOf('day').toDate() })
}
if (rules.length === 0) return undefined
if (rules.length === 1) return rules[0]
return rules
}
export function DatePicker({
to,
from,
onChange,
triggerButtonVariant = 'default',
triggerButtonClassName = '',
triggerButtonTitle,
triggerButtonSize,
contentSide = 'bottom',
minDate,
maxDate,
hideTime = false,
hideClear = false,
selectsRange = true,
renderFooter = () => null,
children,
}: DatePickerProps) {
const [open, setOpen] = useState<boolean>(false)
const [appliedStartDate, setAppliedStartDate] = useState<null | Date>(null)
const [appliedEndDate, setAppliedEndDate] = useState<null | Date>(null)
const [startDate, setStartDate] = useState<Date | null>(START_DATE_DEFAULT)
const [endDate, setEndDate] = useState<Date | null>(END_DATE_DEFAULT)
const [startTime, setStartTime] = useState<any>(START_TIME_DEFAULT)
const [endTime, setEndTime] = useState<any>(END_TIME_DEFAULT)
const disabledDays = useMemo(() => calculateDisabledDays(minDate, maxDate), [minDate, maxDate])
const startMonth = minDate ? dayjs(minDate).startOf('month').toDate() : undefined
const endMonth = maxDate ? dayjs(maxDate).endOf('month').toDate() : undefined
const clampDateToRange = useCallback(
(date: Date | null) => {
if (!date) return date
let nextDate = date
if (minDate && dayjs(nextDate).isBefore(minDate, 'day')) {
nextDate = dayjs(minDate).toDate()
}
if (maxDate && dayjs(nextDate).isAfter(maxDate, 'day')) {
nextDate = dayjs(maxDate).toDate()
}
return nextDate
},
[minDate, maxDate]
)
useEffect(() => {
if (!minDate && !maxDate) return
setStartDate((prev) => {
if (!prev) return prev
const clamped = clampDateToRange(prev)
return dayjs(clamped).isSame(prev) ? prev : clamped
})
setEndDate((prev) => {
if (!prev) return prev
const clamped = clampDateToRange(prev)
return dayjs(clamped).isSame(prev) ? prev : clamped
})
setAppliedStartDate((prev) => {
if (!prev) return prev
const clamped = clampDateToRange(prev)
return dayjs(clamped).isSame(prev) ? prev : clamped
})
setAppliedEndDate((prev) => {
if (!prev) return prev
const clamped = clampDateToRange(prev)
return dayjs(clamped).isSame(prev) ? prev : clamped
})
}, [minDate, maxDate, clampDateToRange])
useEffect(() => {
if (!from) {
setAppliedStartDate(null)
} else if (from !== appliedStartDate?.toISOString()) {
const start = dayjs(from)
const startDate = start.toDate()
setAppliedStartDate(startDate)
setStartDate(startDate)
setStartTime({
HH: start.format('HH'),
mm: start.format('mm'),
ss: start.format('ss'),
})
}
if (!to) {
setAppliedEndDate(null)
} else if (to !== appliedEndDate?.toISOString()) {
const end = dayjs(to)
const endDate = end.toDate()
setAppliedEndDate(endDate)
setEndDate(endDate)
setEndTime({
HH: end.format('HH'),
mm: end.format('mm'),
ss: end.format('ss'),
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [to, from])
function handleDatePickerChange(dates: Date | [from: Date | null, to: Date | null] | null) {
if (!dates) {
setStartDate(null)
setEndDate(null)
} else if (dates instanceof Date) {
setStartDate(dates)
setEndDate(dates)
} else {
const [from, to] = dates
setStartDate(from)
setEndDate(to)
}
}
function handleSubmit() {
setOpen(false)
setAppliedStartDate(startDate)
setAppliedEndDate(endDate)
const payload = {
from: dayjs(startDate)
.second(Number(startTime.ss))
.minute(Number(startTime.mm))
.hour(Number(startTime.HH))
.toISOString(),
to: dayjs(endDate || startDate)
.second(Number(endTime.ss))
.minute(Number(endTime.mm))
.hour(Number(endTime.HH))
.toISOString(),
}
if (onChange) onChange(payload)
}
function handleClear() {
setOpen(false)
setStartDate(START_DATE_DEFAULT)
setEndDate(END_DATE_DEFAULT)
setStartTime(START_TIME_DEFAULT)
setEndTime(END_TIME_DEFAULT)
setAppliedStartDate(null)
setAppliedEndDate(null)
if (onChange) onChange({ from: null, to: null })
}
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
title={triggerButtonTitle}
variant={triggerButtonVariant}
icon={<Calendar />}
size={triggerButtonSize}
className={triggerButtonClassName}
>
{children !== undefined ? (
children
) : (
<>
{/* Custom */}
{selectsRange &&
appliedStartDate &&
appliedEndDate &&
appliedStartDate !== appliedEndDate ? (
<>
{format(new Date(appliedStartDate), 'dd MMM')} -{' '}
{format(new Date(appliedEndDate), 'dd MMM')}
</>
) : appliedStartDate || appliedEndDate ? (
format(new Date((appliedStartDate || appliedEndDate)!), 'dd MMM')
) : (
'Custom'
)}
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="center" side={contentSide} className="p-0">
<>
{hideTime ? null : (
<>
<div className="flex items-stretch justify-between p-2">
{!selectsRange ? null : (
<>
<div className="flex grow flex-col gap-1">
<TimeSplitInput
type="start"
startTime={startTime}
endTime={endTime}
time={startTime}
setTime={setStartTime}
setStartTime={setStartTime}
setEndTime={setEndTime}
startDate={startDate}
endDate={endDate}
/>
</div>
<div className="flex items-center justify-center w-12 text-foreground-lighter">
<ArrowRight strokeWidth={1.5} size={14} />
</div>
</>
)}
<div className="flex grow flex-col gap-1">
<TimeSplitInput
type="end"
startTime={startTime}
endTime={endTime}
time={endTime}
setTime={setEndTime}
setStartTime={setStartTime}
setEndTime={setEndTime}
startDate={startDate}
endDate={endDate}
/>
</div>
</div>
</>
)}
<div className="p-2">
{selectsRange ? (
<CalendarPicker
mode="range"
startMonth={startMonth}
endMonth={endMonth}
disabled={disabledDays}
selected={{ from: startDate ?? undefined, to: endDate ?? undefined }}
onSelect={(range) => {
handleDatePickerChange([range?.from ?? null, range?.to ?? null])
}}
/>
) : (
<CalendarPicker
mode="single"
startMonth={startMonth}
endMonth={endMonth}
disabled={disabledDays}
selected={endDate ?? undefined}
onSelect={(date) => {
handleDatePickerChange(date ?? null)
}}
/>
)}
</div>
{renderFooter({
from: startDate?.toISOString() || null,
to: endDate?.toISOString() || null,
})}
<PopoverSeparator />
<div className="flex items-center justify-end gap-2 py-2 px-3 pb-4">
{!hideClear && (
<Button variant="default" onClick={() => handleClear()}>
Clear
</Button>
)}
<Button onClick={() => handleSubmit()}>Apply</Button>
</div>
</>
</PopoverContent>
</Popover>
)
}