Merge branch 'master' of github.com:supabase/supabase

This commit is contained in:
Terry Sutton committed 2024-12-24 14:34:20 -03:30
commit b431afd14e
90 files changed
+1275 -537

No files matched your search

@@ -652,7 +652,7 @@ Broadcast Changes allows you to trigger messages from your database. To achieve
It uses partitioned tables per day which allows the deletion your previous images in a performant way by dropping the physical tables of this partitioned table. Tables older than 3 days old are deleted.
Broadcasting from the database works like a client-side broadcast, using WebSockets to send JSON packages. [Realtime Authorization]/docs/guides/realtime/authorization) is required and enabled by default to protect your data.
Broadcasting from the database works like a client-side broadcast, using WebSockets to send JSON packages. [Realtime Authorization](/docs/guides/realtime/authorization) is required and enabled by default to protect your data.
The database broadcast feature provides two functions to help you send messages:
@@ -4,7 +4,6 @@ import { useState } from 'react'
import { useAccessTokenCreateMutation } from 'data/access-tokens/access-tokens-create-mutation'
import {
Alert,
Button,
DialogFooter,
DropdownMenu,
@@ -15,6 +14,7 @@ import {
Input,
Modal,
} from 'ui'
import { Admonition } from 'ui-patterns'
export interface NewAccessTokenButtonProps {
onCreateToken: (token: any) => void
@@ -105,27 +105,30 @@ const NewAccessTokenButton = ({ onCreateToken }: NewAccessTokenButtonProps) => {
<>
{tokenScope === 'V0' && (
<Modal.Content>
<Alert
withIcon
variant="warning"
title="The experimental API provides additional endpoints which allows you to manage your organizations and projects."
>
<p>
These include deleting organizations and projects which cannot be undone. As
such, be very careful when using this API.
</p>
<div className="mt-4">
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href="https://api.supabase.com/api/v0"
target="_blank"
rel="noreferrer"
>
Experimental API documentation
</Link>
</Button>
</div>
</Alert>
<Admonition
type="warning"
title="The experimental API provides additional endpoints which allows you to manage
your organizations and projects."
description={
<>
<p>
These include deleting organizations and projects which cannot be undone.
As such, be very careful when using this API.
</p>
<div className="mt-4">
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href="https://api.supabase.com/api/v0"
target="_blank"
rel="noreferrer"
>
Experimental API documentation
</Link>
</Button>
</div>
</>
}
/>
</Modal.Content>
)}
<Modal.Content>
@@ -15,7 +15,6 @@ import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { BASE_PATH } from 'lib/constants'
import {
Alert,
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
@@ -29,6 +28,7 @@ import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants'
import type { Provider } from './AuthProvidersForm.types'
import FormField from './FormField'
import { Markdown } from 'components/interfaces/Markdown'
import { Admonition } from 'ui-patterns'
export interface ProviderFormProps {
config: components['schemas']['GoTrueConfigResponse']
@@ -264,9 +264,15 @@ const ProviderForm = ({ config, provider }: ProviderFormProps) => {
))}
{provider?.misc?.alert && (
<Alert title={provider.misc.alert.title} variant="warning" withIcon>
<ReactMarkdown>{provider.misc.alert.description}</ReactMarkdown>
</Alert>
<Admonition
type="warning"
title={provider.misc.alert.title}
description={
<>
<ReactMarkdown>{provider.misc.alert.description}</ReactMarkdown>
</>
}
/>
)}
{provider.misc.requiresRedirect && (
@@ -44,52 +44,49 @@ const InviteUserModal = ({ visible, setVisible }: InviteUserModalProps) => {
}
return (
<div>
<Modal
closable
hideFooter
size="small"
key="invite-user-modal"
visible={visible}
header="Invite a new user"
onCancel={handleToggle}
<Modal
hideFooter
size="small"
key="invite-user-modal"
visible={visible}
header="Invite a new user"
onCancel={handleToggle}
>
<Form
validateOnBlur={false}
initialValues={{ email: '' }}
validate={validate}
onSubmit={onInviteUser}
>
<Form
validateOnBlur={false}
initialValues={{ email: '' }}
validate={validate}
onSubmit={onInviteUser}
>
{() => (
<>
<Modal.Content>
<Input
id="email"
className="w-full"
label="User email"
icon={<Mail />}
type="email"
name="email"
placeholder="User email"
/>
</Modal.Content>
{() => (
<>
<Modal.Content>
<Input
id="email"
className="w-full"
label="User email"
icon={<Mail />}
type="email"
name="email"
placeholder="User email"
/>
</Modal.Content>
<Modal.Content>
<Button
block
size="small"
htmlType="submit"
loading={isInviting}
disabled={!canInviteUsers || isInviting}
>
Invite user
</Button>
</Modal.Content>
</>
)}
</Form>
</Modal>
</div>
<Modal.Content>
<Button
block
size="small"
htmlType="submit"
loading={isInviting}
disabled={!canInviteUsers || isInviting}
>
Invite user
</Button>
</Modal.Content>
</>
)}
</Form>
</Modal>
)
}
@@ -1,6 +1,9 @@
import { BASE_PATH } from 'lib/constants'
import { PROVIDER_PHONE, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation'
// [Joshen] Temporary fix as bulk delete will fire n requests since Auth + API do not have a bulk delete endpoint yet
export const MAX_BULK_DELETE = 20
export const PROVIDER_FILTER_OPTIONS = PROVIDERS_SCHEMAS.map((provider) => ({
name: provider.title,
value:
@@ -1,12 +1,12 @@
import dayjs from 'dayjs'
import { UserIcon } from 'lucide-react'
import { UIEvent } from 'react'
import { Column } from 'react-data-grid'
import { Column, useRowSelection } from 'react-data-grid'
import { User } from 'data/auth/users-infinite-query'
import { BASE_PATH } from 'lib/constants'
import { cn } from 'ui'
import { HeaderCell } from './UsersGridComponents'
import { Checkbox_Shadcn_, cn } from 'ui'
import { HeaderCell, SelectHeaderCell } from './UsersGridComponents'
import { ColumnConfiguration, USERS_TABLE_COLUMNS } from './UsersV2'
const SUPPORTED_CSP_AVATAR_URLS = [
@@ -262,10 +262,18 @@ export const formatUserColumns = ({
minWidth: col.minWidth ?? 120,
headerCellClass: 'z-50 outline-none !shadow-none',
renderHeaderCell: () => {
// [Joshen] I'm on the fence to support "Select all" for users, as the results are infinitely paginated
// "Select all" wouldn't be an accurate representation if not all the pages have been fetched, but if decide
// to support - the component is ready as such: Just pass selectedUsers and allRowsSelected as props from parent
// <SelectHeaderCell selectedUsers={selectedUsers} allRowsSelected={allRowsSelected} />
if (col.id === 'img') return undefined
return <HeaderCell col={col} setSortByValue={setSortByValue} />
},
renderCell: ({ row }) => {
// This is actually a valid React component, so we can use hooks here
// eslint-disable-next-line react-hooks/rules-of-hooks
const [isRowSelected, onRowSelectionChange] = useRowSelection()
const value = row?.[col.id]
const user = users?.find((u) => u.id === row.id)
const formattedValue =
@@ -278,7 +286,19 @@ export const formatUserColumns = ({
if (col.id === 'img') {
return (
<div className="flex items-center justify-center">
<div className="flex items-center justify-center gap-x-2">
<Checkbox_Shadcn_
checked={isRowSelected}
onClick={(e) => {
e.stopPropagation()
onRowSelectionChange({
row,
type: 'ROW',
checked: !isRowSelected,
isShiftClick: e.shiftKey,
})
}}
/>
<div
className={cn(
'flex items-center justify-center w-6 h-6 rounded-full bg-center bg-cover bg-no-repeat',
@@ -1,5 +1,6 @@
import { ChevronDown, SortAsc, SortDesc } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useRowSelection } from 'react-data-grid'
import {
Button,
DropdownMenu,
@@ -8,6 +9,40 @@ import {
DropdownMenuTrigger,
} from 'ui'
// [Joshen] Currently not being used, refer to Users.utils -> formatUserColumns for more info
export const SelectHeaderCell = ({
selectedUsers,
allRowsSelected,
}: {
selectedUsers: Set<any>
allRowsSelected: boolean
}) => {
const inputRef = useRef<HTMLInputElement>(null)
const [isRowSelected, onRowSelectionChange] = useRowSelection()
const isIndeterminate = selectedUsers.size > 0 && !allRowsSelected
useEffect(() => {
if (inputRef.current) inputRef.current.indeterminate = isIndeterminate
}, [isIndeterminate])
return (
<div className="px-4 flex items-center sb-grid">
<div className="sb-grid-select-cell__header">
<input
ref={inputRef}
type="checkbox"
aria-label="Select-all"
className="sb-grid-select-cell__header__input"
disabled={false}
checked={isRowSelected}
onChange={(e) => onRowSelectionChange({ type: 'HEADER', checked: e.target.checked })}
/>
</div>
</div>
)
}
export const HeaderCell = ({
col,
setSortByValue,
@@ -1,7 +1,9 @@
import { useQueryClient } from '@tanstack/react-query'
import AwesomeDebouncePromise from 'awesome-debounce-promise'
import { ArrowDown, ArrowUp, Loader2, RefreshCw, Search, Users, X } from 'lucide-react'
import { ArrowDown, ArrowUp, Loader2, RefreshCw, Search, Trash, Users, X } from 'lucide-react'
import { UIEvent, useEffect, useMemo, useRef, useState } from 'react'
import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
@@ -10,6 +12,8 @@ import AlertError from 'components/ui/AlertError'
import APIDocsButton from 'components/ui/APIDocsButton'
import { FilterPopover } from 'components/ui/FilterPopover'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { authKeys } from 'data/auth/keys'
import { useUserDeleteMutation } from 'data/auth/user-delete-mutation'
import { useUsersCountQuery } from 'data/auth/users-count-query'
import { useUsersInfiniteQuery } from 'data/auth/users-infinite-query'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
@@ -36,13 +40,15 @@ import {
SelectValue_Shadcn_,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import AddUserDropdown from './AddUserDropdown'
import { UserPanel } from './UserPanel'
import { PROVIDER_FILTER_OPTIONS } from './Users.constants'
import { MAX_BULK_DELETE, PROVIDER_FILTER_OPTIONS } from './Users.constants'
import { formatUserColumns, formatUsersData, isAtBottom } from './Users.utils'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
type Filter = 'all' | 'verified' | 'unverified' | 'anonymous'
export type Filter = 'all' | 'verified' | 'unverified' | 'anonymous'
export type UsersTableColumn = {
id: string
name: string
@@ -52,7 +58,7 @@ export type UsersTableColumn = {
}
export type ColumnConfiguration = { id: string; width?: number }
export const USERS_TABLE_COLUMNS: UsersTableColumn[] = [
{ id: 'img', name: '', minWidth: 65, width: 65, resizable: false },
{ id: 'img', name: '', minWidth: 95, width: 95, resizable: false },
{ id: 'id', name: 'UID', width: 280 },
{ id: 'name', name: 'Display name', minWidth: 0, width: 150 },
{ id: 'email', name: 'Email', width: 300 },
@@ -66,6 +72,7 @@ export const USERS_TABLE_COLUMNS: UsersTableColumn[] = [
// [Joshen] Just naming it as V2 as its a rewrite of the old one, to make it easier for reviews
// Can change it to remove V2 thereafter
export const UsersV2 = () => {
const queryClient = useQueryClient()
const { ref: projectRef } = useParams()
const { project } = useProjectContext()
const gridRef = useRef<DataGridHandle>(null)
@@ -75,10 +82,13 @@ export const UsersV2 = () => {
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [filterKeywords, setFilterKeywords] = useState('')
const [selectedUsers, setSelectedUsers] = useState<Set<any>>(new Set([]))
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
const [selectedProviders, setSelectedProviders] = useState<string[]>([])
const [selectedRow, setSelectedRow] = useState<number>()
const [selectedUser, setSelectedUser] = useState<string>()
const [sortByValue, setSortByValue] = useState<string>('created_at:desc')
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isDeletingUsers, setIsDeletingUsers] = useState(false)
const [
columnConfiguration,
setColumnConfiguration,
@@ -123,8 +133,12 @@ export const UsersV2 = () => {
providers: selectedProviders,
})
const { mutateAsync: deleteUser } = useUserDeleteMutation()
const totalUsers = countData ?? 0
const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
// [Joshen] Only relevant for when selecting one user only
const selectedUserToDelete = users.find((u) => u.id === [...selectedUsers][0])
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
if (isLoading || !isAtBottom(event)) return
@@ -169,6 +183,33 @@ export const UsersV2 = () => {
500
)
const handleDeleteUsers = async () => {
if (!projectRef) return console.error('Project ref is required')
const userIds = [...selectedUsers]
setIsDeletingUsers(true)
try {
await Promise.all(
userIds.map((id) => deleteUser({ projectRef, userId: id, skipInvalidation: true }))
)
// [Joshen] Skip invalidation within RQ to prevent multiple requests, then invalidate once at the end
await Promise.all([
queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)),
queryClient.invalidateQueries(authKeys.usersCount(projectRef)),
])
toast.success(
`Successfully deleted the selected ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}`
)
setShowDeleteModal(false)
setSelectedUsers(new Set([]))
if (userIds.includes(selectedUser)) setSelectedUser(undefined)
} catch (error: any) {
toast.error(`Failed to delete selected users: ${error.message}`)
setIsDeletingUsers(false)
}
}
useEffect(() => {
if (
!isRefetching &&
@@ -187,278 +228,346 @@ export const UsersV2 = () => {
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess, isRefetching, isSuccessStorage, isErrorStorage, errorStorage, users])
}, [
isSuccess,
isRefetching,
isSuccessStorage,
isErrorStorage,
errorStorage,
users,
selectedUsers,
])
return (
<div className="h-full flex flex-col">
<FormHeader className="py-4 px-6 !mb-0" title="Users" />
<div className="bg-surface-200 py-3 px-6 flex items-center justify-between border-t">
<div className="flex items-center gap-x-2">
<Input
size="tiny"
className="w-52 pl-7 bg-transparent"
iconContainerClassName="pl-2"
icon={<Search size={14} className="text-foreground-lighter" />}
placeholder="Search email, phone or UID"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.code === 'Enter') {
setSearch(search.trim())
setFilterKeywords(search.trim().toLocaleLowerCase())
}
}}
actions={[
search && (
<>
<div className="h-full flex flex-col">
<FormHeader className="py-4 px-6 !mb-0" title="Users" />
<div className="bg-surface-200 py-3 px-6 flex items-center justify-between border-t">
{selectedUsers.size > 0 ? (
<div className="flex items-center gap-x-2">
<Button type="default" icon={<Trash />} onClick={() => setShowDeleteModal(true)}>
Delete {selectedUsers.size} users
</Button>
<ButtonTooltip
type="default"
icon={<X />}
className="px-1.5"
onClick={() => setSelectedUsers(new Set([]))}
tooltip={{ content: { side: 'bottom', text: 'Cancel selection' } }}
/>
</div>
) : (
<>
<div className="flex items-center gap-x-2">
<Input
size="tiny"
className="w-52 pl-7 bg-transparent"
iconContainerClassName="pl-2"
icon={<Search size={14} className="text-foreground-lighter" />}
placeholder="Search email, phone or UID"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.code === 'Enter') {
setSearch(search.trim())
setFilterKeywords(search.trim().toLocaleLowerCase())
}
}}
actions={[
search && (
<Button
size="tiny"
type="text"
icon={<X />}
onClick={() => clearSearch()}
className="p-0 h-5 w-5"
/>
),
]}
/>
<Select_Shadcn_ value={filter} onValueChange={(val) => setFilter(val as Filter)}>
<SelectTrigger_Shadcn_
size="tiny"
className={cn('w-[140px] !bg-transparent', filter === 'all' && 'border-dashed')}
>
<SelectValue_Shadcn_ />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
<SelectItem_Shadcn_ value="all" className="text-xs">
All users
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="verified" className="text-xs">
Verified users
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="unverified" className="text-xs">
Unverified users
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="anonymous" className="text-xs">
Anonymous users
</SelectItem_Shadcn_>
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
<FilterPopover
name="Provider"
options={PROVIDER_FILTER_OPTIONS}
labelKey="name"
valueKey="value"
iconKey="icon"
activeOptions={selectedProviders}
labelClass="text-xs"
maxHeightClass="h-[190px]"
onSaveFilters={setSelectedProviders}
/>
<div className="border-r border-strong h-6" />
<FilterPopover
name={selectedColumns.length === 0 ? 'All columns' : 'Columns'}
title="Select columns to show"
buttonType={selectedColumns.length === 0 ? 'dashed' : 'default'}
options={USERS_TABLE_COLUMNS.slice(1)} // Ignore user image column
labelKey="name"
valueKey="id"
labelClass="text-xs"
maxHeightClass="h-[190px]"
clearButtonText="Reset"
activeOptions={selectedColumns}
onSaveFilters={(value) => {
// When adding back hidden columns:
// (1) width set to default value if any
// (2) they will just get appended to the end
// (3) If "clearing", reset order of the columns to original
let updatedConfig = (columnConfiguration ?? []).slice()
if (value.length === 0) {
updatedConfig = USERS_TABLE_COLUMNS.map((c) => ({ id: c.id, width: c.width }))
} else {
value.forEach((col) => {
const hasExisting = updatedConfig.find((c) => c.id === col)
if (!hasExisting)
updatedConfig.push({
id: col,
width: USERS_TABLE_COLUMNS.find((c) => c.id === col)?.width,
})
})
}
const updatedColumns = formatUserColumns({
config: updatedConfig,
users: users ?? [],
visibleColumns: value,
setSortByValue,
})
setSelectedColumns(value)
setColumns(updatedColumns)
saveColumnConfiguration('toggle', { columns: updatedColumns })
}}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={sortOrder === 'desc' ? <ArrowDown /> : <ArrowUp />}>
Sorted by {sortColumn.replaceAll('_', ' ')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-44" align="start">
<DropdownMenuRadioGroup value={sortByValue} onValueChange={setSortByValue}>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by created at</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioItem value="created_at:asc">
Ascending
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created_at:desc">
Descending
</DropdownMenuRadioItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by last sign in at</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioItem value="last_sign_in_at:asc">
Ascending
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="last_sign_in_at:desc">
Descending
</DropdownMenuRadioItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by email</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioItem value="email:asc">Ascending</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="email:desc">
Descending
</DropdownMenuRadioItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by phone</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioItem value="phone:asc">Ascending</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="phone:desc">
Descending
</DropdownMenuRadioItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-x-2">
{isNewAPIDocsEnabled && <APIDocsButton section={['user-management']} />}
<Button
size="tiny"
type="text"
icon={<X />}
onClick={() => clearSearch()}
className="p-0 h-5 w-5"
/>
),
]}
/>
<Select_Shadcn_ value={filter} onValueChange={(val) => setFilter(val as Filter)}>
<SelectTrigger_Shadcn_
size="tiny"
className={cn('w-[140px] !bg-transparent', filter === 'all' && 'border-dashed')}
>
<SelectValue_Shadcn_ />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
<SelectItem_Shadcn_ value="all" className="text-xs">
All users
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="verified" className="text-xs">
Verified users
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="unverified" className="text-xs">
Unverified users
</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="anonymous" className="text-xs">
Anonymous users
</SelectItem_Shadcn_>
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
<FilterPopover
name="Provider"
options={PROVIDER_FILTER_OPTIONS}
labelKey="name"
valueKey="value"
iconKey="icon"
activeOptions={selectedProviders}
labelClass="text-xs"
maxHeightClass="h-[190px]"
onSaveFilters={setSelectedProviders}
/>
<div className="border-r border-strong h-6" />
<FilterPopover
name={selectedColumns.length === 0 ? 'All columns' : 'Columns'}
title="Select columns to show"
buttonType={selectedColumns.length === 0 ? 'dashed' : 'default'}
options={USERS_TABLE_COLUMNS.slice(1)} // Ignore user image column
labelKey="name"
valueKey="id"
labelClass="text-xs"
maxHeightClass="h-[190px]"
clearButtonText="Reset"
activeOptions={selectedColumns}
onSaveFilters={(value) => {
// When adding back hidden columns:
// (1) width set to default value if any
// (2) they will just get appended to the end
// (3) If "clearing", reset order of the columns to original
let updatedConfig = (columnConfiguration ?? []).slice()
if (value.length === 0) {
updatedConfig = USERS_TABLE_COLUMNS.map((c) => ({ id: c.id, width: c.width }))
} else {
value.forEach((col) => {
const hasExisting = updatedConfig.find((c) => c.id === col)
if (!hasExisting)
updatedConfig.push({
id: col,
width: USERS_TABLE_COLUMNS.find((c) => c.id === col)?.width,
})
})
}
const updatedColumns = formatUserColumns({
config: updatedConfig,
users: users ?? [],
visibleColumns: value,
setSortByValue,
})
setSelectedColumns(value)
setColumns(updatedColumns)
saveColumnConfiguration('toggle', { columns: updatedColumns })
}}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={sortOrder === 'desc' ? <ArrowDown /> : <ArrowUp />}>
Sorted by {sortColumn.replaceAll('_', ' ')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-44" align="start">
<DropdownMenuRadioGroup value={sortByValue} onValueChange={setSortByValue}>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by created at</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioItem value="created_at:asc">Ascending</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created_at:desc">
Descending
</DropdownMenuRadioItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by last sign in at</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioItem value="last_sign_in_at:asc">
Ascending
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="last_sign_in_at:desc">
Descending
</DropdownMenuRadioItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by email</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioItem value="email:asc">Ascending</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="email:desc">Descending</DropdownMenuRadioItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by phone</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioItem value="phone:asc">Ascending</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="phone:desc">Descending</DropdownMenuRadioItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
icon={<RefreshCw />}
type="default"
loading={isRefetching && !isFetchingNextPage}
onClick={() => refetch()}
>
Refresh
</Button>
<AddUserDropdown />
</div>
</>
)}
</div>
<LoadingLine loading={isLoading || isRefetching || isFetchingNextPage} />
<ResizablePanelGroup
direction="horizontal"
className="relative flex flex-grow bg-alternative min-h-0"
autoSaveId="query-performance-layout-v1"
>
<ResizablePanel defaultSize={1}>
<div className="flex flex-col w-full h-full">
<DataGrid
ref={gridRef}
className="flex-grow border-t-0"
rowHeight={44}
headerRowHeight={36}
columns={columns}
rows={formatUsersData(users ?? [])}
rowClass={(row) => {
const isSelected = row.id === selectedUser
return [
`${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} cursor-pointer`,
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
'[&>.rdg-cell:first-child>div]:ml-4',
].join(' ')
}}
rowKeyGetter={(row) => row.id}
selectedRows={selectedUsers}
onScroll={handleScroll}
onSelectedRowsChange={(rows) => {
if (rows.size > MAX_BULK_DELETE) {
toast(`Only up to ${MAX_BULK_DELETE} users can be selected at a time`)
} else setSelectedUsers(rows)
}}
onColumnResize={(idx, width) => saveColumnConfiguration('resize', { idx, width })}
onColumnsReorder={(source, target) => {
const sourceIdx = columns.findIndex((col) => col.key === source)
const targetIdx = columns.findIndex((col) => col.key === target)
<div className="flex items-center gap-2">
{isNewAPIDocsEnabled && <APIDocsButton section={['user-management']} />}
<Button
size="tiny"
icon={<RefreshCw />}
type="default"
loading={isRefetching && !isFetchingNextPage}
onClick={() => refetch()}
>
Refresh
</Button>
<AddUserDropdown />
</div>
</div>
<LoadingLine loading={isLoading || isRefetching || isFetchingNextPage} />
<ResizablePanelGroup
direction="horizontal"
className="relative flex flex-grow bg-alternative min-h-0"
autoSaveId="query-performance-layout-v1"
>
<ResizablePanel defaultSize={1}>
<div className="flex flex-col w-full h-full">
<DataGrid
ref={gridRef}
className="flex-grow border-t-0"
rowHeight={44}
headerRowHeight={36}
columns={columns}
rows={formatUsersData(users ?? [])}
rowClass={(_, idx) => {
const isSelected = idx === selectedRow
return [
`${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} cursor-pointer`,
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
'[&>.rdg-cell:first-child>div]:ml-4',
].join(' ')
}}
onScroll={handleScroll}
onColumnResize={(idx, width) => saveColumnConfiguration('resize', { idx, width })}
onColumnsReorder={(source, target) => {
const sourceIdx = columns.findIndex((col) => col.key === source)
const targetIdx = columns.findIndex((col) => col.key === target)
const updatedColumns = swapColumns(columns, sourceIdx, targetIdx)
setColumns(updatedColumns)
const updatedColumns = swapColumns(columns, sourceIdx, targetIdx)
setColumns(updatedColumns)
saveColumnConfiguration('reorder', { columns: updatedColumns })
}}
renderers={{
renderRow(idx, props) {
return (
<Row
{...props}
key={props.row.id}
onClick={() => {
if (typeof idx === 'number' && idx >= 0) {
setSelectedRow(idx)
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
}
}}
/>
)
},
noRowsFallback: isLoading ? (
<div className="absolute top-14 px-6 w-full">
<GenericSkeletonLoader />
</div>
) : isError ? (
<div className="absolute top-14 px-6 flex flex-col items-center justify-center w-full">
<AlertError subject="Failed to retrieve users" error={error} />
</div>
) : (
<div className="absolute top-20 px-6 flex flex-col items-center justify-center w-full gap-y-2">
<Users className="text-foreground-lighter" strokeWidth={1} />
<div className="text-center">
<p className="text-foreground">
{filter !== 'all' || filterKeywords.length > 0
? 'No users found'
: 'No users in your project'}
</p>
<p className="text-foreground-light">
{filter !== 'all' || filterKeywords.length > 0
? 'There are currently no users based on the filters applied'
: 'There are currently no users who signed up to your project'}
</p>
saveColumnConfiguration('reorder', { columns: updatedColumns })
}}
renderers={{
renderRow(id, props) {
return (
<Row
{...props}
key={props.row.id}
onClick={() => {
const idx = users.indexOf(users.find((u) => u.id === id) ?? {})
if (props.row.id) {
setSelectedUser(props.row.id)
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
}
}}
/>
)
},
noRowsFallback: isLoading ? (
<div className="absolute top-14 px-6 w-full">
<GenericSkeletonLoader />
</div>
</div>
),
}}
) : isError ? (
<div className="absolute top-14 px-6 flex flex-col items-center justify-center w-full">
<AlertError subject="Failed to retrieve users" error={error} />
</div>
) : (
<div className="absolute top-20 px-6 flex flex-col items-center justify-center w-full gap-y-2">
<Users className="text-foreground-lighter" strokeWidth={1} />
<div className="text-center">
<p className="text-foreground">
{filter !== 'all' || filterKeywords.length > 0
? 'No users found'
: 'No users in your project'}
</p>
<p className="text-foreground-light">
{filter !== 'all' || filterKeywords.length > 0
? 'There are currently no users based on the filters applied'
: 'There are currently no users who signed up to your project'}
</p>
</div>
</div>
),
}}
/>
</div>
</ResizablePanel>
{selectedUser !== undefined && (
<UserPanel
selectedUser={users.find((u) => u.id === selectedUser)}
onClose={() => setSelectedUser(undefined)}
/>
</div>
</ResizablePanel>
{selectedRow !== undefined && (
<UserPanel
selectedUser={users?.[selectedRow]}
onClose={() => setSelectedRow(undefined)}
/>
)}
</ResizablePanelGroup>
)}
</ResizablePanelGroup>
<div className="flex justify-between min-h-9 h-9 overflow-hidden items-center px-6 w-full border-t text-xs text-foreground-light">
{isLoading || isRefetching ? 'Loading users...' : `Total: ${totalUsers} users`}
{(isLoading || isRefetching || isFetchingNextPage) && (
<span className="flex items-center gap-2">
<Loader2 size={14} className="animate-spin" /> Loading...
</span>
)}
<div className="flex justify-between min-h-9 h-9 overflow-hidden items-center px-6 w-full border-t text-xs text-foreground-light">
{isLoading || isRefetching ? 'Loading users...' : `Total: ${totalUsers} users`}
{(isLoading || isRefetching || isFetchingNextPage) && (
<span className="flex items-center gap-2">
<Loader2 size={14} className="animate-spin" /> Loading...
</span>
)}
</div>
</div>
</div>
<ConfirmationModal
visible={showDeleteModal}
variant="destructive"
title={`Confirm to delete ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}`}
loading={isDeletingUsers}
confirmLabel="Delete"
onCancel={() => setShowDeleteModal(false)}
onConfirm={() => handleDeleteUsers()}
alert={{
title: `Deleting ${selectedUsers.size === 1 ? 'a user' : 'users'} is irreversible`,
description: `This will remove the selected ${selectedUsers.size === 1 ? '' : `${selectedUsers.size} `}user${selectedUsers.size > 1 ? 's' : ''} from the project and all associated data.`,
}}
>
<p className="text-sm text-foreground-light">
This is permanent! Are you sure you want to delete the{' '}
{selectedUsers.size === 1 ? '' : `selected ${selectedUsers.size} `}user
{selectedUsers.size > 1 ? 's' : ''}
{selectedUsers.size === 1 ? (
<span className="text-foreground">
{' '}
{selectedUserToDelete?.email ?? selectedUserToDelete?.phone ?? 'this user'}
</span>
) : null}
?
</p>
</ConfirmationModal>
</>
)
}
@@ -268,7 +268,7 @@ const Connect = () => {
/>
<Panel.Notice
className="border border-t-0 rounded-lg rounded-t-none"
title="New API keys coming Q4 2024"
title="New API keys coming 2025"
description={`
\`anon\` and \`service_role\` API keys will be changing to \`publishable\` and \`secret\` API keys.
`}
@@ -43,13 +43,6 @@ export const ConfirmRestoreDialog = ({
<li>
Project region will stay the same: <code>{project?.region || ''}</code>
</li>
<li>
A project can only be restored to another project once. <br />
<span className="text-foreground-lighter text-xs">
This is a temporary limitation. Please contact us if you need to restore a project
to multiple other projects.
</span>
</li>
</ul>
<ul>
<li>What will be transferred?</li>
@@ -7,9 +7,10 @@ import Table from 'components/to-be-cleaned/Table'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-query'
import { Alert, Button, Input, SidePanel } from 'ui'
import { Button, Input, SidePanel } from 'ui'
import MigrationsEmptyState from './MigrationsEmptyState'
import { Search } from 'lucide-react'
import { Admonition } from 'ui-patterns'
const Migrations = () => {
const [search, setSearch] = useState('')
@@ -39,26 +40,27 @@ const Migrations = () => {
<div>
{isError && (
<Alert
withIcon
variant="warning"
<Admonition
type="warning"
title="Failed to retrieve migration history for database"
actions={[
<Button key="contact-support" asChild type="default" className="ml-4">
<Link
href={`/support/new?ref=${project?.ref}&category=dashboard_bug&subject=Unable%20to%20view%20database%20migrations`}
>
Contact support
</Link>
</Button>,
]}
description={
<>
<p className="mb-1">
Try refreshing your browser, but if the issue persists, please reach out to us via
support.
</p>
<p className="mb-4">Error: {(error as any)?.message ?? 'Unknown'}</p>
</>
}
>
<p className="mb-1">
Try refreshing your browser, but if the issue persists, please reach out to us via
support.
</p>
<p>Error: {(error as any)?.message ?? 'Unknown'}</p>
</Alert>
<Button key="contact-support" asChild type="default">
<Link
href={`/support/new?ref=${project?.ref}&category=dashboard_bug&subject=Unable%20to%20view%20database%20migrations`}
>
Contact support
</Link>
</Button>
</Admonition>
)}
{isSuccess && (
<div>
@@ -48,7 +48,7 @@ const Authentication = ({ selectedLang, showApiKey }: AuthenticationProps) => {
</div>
<h2 className="doc-heading">Client API Keys</h2>
<div className="doc-section ">
<div className="doc-section">
<article className="code-column text-foreground">
<p>
Client keys allow "anonymous access" to your database, until the user has logged in.
@@ -79,7 +79,7 @@ const Authentication = ({ selectedLang, showApiKey }: AuthenticationProps) => {
</div>
<h2 className="doc-heading">Service Keys</h2>
<div className="doc-section ">
<div className="doc-section">
<article className="code-column text-foreground">
<p>
Service keys have FULL access to your data, bypassing any security policies. Be VERY
@@ -2,6 +2,7 @@ import { useParams } from 'common'
import Snippets from 'components/interfaces/Docs/Snippets'
import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query'
import { InlineLink } from 'components/ui/InlineLink'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import CodeSnippet from './CodeSnippet'
import PublicSchemaNotEnabledAlert from './PublicSchemaNotEnabledAlert'
@@ -26,12 +27,26 @@ export default function Introduction({ selectedLang }: Props) {
.includes('public')
return (
<div className="doc-section doc-section--client-libraries">
<article className="code">
<CodeSnippet selectedLang={selectedLang} snippet={Snippets.init(endpoint)} />
{!isPublicSchemaEnabled && <PublicSchemaNotEnabledAlert />}
</article>
</div>
<>
<h2 className="doc-heading">Connect to your project</h2>
<div className="doc-section">
<article className="code-column text-foreground">
<p>
All projects have a RESTful endpoint that you can use with your project's API key to
query and manage your database. These can be obtained from the{' '}
<InlineLink href={`/project/${projectRef}/settings/api`}>API settings</InlineLink>.
</p>
<p>
You can initialize a new Supabase client using the <code>createClient()</code> method.
The Supabase client is your entrypoint to the rest of the Supabase functionality and is
the easiest way to interact with everything we offer within the Supabase ecosystem.
</p>
</article>
<article className="code">
<CodeSnippet selectedLang={selectedLang} snippet={Snippets.init(endpoint)} />
{!isPublicSchemaEnabled && <PublicSchemaNotEnabledAlert />}
</article>
</div>
</>
)
}
@@ -101,7 +101,7 @@ const EdgeFunctionDetails = () => {
return (
<>
<div className="space-y-4 pb-16">
<div className="space-y-4 pb-16 pt-3">
<Form id={formId} initialValues={{}} onSubmit={onUpdateFunction}>
{({ handleReset, values, initialValues, resetForm }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
@@ -36,7 +36,7 @@ const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) =
<Table.tr
key={item.id}
onClick={() => {
router.push(`/project/${ref}/functions/${item.slug}/details`)
router.push(`/project/${ref}/functions/${item.slug}`)
}}
>
<Table.td>
@@ -11,17 +11,18 @@ const FunctionsNav = ({ item }: any) => {
defaultActiveId="1"
type="underlined"
size="medium"
activeId={!activeRoute ? 'metrics' : activeRoute}
baseClassNames="!space-y-0"
activeId={!activeRoute ? 'overview' : activeRoute}
onChange={(e: string) => {
if (item?.slug) {
router.push(`/project/${ref}/functions/${item.slug}/${e === 'metrics' ? '' : e}`)
router.push(`/project/${ref}/functions/${item.slug}/${e === 'overview' ? '' : e}`)
}
}}
>
<Tabs.Panel id="details" label="Details" />
<Tabs.Panel id="metrics" label="Metrics" />
<Tabs.Panel id="overview" label="Overview" />
<Tabs.Panel id="invocations" label="Invocations" />
<Tabs.Panel id="logs" label="Logs" />
<Tabs.Panel id="details" label="Details" />
</Tabs>
)
}
@@ -1,4 +1,4 @@
import { toString as CronToString } from 'cronstrue'
import dayjs from 'dayjs'
import { Clock, History, Loader2, MoreVertical } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
@@ -7,21 +7,26 @@ import { useParams } from 'common'
import { SQLCodeBlock } from 'components/interfaces/Auth/ThirdPartyAuthForm/SqlCodeBlock'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-query'
import { useCronJobRunQuery } from 'data/database-cron-jobs/database-cron-jobs-run-query'
import { useDatabaseCronJobToggleMutation } from 'data/database-cron-jobs/database-cron-jobs-toggle-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { TelemetryActions } from 'lib/constants/telemetry'
import {
Badge,
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
Label_Shadcn_,
Switch,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { convertCronToString, getNextRun } from './CronJobs.utils'
interface CronJobCardProps {
job: CronJob
@@ -35,17 +40,18 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard
const [toggleConfirmationModalShown, showToggleConfirmationModal] = useState(false)
const { data } = useCronJobRunQuery({
projectRef: ref,
connectionString: selectedProject?.connectionString,
jobId: job.jobid,
})
const lastRun = data?.start_time ? dayjs(data.start_time).valueOf() : undefined
const nextRun = getNextRun(job.schedule, data?.start_time)
const schedule = convertCronToString(job.schedule)
const { mutate: sendEvent } = useSendEventMutation()
const { mutate: toggleDatabaseCronJob, isLoading } = useDatabaseCronJobToggleMutation()
// pg_cron can also use "30 seconds" format for schedule. Cronstrue doesn't understand that format so just use the
// original schedule when cronstrue throws
let schedule = job.schedule
try {
const scheduleString = CronToString(job.schedule)
schedule = scheduleString
} catch {}
return (
<>
<div className="bg-surface-100 border-default overflow-hidden border shadow px-5 py-4 flex flex-row rounded-md space-x-4">
@@ -120,12 +126,56 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard
<div className="grid grid-cols-10 gap-3 items-center">
<span className="text-foreground-light col-span-1">Schedule</span>
<div className="col-span-9">
<Input
title={schedule}
readOnly
className="input-mono [&>div>div>div>input]:text-xs [&>div>div>div>input]:opacity-100 flex-1"
value={schedule}
/>
<Input readOnly title={schedule} value={schedule} className="w-96" />
</div>
</div>
<div className="grid grid-cols-10 gap-3 items-center">
<span className="text-foreground-light col-span-1">Last run</span>
<div className="col-span-9">
<div
className={cn(
'border border-control bg-foreground/[0.026] rounded-md px-3 py-1.5 w-96',
!lastRun && 'text-foreground-lighter'
)}
>
{lastRun ? (
<>
<TimestampInfo
utcTimestamp={lastRun}
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
className="font-sans text-sm"
/>
{data?.status && (
<Badge variant="success" className="capitalize ml-2">
{data.status}
</Badge>
)}
</>
) : (
'Job has not been run yet'
)}
</div>
</div>
</div>
<div className="grid grid-cols-10 gap-3 items-center">
<span className="text-foreground-light col-span-1">Next run</span>
<div className="col-span-9">
<div
className={cn(
'border border-control bg-foreground/[0.026] rounded-md px-3 py-1.5 w-96',
!nextRun && 'text-foreground-lighter'
)}
>
{nextRun ? (
<TimestampInfo
utcTimestamp={nextRun}
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
className="font-sans text-sm"
/>
) : (
'Unable to parse next run for job'
)}
</div>
</div>
</div>
<div className="grid grid-cols-10 gap-3">
@@ -1,4 +1,6 @@
import parser from 'cron-parser'
import { toString as CronToString } from 'cronstrue'
import dayjs from 'dayjs'
import { CronJobType } from './CreateCronJobSheet'
import { HTTPHeader } from './CronJobs.constants'
@@ -190,3 +192,41 @@ export const formatScheduleString = (value: string) => {
return ''
}
}
export const convertCronToString = (schedule: string) => {
// pg_cron can also use "30 seconds" format for schedule. Cronstrue doesn't understand that format so just use the
// original schedule when cronstrue throws
try {
return CronToString(schedule)
} catch (error) {
return schedule
}
}
export const getNextRun = (schedule: string, lastRun?: string) => {
// cron-parser can only deal with the traditional cron syntax but technically users can also
// use strings like "30 seconds" now, For the latter case, we try our best to parse the next run
// (can't guarantee as scope is quite big)
if (schedule.includes('*')) {
try {
const interval = parser.parseExpression(schedule)
return interval.next().getTime()
} catch (error) {
return undefined
}
} else {
// [Joshen] Only going to attempt to parse if the schedule is as simple as "n seconds", "n minutes", or "n days"
// Returned undefined otherwise - we can revisit this perhaps if we get feedback about this
const [value, unit] = schedule.split(' ')
if (
['seconds', 'minutes', 'days'].includes(unit) &&
!Number.isNaN(Number(value)) &&
lastRun !== undefined
) {
const parsedLastRun = dayjs(lastRun).add(Number(value), unit as dayjs.ManipulateType)
return parsedLastRun.valueOf()
} else {
return undefined
}
}
}
@@ -14,7 +14,8 @@ import { usePgSodiumKeyDeleteMutation } from 'data/pg-sodium-keys/pg-sodium-key-
import { usePgSodiumKeysQuery } from 'data/pg-sodium-keys/pg-sodium-keys-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Key, Loader, Search, Trash, X } from 'lucide-react'
import { Alert, Button, Form, Input, Listbox, Modal, Separator } from 'ui'
import { Button, Form, Input, Listbox, Modal, Separator } from 'ui'
import { Admonition } from 'ui-patterns'
const DEFAULT_KEY_NAME = 'No description provided'
@@ -256,13 +257,12 @@ export const EncryptionKeysManagement = () => {
header="Confirm to delete key"
>
<Modal.Content className="space-y-4">
<Alert
withIcon
variant="warning"
title="Deleting a key that's in use will cause any secret or column which depends on it to be unusable."
>
Do ensure that the key is not currently in use to prevent any issues.
</Alert>
<Admonition
type="warning"
title="Deleting a key that's in use will cause any secret or column which depends on it to be
unusable."
description="Do ensure that the key is not currently in use to prevent any issues."
/>
<p className="text-sm">
The following key will be permanently removed and cannot be recovered.
</p>
@@ -1,9 +1,10 @@
import { useParams } from 'common'
import { toast } from 'sonner'
import { Alert, Button, Modal } from 'ui'
import { Button, Modal } from 'ui'
import { useOrganizationPaymentMethodDeleteMutation } from 'data/organizations/organization-payment-method-delete-mutation'
import type { OrganizationPaymentMethod } from 'data/organizations/organization-payment-methods-query'
import { Admonition } from 'ui-patterns'
export interface DeletePaymentMethodModalProps {
selectedPaymentMethod?: OrganizationPaymentMethod
@@ -55,9 +56,11 @@ const DeletePaymentMethodModal = ({
}
>
<Modal.Content>
<Alert withIcon variant="info" title="This will permanently delete your payment method.">
<p>You can re-add the payment method any time.</p>
</Alert>
<Admonition
type="default"
title="This will permanently delete your payment method."
description="You can re-add the payment method any time."
/>
</Modal.Content>
</Modal>
)
@@ -4,7 +4,8 @@ import { toast } from 'sonner'
import { useParams } from 'common'
import { useOAuthAppDeleteMutation } from 'data/oauth/oauth-app-delete-mutation'
import type { OAuthApp } from 'data/oauth/oauth-apps-query'
import { Alert, Modal } from 'ui'
import { Modal } from 'ui'
import { Admonition } from 'ui-patterns'
export interface DeleteAppModalProps {
selectedApp?: OAuthApp
@@ -37,10 +38,12 @@ const DeleteAppModal = ({ selectedApp, onClose }: DeleteAppModalProps) => {
onConfirm={onConfirmDelete}
>
<Modal.Content>
<Alert withIcon variant="warning" title="This action cannot be undone">
Deleting {selectedApp?.name} will invalidate any access tokens from this application that
were authorized by users.
</Alert>
<Admonition
type="warning"
title="This action cannot be undone"
description={`Deleting ${selectedApp?.name} will invalidate any access tokens from this application that
were authorized by users.`}
/>
</Modal.Content>
<Modal.Content>
<ul className="space-y-5">
@@ -1,10 +1,11 @@
import { useParams } from 'common'
import { Lock } from 'lucide-react'
import { toast } from 'sonner'
import { Alert, Modal } from 'ui'
import { Modal } from 'ui'
import { useAuthorizedAppRevokeMutation } from 'data/oauth/authorized-app-revoke-mutation'
import type { AuthorizedApp } from 'data/oauth/authorized-apps-query'
import { Admonition } from 'ui-patterns'
export interface RevokeAppModalProps {
selectedApp?: AuthorizedApp
@@ -37,10 +38,12 @@ const RevokeAppModal = ({ selectedApp, onClose }: RevokeAppModalProps) => {
onConfirm={onConfirmDelete}
>
<Modal.Content>
<Alert withIcon variant="warning" title="This action cannot be undone">
{selectedApp?.name} application will no longer have access to your organization's settings
and projects.
</Alert>
<Admonition
type="warning"
title="This action cannot be undone"
description={`${selectedApp?.name} application will no longer have access to your organization's settings
and projects.`}
/>
</Modal.Content>
<Modal.Content>
<ul className="space-y-5">
@@ -26,7 +26,6 @@ import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-co
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { uuidv4 } from 'lib/helpers'
import {
Alert,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
@@ -45,6 +44,7 @@ import {
JWT_SECRET_UPDATE_ERROR_MESSAGES,
JWT_SECRET_UPDATE_PROGRESS_MESSAGES,
} from './API.constants'
import { Admonition } from 'ui-patterns'
const JWTSettings = () => {
const { ref: projectRef } = useParams()
@@ -192,12 +192,12 @@ const JWTSettings = () => {
)}
</div>
{isJwtSecretUpdateFailed ? (
<Alert withIcon variant="warning" title="Failed to update JWT secret">
<Admonition type="warning" title="Failed to update JWT secret">
Please try again. If the failures persist, please contact Supabase support with
the following details: <br />
Change tracking ID: {data?.changeTrackingId} <br />
Error message: {jwtSecretUpdateErrorMessage}
</Alert>
</Admonition>
) : null}
</div>
</>
@@ -13,8 +13,9 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { formatCurrency } from 'lib/helpers'
import { useAddonsPagePanel } from 'state/addons-page'
import { Alert, AlertDescription_Shadcn_, Alert_Shadcn_, Button, Radio, SidePanel, cn } from 'ui'
import { ExternalLink, AlertTriangle } from 'lucide-react'
import { Button, Radio, SidePanel, cn } from 'ui'
import { ExternalLink } from 'lucide-react'
import { Admonition } from 'ui-patterns'
const IPv4SidePanel = () => {
const { ref: projectRef } = useParams()
@@ -187,10 +188,12 @@ const IPv4SidePanel = () => {
{hasChanges && (
<>
<Alert withIcon variant="info" title="Potential downtime">
There might be some downtime when enabling the add-on since some DNS clients might
have cached the old DNS entry. Generally, this should be less than a minute.
</Alert>
<Admonition
type="note"
title="Potential downtime"
description="There might be some downtime when enabling the add-on since some DNS clients might
have cached the old DNS entry. Generally, this should be less than a minute."
/>
{selectedOption !== 'ipv4_none' && (
<p className="text-sm text-foreground-light">
By default, this is only applied to the Primary database for your project. If{' '}
@@ -216,20 +219,14 @@ const IPv4SidePanel = () => {
)}
{isFreePlan && (
<Alert
withIcon
variant="info"
title="IPv4 add-on is unavailable on the Free Plan"
actions={
<Button asChild type="default">
<Link href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}>
View available plans
</Link>
</Button>
}
>
Upgrade your plan to enable a IPv4 address for your project
</Alert>
<Admonition type="note" title="IPv4 add-on is unavailable on the Free Plan">
<p>Upgrade your plan to enable a IPv4 address for your project</p>
<Button asChild type="default">
<Link href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}>
View available plans
</Link>
</Button>
</Admonition>
)}
</div>
</SidePanel.Content>
@@ -1,9 +1,10 @@
import { useParams } from 'common'
import { toast } from 'sonner'
import { Alert, Button, Modal } from 'ui'
import { Button, Modal } from 'ui'
import { useNetworkRestrictionsQuery } from 'data/network-restrictions/network-restrictions-query'
import { useNetworkRestrictionsApplyMutation } from 'data/network-restrictions/network-retrictions-apply-mutation'
import { Admonition } from 'ui-patterns'
interface RemoveRestrictionModalProps {
visible: boolean
@@ -59,7 +60,6 @@ const RemoveRestrictionModal = ({
return (
<Modal
closable
hideFooter
size="medium"
visible={visible}
@@ -75,10 +75,12 @@ const RemoveRestrictionModal = ({
: ", and no longer have access to your project's database."}
</p>
{isRemovingOnlyRestriction && (
<Alert withIcon variant="warning" title="Database access will no longer be restricted">
Removing all network restrictions will default to your database being accessible from
all IP addresses.
</Alert>
<Admonition
type="warning"
title="Database access will no longer be restricted"
description="Removing all network restrictions will default to your database being accessible from
all IP addresses."
/>
)}
</Modal.Content>
<Modal.Content className="flex items-center justify-end space-x-2">
@@ -92,15 +92,10 @@ const LogTable = ({
const { profile } = useProfile()
const { show: showContextMenu } = useContextMenu()
const downloadCsvRef = useRef<HTMLDivElement>(null)
const [cellPosition, setCellPosition] = useState<any>()
const [selectionOpen, setSelectionOpen] = useState(false)
useEffect(() => {
if (selectedLog || isSelectedLogLoading) {
setSelectionOpen(true)
}
}, [selectedLog, isSelectedLogLoading])
const [selectedRow, setSelectedRow] = useState<LogData | null>(null)
const canCreateLogQuery = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
resource: { type: 'log_sql', owner_id: profile?.id },
@@ -148,6 +143,7 @@ const LogTable = ({
})
let columns = DEFAULT_COLUMNS
if (!queryType) {
columns
} else {
@@ -235,21 +231,10 @@ const LogTable = ({
}
}
const copyResultsToClipboard = () => {
copyToClipboard(stringData, () => {
toast.success('Results copied to clipboard')
})
}
const downloadCsvRef = useRef<HTMLDivElement>(null)
function downloadCSV() {
downloadCsvRef.current?.click()
}
const LogsExplorerTableHeader = () => (
<div
className={cn(
'flex w-full items-center justify-between border-t bg-surface-100 px-5 py-2',
'flex w-full items-center justify-between border-t bg-surface-100 px-5 py-2',
className,
{ hidden: !showHeader }
)}
@@ -262,11 +247,23 @@ const LogTable = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={downloadCSV} className="space-x-2">
<DropdownMenuItem
onClick={() => {
downloadCsvRef.current?.click()
}}
className="space-x-2"
>
<Download size={14} />
<div>Download CSV</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={copyResultsToClipboard} className="space-x-2">
<DropdownMenuItem
onClick={() => {
copyToClipboard(stringData, () => {
toast.success('Results copied to clipboard')
})
}}
className="space-x-2"
>
<Clipboard size={14} />
<div>Copy to clipboard</div>
</DropdownMenuItem>
@@ -352,14 +349,22 @@ const LogTable = ({
else return <LogsTableEmptyState />
}
const [selectedRow, setSelectedRow] = useState<LogData | null>(null)
function onRowClick(row: LogData) {
setSelectedRow(row)
onSelectedLogChange?.(row)
}
useEffect(() => {
if (selectedLog || isSelectedLogLoading) {
setSelectionOpen(true)
}
if (!isSelectedLogLoading && !selectedLog) {
setSelectedRow(null)
}
}, [selectedLog, isSelectedLogLoading])
if (!data) return null
return (
<section className={'h-full flex w-full flex-col flex-1'}>
{!queryType && <LogsExplorerTableHeader />}
@@ -369,7 +374,7 @@ const LogTable = ({
<DataGrid
role="table"
style={{ height: '100%' }}
className={cn('flex-1 flex-grow h-full border-none', {
className={cn('flex-1 flex-grow h-full border-0', {
'data-grid--simple-logs': queryType,
'data-grid--logs-explorer': !queryType,
})}
@@ -350,6 +350,9 @@ export const SQL_FILTER_TEMPLATES: any = {
..._SQL_FILTER_COMMON,
database: (value: string) => `m.project like '${value}%'`,
},
pg_cron_logs: {
..._SQL_FILTER_COMMON,
},
}
export enum LogsTableName {
@@ -39,6 +39,7 @@ interface LogsPreviewerProps {
condensedLayout?: boolean
tableName?: LogsTableName
EmptyState?: React.ReactNode
filterPanelClassName?: string
}
export const LogsPreviewer = ({
projectRef,
@@ -48,6 +49,7 @@ export const LogsPreviewer = ({
tableName,
children,
EmptyState,
filterPanelClassName,
}: PropsWithChildren<LogsPreviewerProps>) => {
const router = useRouter()
const { s, ite, its, db } = useParams()
@@ -150,6 +152,7 @@ export const LogsPreviewer = ({
}
const handleSearch: LogSearchCallback = async (event, { query, to, from }) => {
if (event === 'search-input-change') {
setSelectedLogId(null)
setFilters((prev) => ({ ...prev, search_query: query }))
router.push({
pathname: router.pathname,
@@ -199,6 +202,7 @@ export const LogsPreviewer = ({
return (
<div className="flex-1 flex flex-col h-full">
<PreviewFilterPanel
className={filterPanelClassName}
csvData={logData}
isLoading={isLoading}
newCount={newCount}
@@ -1,13 +1,21 @@
import { Eye, EyeOff, RefreshCw, Search, Terminal } from 'lucide-react'
import { Eye, EyeOff, RefreshCw, Search, Terminal, X } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { Button, Input, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import CSVButton from 'components/ui/CSVButton'
import DatabaseSelector from 'components/ui/DatabaseSelector'
import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query'
import {
Button,
Input,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
Tooltip_Shadcn_,
cn,
} from 'ui'
import DatePickers from './Logs.DatePickers'
import {
FILTER_OPTIONS,
@@ -38,6 +46,7 @@ interface PreviewFilterPanelProps {
onFiltersChange: (filters: Filters) => void
filters: Filters
onSelectedDatabaseChange: (id: string) => void
className?: string
}
/**
@@ -61,6 +70,7 @@ const PreviewFilterPanel = ({
filters,
table,
onSelectedDatabaseChange,
className,
}: PreviewFilterPanelProps) => {
const router = useRouter()
const { ref } = useParams()
@@ -121,7 +131,13 @@ const PreviewFilterPanel = ({
const handleInputSearch = (query: string) => onSearch('search-input-change', { query })
return (
<div className={'flex w-full items-center justify-between' + (condensedLayout ? ' p-3' : '')}>
<div
className={cn(
'flex w-full items-center justify-between',
condensedLayout ? ' p-3' : '',
className
)}
>
<div className="flex flex-row items-center gap-x-2">
<form
id="log-panel-search"
@@ -147,14 +163,27 @@ const PreviewFilterPanel = ({
}
value={search}
actions={
hasEdits && (
<button
onClick={() => handleInputSearch(search)}
className="mx-2 text-foreground-light hover:text-foreground"
>
{'↲'}
</button>
)
<div className="flex items-center gap-x-1 mr-0.5">
{hasEdits && (
<ButtonTooltip
icon={<span>↲</span>}
type="text"
className="px-1 h-[20px]"
onClick={() => handleInputSearch(search)}
tooltip={{ content: { side: 'bottom', text: 'Search for events' } }}
/>
)}
{search.length > 0 && (
<ButtonTooltip
icon={<X />}
type="text"
className="p-[1px] h-[20px]"
onClick={() => handleInputSearch('')}
tooltip={{ content: { side: 'bottom', text: 'Clear search' } }}
/>
)}
</div>
}
/>
</form>
@@ -89,12 +89,12 @@ const FunctionsLayout = ({ title, children }: PropsWithChildren<FunctionsLayoutP
</h1>
</Link>
{name && (
<div className="mt-1.5 flex items-center space-x-4">
<div className="flex items-center space-x-4">
<span className="text-foreground-light">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
width="24"
height="24"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
@@ -105,7 +105,7 @@ const FunctionsLayout = ({ title, children }: PropsWithChildren<FunctionsLayoutP
<path d="M16 3.549L7.12 20.600"></path>
</svg>
</span>
<h5 className="text-lg text-foreground">{name}</h5>
<h1 className="text-2xl text-foreground">{name}</h1>
</div>
)}
</div>
@@ -132,7 +132,7 @@ const DisplayApiSettings = ({ legacy }: { legacy?: boolean }) => {
)}
<Panel.Notice
className="border-t"
title="New API keys coming Q4 2024"
title="New API keys coming 2025"
description={`
\`anon\` and \`service_role\` API keys will be changing to \`publishable\` and \`secret\` API keys.
`}
@@ -8,6 +8,7 @@ import { authKeys } from './keys'
export type UserDeleteVariables = {
projectRef: string
userId: string
skipInvalidation?: boolean
}
export async function deleteUser({ projectRef, userId }: UserDeleteVariables) {
@@ -34,12 +35,14 @@ export const useUserDeleteMutation = ({
(vars) => deleteUser(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
const { projectRef, skipInvalidation = false } = variables
await Promise.all([
queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)),
queryClient.invalidateQueries(authKeys.usersCount(projectRef)),
])
if (!skipInvalidation) {
await Promise.all([
queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)),
queryClient.invalidateQueries(authKeys.usersCount(projectRef)),
])
}
await onSuccess?.(data, variables, context)
},
@@ -0,0 +1,53 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { executeSql } from 'data/sql/execute-sql-query'
import { ResponseError } from 'types'
import { CronJobRun } from './database-cron-jobs-runs-infinite-query'
import { databaseCronJobsKeys } from './keys'
export type DatabaseCronJobRunVariables = {
projectRef?: string
connectionString?: string
jobId: number
}
export async function getDatabaseCronJobRun({
projectRef,
connectionString,
jobId,
}: DatabaseCronJobRunVariables) {
if (!projectRef) throw new Error('Project ref is required')
const query = `
SELECT * FROM cron.job_run_details
WHERE jobid = '${jobId}'
ORDER BY start_time DESC
LIMIT 1`
const { result } = await executeSql({
projectRef,
connectionString,
sql: query,
})
return result[0]
}
export type DatabaseCronJobRunData = CronJobRun
export type DatabaseCronJobRunError = ResponseError
export const useCronJobRunQuery = <TData = DatabaseCronJobRunData>(
{ projectRef, connectionString, jobId }: DatabaseCronJobRunVariables,
{
enabled = true,
...options
}: UseQueryOptions<DatabaseCronJobRunData, DatabaseCronJobRunError, TData> = {}
) =>
useQuery<DatabaseCronJobRunData, DatabaseCronJobRunError, TData>(
databaseCronJobsKeys.run(projectRef, jobId),
() => getDatabaseCronJobRun({ projectRef, connectionString, jobId }),
{
enabled: enabled && typeof projectRef !== 'undefined',
...options,
}
)
@@ -3,6 +3,13 @@ export const databaseCronJobsKeys = {
delete: () => ['cron-jobs', 'delete'] as const,
alter: () => ['cronjobs', 'alter'] as const,
list: (projectRef: string | undefined) => ['projects', projectRef, 'cron-jobs'] as const,
run: (projectRef: string | undefined, jobId: number) => [
'projects',
projectRef,
'cron-jobs',
jobId,
'run',
],
runsInfinite: (projectRef: string | undefined, jobId: number, options?: object) => [
'projects',
projectRef,
+1 -7
View File
@@ -461,13 +461,7 @@ const Wizard: NextPageWithLayout = () => {
render={({ field }) => (
<FormItemLayout label="Project name" layout="horizontal">
<FormControl_Shadcn_>
<Input_Shadcn_
placeholder="Project name"
{...field}
onChange={(event) => {
field.onChange(event.target.value.replace(/\./g, ''))
}}
/>
<Input_Shadcn_ {...field} placeholder="Project name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
@@ -151,7 +151,7 @@ const PageLayout: NextPageWithLayout = () => {
return <NoPermission isFullPage resourceText="access this edge function" />
}
return (
<div className="space-y-6 py-2">
<div className="space-y-6 py-3">
<div className="flex flex-row items-center gap-2">
<div className="flex items-center">
{CHART_INTERVALS.map((item, i) => {
@@ -19,9 +19,11 @@ export const LogPage: NextPageWithLayout = () => {
return (
<LogsPreviewer
condensedLayout
projectRef={ref as string}
queryType={'fn_edge'}
filterOverride={{ function_id: selectedFunction.id }}
filterPanelClassName="px-0"
/>
)
}
@@ -16,9 +16,11 @@ export const LogPage: NextPageWithLayout = () => {
return (
<LogsPreviewer
condensedLayout
projectRef={ref as string}
queryType={'functions'}
filterOverride={{ 'metadata.function_id': selectedFunction.id }}
filterPanelClassName="px-0"
/>
)
}
@@ -1,16 +1,5 @@
import { useParams } from 'common'
import { useState } from 'react'
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTrigger,
Modal,
} from 'ui'
import { Button, Dialog, DialogContent, DialogSection, DialogTrigger } from 'ui'
import {
EdgeFunctionsListItem,
@@ -26,7 +15,6 @@ import type { NextPageWithLayout } from 'types'
const PageLayout: NextPageWithLayout = () => {
const { ref } = useParams()
const [showTerminalInstructions, setShowTerminalInstructions] = useState(false)
const {
data: functions,
@@ -55,9 +43,7 @@ const PageLayout: NextPageWithLayout = () => {
} deployed`}</span>
<Dialog>
<DialogTrigger asChild>
<Button type="primary" onClick={() => setShowTerminalInstructions(true)}>
Deploy a new function
</Button>
<Button type="primary">Deploy a new function</Button>
</DialogTrigger>
<DialogContent size={'large'}>
<DialogSection padding="small">
@@ -10,7 +10,7 @@ categories:
tags:
- launch-week
- security
date: '2024-12-20'
date: '2024-12-19'
---
## **How to hack-the-base?**
@@ -0,0 +1,224 @@
---
title: 'AI Hackathon at Y Combinator'
description: Announcing the winners of the Supabase AI Hackathon.
author: craig_cannon
image: 2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-og.png
thumb: 2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-og.png
categories:
- hackathon
- AI
date: '2024-12-20'
toc_depth: 2
---
A few weeks ago we hosted an AI hackathon at [Y Combinator](https://www.ycombinator.com) in San Francisco.
Over 150 people flew in from all around the world, including Greece, Australia, Turkey, New Zealand, and South Africa. Students from many of the top universities in the US traveled to attend as well.
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-17.jpg"
/>
We kicked things off on Friday evening. Participants pitched their ideas, formed over 40 teams, and got to work. By the next afternoon there were 47 projects ready to demo.
Because there were so many projects, we split the teams into three tracks and had a semi-final round of judging. Supabase team members (Ant Wilson, Greg Papas, Long Hoang, Paul Copplestone, and Wen Bo Xie) and YC founders (Amadeo Pellicce, Benjamin Swerdlow, Michael Rosenfield, Nikhara Nirghin, Rahul Asati, and Rohan Das) judged the semi-finals.
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-5.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-3.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-23.jpg"
/>
Our semi-final judges selected 10 teams then we went next door to watch the presentations on stage. YC Partner [Jared Friedman](https://x.com/snowmaker), YC Visiting Partner [Eli Brown](https://x.com/iechoic), [Copple](https://x.com/kiwicopple), and [Ant](https://x.com/AntWilson) were there to judge the finals.
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-10.jpg"
/>
After watching all the demos, the judges awarded the following teams:
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-15.jpg"
/>
### **Overall Winner - Soshi**
[A Twitter assistant](https://www.linkedin.com/posts/elijahmuraoka_everyones-been-asking-me-how-we-won-the-activity-7268717109218619392-Fegu/) that browses and generates replies based on your niche to help you grow your Twitter following.
_Elijah Muraoka, Michael Long, Soumil Rathi, Veer Doshi_
### **Most Entertaining - Dropout**
Dropping out of college is a badge of honor for many founders. Use this app to find potential cofounders or hires that are still in college that you could convince to drop out.
_David Head, Kevin Fu, Jake Schwartz_
### **Most Technically Impressive - Explainstein**
Upload your homework and have a video call with [your AI Tutor](https://www.linkedin.com/posts/sricharanguddanti_this-demo-won-us-most-technically-impressive-activity-7267335808121004032-xymA/).
_Finn Metz, Sricharan Guddanti, Sveinung Myhre_
### **Most Novel Use of Supabase - Suparova**
[A Supabase-powered rover](https://www.linkedin.com/posts/kwkramer_team-suparova-at-the-supabase-ycombinator-activity-7266175490636296193-9ZzS/) that can speak, recognize faces, and, most importantly, dance!
_Kwindla Kramer, Simon Sturmer, Bassim Eledath_
---
We had a blast meeting everyone in the community and look forward to seeing you all at more events.
And we'd like to thank everyone that attended, [Anthropic](https://www.anthropic.com) for providing Claude credits to all the teams, and Y Combinator for hosting us. See you next time!
💚
---
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-1.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-2.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-4.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-6.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-7.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-8.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-9.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-11.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-12.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-13.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-14.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-16.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-18.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-19.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-20.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-21.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-22.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-24.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-25.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-26.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-27.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-28.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-29.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-30.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-31.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-32.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-33.jpg"
/>
<Img
alt="YC hackathon"
src="/images/blog/2024-12-20-ai-hackathon-at-y-combinator/yc-hackathon-34.jpg"
/>
@@ -0,0 +1,123 @@
---
title: 'Supabase Launch Week 13 Hackathon Winners'
description: Announcing the winners of the Supabase Launch Week 13 Hackathon.
author: tyler_shukert
image: launch-week-13/hackathon-winners/lw13-hackathon.png
thumb: launch-week-13/hackathon-winners/lw13-hackathon.png
launchweek: '13'
categories:
- launch-week
tags:
- launch-week
- hackathon
date: '2024-12-20'
toc_depth: 2
---
[Launch Week 13](https://supabase.com/launch-week) brought an incredible array of new Supabase features that got developers' creative engines revving. To harness this excitement, we challenged our amazing community with the Launch Week 13 Hackathon - and wow, did they deliver!
The submissions were truly impressive, showcasing exceptional technical skill and creativity. Our team thoroughly enjoyed reviewing each project, making it challenging to select winners from such a strong pool of entries. You can browse through all the submissions [here](https://www.hackathon.dev/lw13). For this hackathon, we're excited to have collaborated with StackBlitz to offer special prizes for projects built with bolt.new! Now, let's meet the winners!
## Best overall project
### Winner
[Brainrot GPT](https://github.com/tfrank11/brainrot-gpt-web) by [Timmy](https://twitter.com/timmyf111), and [Tiara](https://github.com/tjanuar)
Brainrot GPT uses AI to turn PDFs into short form video summaries. Perfect if you have a sub-30 second attention span.
![Brainrot GPT](/images/blog/launch-week-13/hackathon-winners/brainrot-gpt.png)
### Runner Up
[SupaSketch](https://github.com/swymbnsl/supasketch) by [Swayam bansal](https://twitter.com/swymbnsl), and [Anchal](https://twitter.com/AnchalGupta1117)
SupaSketch: Sketch and compete with friends in an AI and Supabase powered multiplayer drawing challenge!
![SupaSketch](/images/blog/launch-week-13/hackathon-winners/supasketch.png)
## Best use of AI
### Winner
[Stravation](https://github.com/aaronByrne1/Stravation) by [Aaron Byrne](https://github.com/aaronbyrne1)
AI-driven motivation, BLE heart rate tracking, & real-time connectivity to power your runs.
![Stravation](/images/blog/launch-week-13/hackathon-winners/stravation.png)
### Runner Up
[Kontra](https://github.com/Tammilore/ai-contract-analyzer) by [Tami](https://twitter.com/blacqee_)
AI-powered contract analysis tool that detects risky clauses and protects you from unfair legal agreements.
![Kontra](/images/blog/launch-week-13/hackathon-winners/kontra.png)
## Most fun / best easter egg
### Winner
[Gaze into the Abyss](https://github.com/laznic/gaze-into-the-abyss) by [Niklas Lepistö](https://twitter.com/laznic)
An experimental project where participants join virtual rooms and track each other's eye movements and blinks using
![Gaze into the Abyss](/images/blog/launch-week-13/hackathon-winners/gaze-into-the-abyss.png)
### Runner Up
[smart-style](https://github.com/JoseChipanaTica/smart-style) by [JoseChipana](https://twitter.com/josepaulct)
This project enables users to generate outfits based on their own clothing
![smart-style](/images/blog/launch-week-13/hackathon-winners/smart-style.png)
## Most visually pleasing
### Winner
[Munchwise](https://github.com/asrvd/munchwise) by [Ashish](https://twitter.com/_asheeshh)
Munchwise is an AI based calorie/meal tracking app, use natural language to track daily calorie & macro goals!
![Munchwise](/images/blog/launch-week-13/hackathon-winners/munchwise.png)
### Runner Up
[Students.fyi - We help people make better decisions](https://github.com/imprakashraghu/students.fyi) by [Prakash](https://twitter.com/imprakashraghu)
A consolidated view of an individual's career path where one could make decisions based on previous outcomes.
![Students.fyi](/images/blog/launch-week-13/hackathon-winners/students-fyi.png)
## Best project built with bolt.new
### Winner
[Screensaver](https://github.com/anishsrinivasan/screensaver) by [Anish Kumar Srinivasan](https://twitter.com/iamAnish), and [Zeeshan Rahman](https://github.com/zeeshan-ra)
A beautiful ambient video screensaver app featuring high-quality visuals, built with bolt.new and powered by Supabase.
![Screensaver](/images/blog/launch-week-13/hackathon-winners/screensaver.png)
### Runner Up
[Custom Sticker Designer](https://github.com/mmcspiritt/bolt-stickers) by [Matt McSpiritt](https://twitter.com/mmcspiritt)
An easy-to-use sticker designer made with Bolt, Supabase, Stripe, and Twilio Segment.
![Custom Sticker Designer](/images/blog/launch-week-13/hackathon-winners/custom-sticker-designer.png)
## The Prizes
![bolt.new hoodie](/images/blog/launch-week-13/hackathon/bolt-hoodie.jpeg)
Each winner and runner-up will receive a Supabase swag kit, and the winner and the runner-up of the best bolt.new project will receive a bolt.new hoodie.
## Getting Started Guides
If you're inspired to build, check out some of the latest resources:
- [Quick Start Guides](https://supabase.com/docs/guides/getting-started)
- [AI & Vectors Guide](https://supabase.com/docs/guides/ai)
- [Edge Functions Guide](https://supabase.com/docs/guides/functions)
Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

+36 -15
View File
@@ -8,11 +8,32 @@
<lastBuildDate>Fri, 20 Dec 2024 00:00:00 -0700</lastBuildDate>
<atom:link href="https://supabase.com/rss.xml" rel="self" type="application/rss+xml"/>
<item>
<guid>https://supabase.com/blog/ai-hackathon-at-y-combinator</guid>
<title>AI Hackathon at Y Combinator</title>
<link>https://supabase.com/blog/ai-hackathon-at-y-combinator</link>
<description>Announcing the winners of the Supabase AI Hackathon.</description>
<pubDate>Fri, 20 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/calendars-in-postgres-using-foreign-data-wrappers</guid>
<title>Calendars in Postgres using Foreign Data Wrappers</title>
<link>https://supabase.com/blog/calendars-in-postgres-using-foreign-data-wrappers</link>
<description>Calendar data integration with Cal.com using Wasm foreign data wrapper on Supabase</description>
<pubDate>Fri, 20 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/lw13-hackathon-winners</guid>
<title>Supabase Launch Week 13 Hackathon Winners</title>
<link>https://supabase.com/blog/lw13-hackathon-winners</link>
<description>Announcing the winners of the Supabase Launch Week 13 Hackathon.</description>
<pubDate>Fri, 20 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/how-to-hack-the-base</guid>
<title>How to Hack the Base!</title>
<link>https://supabase.com/blog/how-to-hack-the-base</link>
<description>Played cool games, won cool prizes.</description>
<pubDate>Fri, 20 Dec 2024 00:00:00 -0700</pubDate>
<pubDate>Thu, 19 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/durable-workflows-in-postgres-dbos</guid>
@@ -21,20 +42,6 @@
<description>Technical deep dive into the new DBOS integration for Supabase</description>
<pubDate>Tue, 10 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/hack-the-base</guid>
<title>Hack the Base! with Supabase</title>
<link>https://supabase.com/blog/hack-the-base</link>
<description>Play cool games, win cool prizes.</description>
<pubDate>Fri, 06 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/launch-week-13-top-10</guid>
<title>Top 10 Launches of Launch Week 13</title>
<link>https://supabase.com/blog/launch-week-13-top-10</link>
<description>Highlights from Launch Week 13</description>
<pubDate>Fri, 06 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/database-build-v2</guid>
<title>database.build v2: Bring-your-own-LLM</title>
@@ -49,6 +56,20 @@
<description>Effortlessly Clone Data into a New Supabase Project</description>
<pubDate>Fri, 06 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/hack-the-base</guid>
<title>Hack the Base! with Supabase</title>
<link>https://supabase.com/blog/hack-the-base</link>
<description>Play cool games, win cool prizes.</description>
<pubDate>Fri, 06 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/launch-week-13-top-10</guid>
<title>Top 10 Launches of Launch Week 13</title>
<link>https://supabase.com/blog/launch-week-13-top-10</link>
<description>Highlights from Launch Week 13</description>
<pubDate>Fri, 06 Dec 2024 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/supabase-queues</guid>
<title>Supabase Queues</title>
+1 -1
View File
@@ -27,7 +27,7 @@ const icons: Record<AlertVariant, React.ReactElement> = {
}
/**
* @deprecated Use Alert_Shadcn_
* @deprecated Use Alert_Shadcn_. For studio use Admonition
*/
export function Alert({
variant = 'neutral',