Merge branch 'master' of github.com:supabase/supabase
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
### 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!
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
## 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!
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
## The Prizes
|
||||
|
||||

|
||||
|
||||
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)
|
||||
|
After Width: | Height: | Size: 407 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 407 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 404 KiB |
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||