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 1/5] 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 d6ccd3fe1b0e9..82de89e4f24e7 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 0000000000000..745c2f89ca714 --- /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 b289ad15db441..41a45511e540d 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 81140f80beb39..c6946b0c2d7c9 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 2c484e6d29336..ccdb05bcd837b 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 0000000000000..3ed160fdfa2f9 --- /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 258a65f1dfb52..b81e86ba18df2 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 85e722f88e7ee..64feb2cf4dfbd 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 7b115776d76d0..51653255d02a7 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 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' +) -const TimeSplitInput = ({ +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 f7aa9910e92a6..c4f2d182383ac 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 89f6e22411178..ab003a53b82db 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)} />}
Date: Wed, 20 Aug 2025 03:43:37 -0300 Subject: [PATCH 2/5] feat: aws marketplace billing in dashboard (#37670) --- .../BillingBreakdown/BillingBreakdown.tsx | 57 +++++++++---- .../BillingCustomerData.tsx | 2 +- .../CostControl/CostControl.tsx | 27 +++++- .../PaymentMethods/PaymentMethods.tsx | 5 +- .../Subscription/PlanUpdateSidePanel.tsx | 36 +++++--- .../OrganizationDeletePanel.tsx | 3 +- .../InvoicesSettings/InvoicesSettings.tsx | 24 ++++-- .../components/layouts/OrganizationLayout.tsx | 27 +++++- apps/studio/components/ui/PartnerIcon.tsx | 82 +++++++++++++++---- .../components/ui/PartnerManagedResource.tsx | 35 +++++--- .../data/integrations/aws-redirect-query.ts | 38 +++++++++ apps/studio/data/integrations/keys.ts | 1 + apps/studio/pages/new/[slug].tsx | 3 +- 13 files changed, 269 insertions(+), 71 deletions(-) create mode 100644 apps/studio/data/integrations/aws-redirect-query.ts diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx index 699e4b8533024..0195c78d2e931 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx @@ -16,10 +16,14 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import UpcomingInvoice from './UpcomingInvoice' +import { MANAGED_BY } from 'lib/constants/infrastructure' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' const BillingBreakdown = () => { const { slug: orgSlug } = useParams() + const { data: selectedOrganization } = useSelectedOrganizationQuery() + const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.subscriptions') @@ -44,26 +48,43 @@ const BillingBreakdown = () => {

Upcoming Invoice

- + {selectedOrganization?.managed_by !== MANAGED_BY.AWS_MARKETPLACE && ( + + )}

- Your upcoming invoice (excluding credits) will continue to update until the end of your - billing cycle on {billingCycleEnd.format('MMMM DD')}. For a more detailed breakdown, - visit the usage page. + {selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE ? ( + <> + You'll receive two invoices from AWS Marketplace: one on the 3rd of{' '} + {billingCycleEnd.format('MMMM')} for your usage in{' '} + {billingCycleStart.format('MMMM')} and one on {billingCycleEnd.format('MMMM DD')}{' '} + for the fixed subscription fee. + + ) : ( + <> + Your upcoming invoice (excluding credits) will continue to update until the end of + your billing cycle on {billingCycleEnd.format('MMMM DD')}. + + )} + <> + {' '} + For a more detailed breakdown, visit the{' '} + usage page. +


diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx index a137720baa988..54d50a87d7778 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx @@ -118,7 +118,7 @@ export const BillingCustomerData = () => { {selectedOrganization?.managed_by !== undefined && selectedOrganization?.managed_by !== 'supabase' ? ( { const { slug } = useParams() const { resolvedTheme } = useTheme() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.subscriptions') @@ -47,6 +53,8 @@ const CostControl = ({}: CostControlProps) => { const canChangeTier = !projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id || '') + const costControlDisabled = selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE + return ( <> @@ -97,7 +105,22 @@ const CostControl = ({}: CostControlProps) => { {isError && } - {isSuccess && ( + {isSuccess && costControlDisabled && ( + + + + + The Spend Cap is not available for organizations managed by{' '} + {PARTNER_TO_NAME[selectedOrganization?.managed_by]}. + + + )} + + {isSuccess && !costControlDisabled && (

{['team', 'enterprise'].includes(currentPlan?.id || '') ? ( { const { slug } = useParams() @@ -66,9 +67,9 @@ const PaymentMethods = () => { {selectedOrganization?.managed_by !== undefined && - selectedOrganization?.managed_by !== 'supabase' ? ( + selectedOrganization?.managed_by !== MANAGED_BY.SUPABASE ? ( { + if (selectedOrganization.managed_by === MANAGED_BY.VERCEL_MARKETPLACE) { + return { + installationId: selectedOrganization?.partner_id, + path: '/settings', + message: 'Change Plan on Vercel Marketplace', + } + } + if (selectedOrganization.managed_by === MANAGED_BY.AWS_MARKETPLACE) { + return { + organizationSlug: selectedOrganization?.slug, + } + } +} const PlanUpdateSidePanel = () => { const router = useRouter() const { slug } = useParams() @@ -144,16 +160,11 @@ const PlanUpdateSidePanel = () => {
} > - {selectedOrganization?.managed_by === 'vercel-marketplace' && ( + {selectedOrganization && selectedOrganization.managed_by !== MANAGED_BY.SUPABASE && ( )} @@ -216,8 +227,10 @@ const PlanUpdateSidePanel = () => { disabled={ subscription?.plan?.id === 'enterprise' || // Downgrades to free are still allowed through the dashboard given we have much better control about showing customers the impact + any possible issues with downgrading to free - (selectedOrganization?.managed_by !== 'supabase' && + (selectedOrganization?.managed_by !== MANAGED_BY.SUPABASE && plan.id !== 'tier_free') || + // Orgs managed by AWS marketplace are not allowed to change the plan + selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE || hasOrioleProjects || !canUpdateSubscription } @@ -243,7 +256,10 @@ const PlanUpdateSidePanel = () => { ? 'Your organization has projects that are using the OrioleDB extension which is only available on the Free plan. Remove all OrioleDB projects before changing your plan.' : !canUpdateSubscription ? 'You do not have permission to change the subscription plan' - : undefined, + : selectedOrganization?.managed_by === + MANAGED_BY.AWS_MARKETPLACE + ? 'You cannot change the plan for an organization managed by AWS Marketplace' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx index c963d1006c533..04e6537c15ca1 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx @@ -3,6 +3,7 @@ import PartnerManagedResource from 'components/ui/PartnerManagedResource' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Admonition } from 'ui-patterns' import { DeleteOrganizationButton } from './DeleteOrganizationButton' +import { MANAGED_BY } from 'lib/constants/infrastructure' const OrganizationDeletePanel = () => { const { data: selectedOrganization } = useSelectedOrganizationQuery() @@ -20,7 +21,7 @@ const OrganizationDeletePanel = () => { ) : ( { + if (selectedOrganization.managed_by === MANAGED_BY.VERCEL_MARKETPLACE) { + return { + installationId: selectedOrganization?.partner_id, + path: '/invoices', + } + } + if (selectedOrganization.managed_by === MANAGED_BY.AWS_MARKETPLACE) { + return { + organizationSlug: selectedOrganization?.slug, + overrideUrl: 'https://console.aws.amazon.com/billing/home#/bills', + } + } +} const InvoicesSettings = () => { const [page, setPage] = useState(1) @@ -62,13 +78,9 @@ const InvoicesSettings = () => { ) { return ( ) } diff --git a/apps/studio/components/layouts/OrganizationLayout.tsx b/apps/studio/components/layouts/OrganizationLayout.tsx index a705d2c6d3fca..ce196c37f72c9 100644 --- a/apps/studio/components/layouts/OrganizationLayout.tsx +++ b/apps/studio/components/layouts/OrganizationLayout.tsx @@ -7,12 +7,33 @@ import { useVercelRedirectQuery } from 'data/integrations/vercel-redirect-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { withAuth } from 'hooks/misc/withAuth' import { Alert_Shadcn_, AlertTitle_Shadcn_, Button, cn } from 'ui' +import { useAwsRedirectQuery } from 'data/integrations/aws-redirect-query' +import { MANAGED_BY } from 'lib/constants/infrastructure' const OrganizationLayoutContent = ({ children }: PropsWithChildren<{}>) => { const { data: selectedOrganization } = useSelectedOrganizationQuery() - const { data, isSuccess } = useVercelRedirectQuery({ - installationId: selectedOrganization?.partner_id, - }) + + const vercelQuery = useVercelRedirectQuery( + { + installationId: selectedOrganization?.partner_id, + }, + { + enabled: selectedOrganization?.managed_by === MANAGED_BY.VERCEL_MARKETPLACE, + } + ) + + const awsQuery = useAwsRedirectQuery( + { + organizationSlug: selectedOrganization?.slug, + }, + { + enabled: selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE, + } + ) + + // Select the appropriate query based on partner + const { data, isSuccess } = + selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE ? awsQuery : vercelQuery return (
diff --git a/apps/studio/components/ui/PartnerIcon.tsx b/apps/studio/components/ui/PartnerIcon.tsx index f4c3ff3fa032c..acd3aa7b9529b 100644 --- a/apps/studio/components/ui/PartnerIcon.tsx +++ b/apps/studio/components/ui/PartnerIcon.tsx @@ -1,5 +1,7 @@ import { Organization } from 'types' import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { MANAGED_BY } from 'lib/constants/infrastructure' +import { PARTNER_TO_NAME } from './PartnerManagedResource' interface PartnerIconProps { organization: Pick @@ -8,27 +10,71 @@ interface PartnerIconProps { size?: 'small' | 'medium' | 'large' } +function getPartnerIcon( + organization: Pick, + size: 'small' | 'medium' | 'large' +) { + switch (organization.managed_by) { + case MANAGED_BY.VERCEL_MARKETPLACE: + return ( + + + + ) + case MANAGED_BY.AWS_MARKETPLACE: + return ( + + + + + + + + + + ) + default: + return null + } +} + function PartnerIcon({ organization, showTooltip = true, - tooltipText = 'This organization is managed by Vercel Marketplace.', + tooltipText, size = 'small', }: PartnerIconProps) { - if (organization.managed_by === 'vercel-marketplace') { - const icon = ( - - - - ) + if ( + organization.managed_by === MANAGED_BY.VERCEL_MARKETPLACE || + organization.managed_by === MANAGED_BY.AWS_MARKETPLACE + ) { + const icon = getPartnerIcon(organization, size) if (!showTooltip) { return ( @@ -45,6 +91,8 @@ function PartnerIcon({ ) } + const defaultTooltipText = `This organization is managed by ${PARTNER_TO_NAME[organization.managed_by]}` + return ( @@ -59,7 +107,7 @@ function PartnerIcon({ {icon}
- {tooltipText} + {tooltipText ?? defaultTooltipText} ) } diff --git a/apps/studio/components/ui/PartnerManagedResource.tsx b/apps/studio/components/ui/PartnerManagedResource.tsx index 2acd744ed9a41..137a7b9b7ffff 100644 --- a/apps/studio/components/ui/PartnerManagedResource.tsx +++ b/apps/studio/components/ui/PartnerManagedResource.tsx @@ -1,15 +1,18 @@ import { ExternalLink } from 'lucide-react' import { useVercelRedirectQuery } from 'data/integrations/vercel-redirect-query' +import { useAwsRedirectQuery } from 'data/integrations/aws-redirect-query' import { Alert_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui' import PartnerIcon from './PartnerIcon' import { MANAGED_BY, ManagedBy } from 'lib/constants/infrastructure' interface PartnerManagedResourceProps { - partner: ManagedBy + managedBy: ManagedBy resource: string cta?: { installationId?: string + organizationSlug?: string + overrideUrl?: string path?: string message?: string } @@ -21,35 +24,47 @@ export const PARTNER_TO_NAME = { [MANAGED_BY.SUPABASE]: 'Supabase', } as const -function PartnerManagedResource({ partner, resource, cta }: PartnerManagedResourceProps) { - const isManagedBySupabase = partner === MANAGED_BY.SUPABASE +function PartnerManagedResource({ managedBy, resource, cta }: PartnerManagedResourceProps) { const ctaEnabled = cta !== undefined - const { data, isLoading, isError } = useVercelRedirectQuery( + // Use appropriate redirect query based on partner + const vercelQuery = useVercelRedirectQuery( { installationId: cta?.installationId, }, { - enabled: ctaEnabled && !isManagedBySupabase, + enabled: ctaEnabled && managedBy === MANAGED_BY.VERCEL_MARKETPLACE, } ) - if (isManagedBySupabase) return null + const awsQuery = useAwsRedirectQuery( + { + organizationSlug: cta?.organizationSlug, + }, + { + enabled: ctaEnabled && managedBy === MANAGED_BY.AWS_MARKETPLACE, + } + ) + + if (managedBy === MANAGED_BY.SUPABASE) return null + + const { data, isLoading, isError } = + managedBy === MANAGED_BY.VERCEL_MARKETPLACE ? vercelQuery : awsQuery const ctaUrl = (data?.url ?? '') + (cta?.path ?? '') return ( - + - {resource} are managed by {PARTNER_TO_NAME[partner]}. + {resource} are managed by {PARTNER_TO_NAME[managedBy]}. {ctaEnabled && ( )} diff --git a/apps/studio/data/integrations/aws-redirect-query.ts b/apps/studio/data/integrations/aws-redirect-query.ts new file mode 100644 index 0000000000000..e9a030cb258f2 --- /dev/null +++ b/apps/studio/data/integrations/aws-redirect-query.ts @@ -0,0 +1,38 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import type { ResponseError } from 'types' +import { integrationKeys } from './keys' +import { get, handleError } from 'data/fetchers' + +export type AwsRedirectVariables = { + organizationSlug?: string +} + +export async function getAwsRedirect( + { organizationSlug }: AwsRedirectVariables, + signal?: AbortSignal +) { + if (!organizationSlug) throw new Error('organizationSlug is required') + + const { data, error } = await get(`/platform/organizations/{slug}/cloud-marketplace/redirect`, { + params: { path: { slug: organizationSlug } }, + signal, + }) + if (error) handleError(error) + return data +} + +export type AwsRedirectData = Awaited> +export type AwsRedirectError = ResponseError + +export const useAwsRedirectQuery = ( + { organizationSlug }: AwsRedirectVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + integrationKeys.awsRedirect(organizationSlug), + ({ signal }) => getAwsRedirect({ organizationSlug }, signal), + { + enabled: enabled && typeof organizationSlug !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/integrations/keys.ts b/apps/studio/data/integrations/keys.ts index 1a86cfdfa407e..a90b6fbe3bdfd 100644 --- a/apps/studio/data/integrations/keys.ts +++ b/apps/studio/data/integrations/keys.ts @@ -25,4 +25,5 @@ export const integrationKeys = { githubConnectionsList: (organizationId: number | undefined) => ['organizations', organizationId, 'github-connections'] as const, vercelRedirect: (installationId?: string) => ['vercel-redirect', installationId] as const, + awsRedirect: (organizationSlug?: string) => ['aws-redirect', organizationSlug] as const, } diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index 420312bb5a8d2..ff03c622b784f 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -60,6 +60,7 @@ import { DEFAULT_MINIMUM_PASSWORD_STRENGTH, DEFAULT_PROVIDER, FLY_REGIONS_DEFAULT, + MANAGED_BY, PROJECT_STATUS, PROVIDERS, } from 'lib/constants' @@ -961,7 +962,7 @@ const Wizard: NextPageWithLayout = () => { ) : isManagedByVercel ? ( Date: Wed, 20 Aug 2025 09:27:58 +0200 Subject: [PATCH 3/5] =?UTF-8?q?doc:=20add=20Ra=C3=BAl=20Barroso=20(#38032)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 5e60cfc984c90..f80575a22d5b9 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -102,6 +102,7 @@ Peter Lyn Qiao Han Rafael Chacón Raminder Singh +Raúl Barroso Riccardo Busetti Rodrigo Mansueli Ronan Lehane From b68b2ebc4fc135dc0d20b43a2b169bbe55c4cb76 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 20 Aug 2025 15:37:55 +0800 Subject: [PATCH 4/5] Refactor access tokens to reuse existing DatePicker component (#38054) * Refactor access tokens to reuse existing DatePicker component * Clean up * Clean * Fixy --- .../Account/AccessTokens/AccessTokenList.tsx | 6 +- .../AccessTokens/NewAccessTokenDialog.tsx | 53 ++-- .../Account/AccessTokens/TokensDatePicker.tsx | 239 ------------------ .../Account/NewAccessTokenButton.tsx | 220 ---------------- .../components/ui/DatePicker/DatePicker.tsx | 31 +-- apps/studio/pages/account/tokens.tsx | 1 + 6 files changed, 35 insertions(+), 515 deletions(-) delete mode 100644 apps/studio/components/interfaces/Account/AccessTokens/TokensDatePicker.tsx delete 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 82de89e4f24e7..73db62030ffb7 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx @@ -163,7 +163,7 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo {dayjs(x.last_used_at).format('DD MMM YYYY')} - Last used on {dayjs(x.last_used_at).format('DD MMM, YYYY HH:mm')} + Last used on {dayjs(x.last_used_at).format('DD MMM, YYYY HH:mm:ss')} ) : ( @@ -179,7 +179,7 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo

Expired

- Expired on {dayjs(x.expires_at).format('DD MMM, YYYY HH:mm')} + Expired on {dayjs(x.expires_at).format('DD MMM, YYYY HH:mm:ss')} ) : ( @@ -190,7 +190,7 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo

- Expires on {dayjs(x.expires_at).format('DD MMM, YYYY HH:mm')} + Expires on {dayjs(x.expires_at).format('DD MMM, YYYY HH:mm:ss')} ) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenDialog.tsx b/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenDialog.tsx index c6946b0c2d7c9..7d6a15f57d6e5 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenDialog.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenDialog.tsx @@ -6,6 +6,7 @@ import { type SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod' +import { DatePicker } from 'components/ui/DatePicker' import { useAccessTokenCreateMutation } from 'data/access-tokens/access-tokens-create-mutation' import { Button, @@ -34,7 +35,6 @@ import { ExpiresAtOptions, NON_EXPIRING_TOKEN_VALUE, } from './AccessTokens.constants' -import { TokenDatePickerValue, TokensDatePicker } from './TokensDatePicker' const formId = 'new-access-token-form' @@ -59,9 +59,7 @@ export const NewAccessTokenDialog = ({ onOpenChange, onCreateToken, }: NewAccessTokenDialogProps) => { - const [customExpiryDate, setCustomExpiryDate] = useState( - undefined - ) + const [customExpiryDate, setCustomExpiryDate] = useState<{ date: string } | undefined>(undefined) const [isCustomExpiry, setIsCustomExpiry] = useState(false) const form = useForm>({ @@ -103,7 +101,7 @@ export const NewAccessTokenDialog = ({ if (value === CUSTOM_EXPIRY_VALUE) { setIsCustomExpiry(true) // Set a default custom date (today at 23:59:59) - const defaultCustomDate: TokenDatePickerValue = { + const defaultCustomDate = { date: dayjs().endOf('day').toISOString(), } setCustomExpiryDate(defaultCustomDate) @@ -115,7 +113,7 @@ export const NewAccessTokenDialog = ({ } } - const handleCustomDateChange = (value: TokenDatePickerValue) => { + const handleCustomDateChange = (value: { date: string }) => { setCustomExpiryDate(value) } @@ -212,37 +210,20 @@ export const NewAccessTokenDialog = ({ {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', + { + if (date.to) handleCustomDateChange({ date: date.to }) }} - /> + > + {customExpiryDate + ? `${dayjs(customExpiryDate.date).format('DD MMM, HH:mm')}` + : 'Select date'} + )}
{field.value === NON_EXPIRING_TOKEN_VALUE && ( diff --git a/apps/studio/components/interfaces/Account/AccessTokens/TokensDatePicker.tsx b/apps/studio/components/interfaces/Account/AccessTokens/TokensDatePicker.tsx deleted file mode 100644 index 04ac87be83049..0000000000000 --- a/apps/studio/components/interfaces/Account/AccessTokens/TokensDatePicker.tsx +++ /dev/null @@ -1,239 +0,0 @@ -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 ( - - - - - -
-
-
- -
-
-
- '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 deleted file mode 100644 index 3ed160fdfa2f9..0000000000000 --- a/apps/studio/components/interfaces/Account/NewAccessTokenButton.tsx +++ /dev/null @@ -1,220 +0,0 @@ -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/ui/DatePicker/DatePicker.tsx b/apps/studio/components/ui/DatePicker/DatePicker.tsx index 64feb2cf4dfbd..52a2825fbf894 100644 --- a/apps/studio/components/ui/DatePicker/DatePicker.tsx +++ b/apps/studio/components/ui/DatePicker/DatePicker.tsx @@ -1,7 +1,7 @@ import { format } from 'date-fns' import dayjs from 'dayjs' import { ArrowRight, Calendar, ChevronLeft, ChevronRight } from 'lucide-react' -import React, { useEffect, useState } from 'react' +import { ReactNode, useEffect, useState } from 'react' import ReactDatePicker from 'react-datepicker' import type { DatePickerToFrom } from 'components/interfaces/Settings/Logs/Logs.types' @@ -22,13 +22,15 @@ export interface DatePickerProps { triggerButtonType?: ButtonProps['type'] triggerButtonClassName?: string triggerButtonTitle?: string + triggerButtonSize?: 'tiny' | 'small' + contentSide?: 'bottom' | 'top' minDate?: Date maxDate?: Date hideTime?: boolean hideClear?: boolean selectsRange?: boolean - renderFooter?: (args: DatePickerToFrom) => React.ReactNode | void - children?: React.ReactNode | React.ReactNode[] | null + renderFooter?: (args: DatePickerToFrom) => ReactNode | void + children?: ReactNode | ReactNode[] | null } const START_DATE_DEFAULT = new Date() @@ -37,13 +39,15 @@ 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' } -function DatePicker({ +export function DatePicker({ to, from, onChange, triggerButtonType = 'default', triggerButtonClassName = '', triggerButtonTitle, + triggerButtonSize, + contentSide = 'bottom', minDate, maxDate, hideTime = false, @@ -147,6 +151,7 @@ function DatePicker({ title={triggerButtonTitle} type={triggerButtonType} icon={} + size={triggerButtonSize} className={triggerButtonClassName} > {children !== undefined ? ( @@ -171,14 +176,14 @@ function DatePicker({ )} - + <> {hideTime ? null : ( <> -
+
{!selectsRange ? null : ( <> -
+
-
+
)} -
+
Date: Wed, 20 Aug 2025 10:29:08 +0100 Subject: [PATCH 5/5] realtime: Improve documentation about message storage (#38039) Co-authored-by: Chris Chinchilla --- apps/docs/content/guides/realtime/broadcast.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/docs/content/guides/realtime/broadcast.mdx b/apps/docs/content/guides/realtime/broadcast.mdx index b81e65da6d4b2..fec239f875df5 100644 --- a/apps/docs/content/guides/realtime/broadcast.mdx +++ b/apps/docs/content/guides/realtime/broadcast.mdx @@ -366,6 +366,12 @@ This feature is in Public Beta. [Submit a support ticket](https://supabase.help) + + +All the messages sent using Broadcast from the Database are stored in `realtime.messages` table and will be deleted after 3 days. + + + You can send messages directly from your database using the `realtime.send()` function: {/* prettier-ignore */}