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 */} 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 diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx index d6ccd3fe1b0e9..73db62030ffb7 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:ss')} + + + ) : ( + 'Never used' + )} +

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

Expired

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

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

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

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..7d6a15f57d6e5 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenDialog.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenDialog.tsx @@ -1,9 +1,12 @@ 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' +import { DatePicker } from 'components/ui/DatePicker' import { useAccessTokenCreateMutation } from 'data/access-tokens/access-tokens-create-mutation' import { Button, @@ -18,13 +21,29 @@ 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' 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,27 @@ export const NewAccessTokenDialog = ({ onOpenChange, onCreateToken, }: NewAccessTokenDialogProps) => { + const [customExpiryDate, setCustomExpiryDate] = useState<{ date: string } | undefined>(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 +92,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 = { + date: dayjs().endOf('day').toISOString(), + } + setCustomExpiryDate(defaultCustomDate) + form.setValue('expiresAt', value) + } else { + setIsCustomExpiry(false) + setCustomExpiryDate(undefined) + form.setValue('expiresAt', value) + } + } + + const handleCustomDateChange = (value: { date: string }) => { + setCustomExpiryDate(value) + } + return ( { - if (!open) form.reset() + if (!open) { + form.reset() + setCustomExpiryDate(undefined) + setIsCustomExpiry(false) + } onOpenChange(open) }} > @@ -132,6 +188,55 @@ export const NewAccessTokenDialog = ({ )} /> + ( + +
+ + + + + + + {Object.values(ExpiresAtOptions).map((option) => ( + + {option.label} + + ))} + + + + {isCustomExpiry && ( + { + if (date.to) handleCustomDateChange({ date: date.to }) + }} + > + {customExpiryDate + ? `${dayjs(customExpiryDate.date).format('DD MMM, HH:mm')}` + : 'Select date'} + + )} +
+ {field.value === NON_EXPIRING_TOKEN_VALUE && ( +
+ + + Make sure to keep your non-expiring token safe and secure. + +
+ )} +
+ )} + /> @@ -141,6 +246,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')} + />
- +
} - /> + > + - + <> {hideTime ? null : ( <> -
+
{!selectsRange ? null : ( <> -
+
-
+
)} -
+
) => { 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/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/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/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/account/tokens.tsx b/apps/studio/pages/account/tokens.tsx index 89f6e22411178..953e898ab9eb4 100644 --- a/apps/studio/pages/account/tokens.tsx +++ b/apps/studio/pages/account/tokens.tsx @@ -32,7 +32,7 @@ const UserAccessTokens: NextPageWithLayout = () => {
- {newToken && } + {newToken && setNewToken(undefined)} />}
{ ) : isManagedByVercel ? (