mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 23:59:08 +03:00
Merge branch 'master' into chore/add-admonition-design-system
This commit is contained in:
commit
7aaad391f9
32 files changed
+939
-1015
No files matched your search
@@ -808,6 +808,61 @@ export const Index: Record<string, any> = {
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"radio-group-stacked-demo": {
|
||||
name: "radio-group-stacked-demo",
|
||||
type: "components:example",
|
||||
registryDependencies: ["radio-group"],
|
||||
component: React.lazy(() => import("@/registry/default/example/radio-group-stacked-demo")),
|
||||
source: "",
|
||||
files: ["registry/default/example/radio-group-stacked-demo.tsx"],
|
||||
category: "undefined",
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"radio-group-stacked-form": {
|
||||
name: "radio-group-stacked-form",
|
||||
type: "components:example",
|
||||
registryDependencies: ["radio-group","form"],
|
||||
component: React.lazy(() => import("@/registry/default/example/radio-group-stacked-form")),
|
||||
source: "",
|
||||
files: ["registry/default/example/radio-group-stacked-form.tsx"],
|
||||
category: "undefined",
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"radio-group-card-demo": {
|
||||
name: "radio-group-card-demo",
|
||||
type: "components:example",
|
||||
registryDependencies: ["radio-group"],
|
||||
component: React.lazy(() => import("@/registry/default/example/radio-group-card-demo")),
|
||||
source: "",
|
||||
files: ["registry/default/example/radio-group-card-demo.tsx"],
|
||||
category: "undefined",
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"radio-group-card-form": {
|
||||
name: "radio-group-card-form",
|
||||
type: "components:example",
|
||||
registryDependencies: ["radio-group","form"],
|
||||
component: React.lazy(() => import("@/registry/default/example/radio-group-card-form")),
|
||||
source: "",
|
||||
files: ["registry/default/example/radio-group-card-form.tsx"],
|
||||
category: "undefined",
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"radio-group-card-with-children": {
|
||||
name: "radio-group-card-with-children",
|
||||
type: "components:example",
|
||||
registryDependencies: ["radio-group","form"],
|
||||
component: React.lazy(() => import("@/registry/default/example/radio-group-card-with-children")),
|
||||
source: "",
|
||||
files: ["registry/default/example/radio-group-card-with-children.tsx"],
|
||||
category: "undefined",
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"resizable-demo": {
|
||||
name: "resizable-demo",
|
||||
type: "components:example",
|
||||
|
||||
@@ -136,8 +136,8 @@ const SourcePanel = forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement> &
|
||||
>
|
||||
<path d="M43.9022 20.0061H46.4499C46.2647 19.0375 46.17 18.1161 46.17 17.0058V12.3753C46.17 9.25687 44.3893 7.72127 41.1943 7.72127C38.3003 7.72127 36.3324 9.23324 36.0777 11.8083H38.9254C39.0181 10.698 39.8052 9.96561 41.1017 9.96561C42.4446 9.96561 43.3243 10.6743 43.3243 12.1391V12.7061L39.8052 13.1077C37.4206 13.3912 35.5684 14.3834 35.5684 16.7931C35.5684 18.9666 37.2353 20.2659 39.5274 20.2659C41.4027 20.2659 42.9845 19.4863 43.6401 18.1161C43.6689 18.937 43.9022 20.0061 43.9022 20.0061ZM40.3377 18.1634C39.157 18.1634 38.5087 17.5727 38.5087 16.6278C38.5087 15.3757 39.4579 15.0922 40.7082 14.9268L43.3243 14.6197V15.352C43.3243 17.242 41.8658 18.1634 40.3377 18.1634ZM56.2588 20.0061H59.176V3H56.2125V9.96561C55.6569 8.76075 54.3141 7.72127 52.4851 7.72127C49.3058 7.72127 47.099 10.2963 47.099 14.0054C47.099 17.7381 49.3058 20.2896 52.4851 20.2896C54.2678 20.2896 55.68 19.2973 56.2588 18.0925V20.0061ZM56.282 14.218C56.282 16.5569 55.1938 18.0689 53.3185 18.0689C51.3969 18.0689 50.1856 16.486 50.1856 14.0054C50.1856 11.5485 51.3969 9.94198 53.3185 9.94198C55.1938 9.94198 56.282 11.454 56.282 13.7928V14.218ZM60.9066 5.97304H64.0553V3.01996H60.9066V5.97304ZM60.9992 20.0061H63.9627V8.00476H60.9992V20.0061ZM67.6638 20.0061L70.6041 15.8954L73.5212 20.0061H76.9246L72.3636 13.7219L76.5542 8.00476H73.3823L70.7661 11.7138L68.1731 8.00476H64.7697L69.0066 13.8637L64.4919 20.0061H67.6638Z"></path>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M24.9132 20V14.0168H28.7986L32.4513 20H35.7006L31.6894 13.5686C33.5045 12.986 35.0955 11.507 35.0955 9.01961C35.0955 5.7479 32.7994 4 28.9571 4H22V20H24.9132ZM24.9132 6.35294V11.6863H28.821C31.0395 11.6863 32.1599 10.7675 32.1599 9.01961C32.1599 7.27171 30.9395 6.35294 28.621 6.35294H24.9132Z"
|
||||
></path>
|
||||
<path d="M7 23C3.13401 23 0 19.6422 0 15.5C0 11.3578 3.13401 8 7 8V23Z"></path>
|
||||
|
||||
@@ -264,6 +264,16 @@ export const docsConfig: DocsConfig = {
|
||||
href: '/docs/components/radio-group',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: 'Radio Group Stacked',
|
||||
href: '/docs/components/radio-group-stacked',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: 'Radio Group Card',
|
||||
href: '/docs/components/radio-group-card',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: 'Resizable',
|
||||
href: '/docs/components/resizable',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Radio Group Card
|
||||
description: A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time.
|
||||
component: true
|
||||
links:
|
||||
doc: https://www.radix-ui.com/docs/primitives/components/radio-group
|
||||
api: https://www.radix-ui.com/docs/primitives/components/radio-group#api-reference
|
||||
source:
|
||||
radix: true
|
||||
---
|
||||
|
||||
<ComponentPreview name="radio-group-card-demo" peekCode wide />
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { RadioGroupCard, RadioGroupCardItem } from 'ui'
|
||||
```
|
||||
|
||||
```tsx
|
||||
<RadioGroupCard defaultValue="comfortable">
|
||||
<RadioGroupCardItem value="default" id="r1" label="Default" />
|
||||
<RadioGroupCardItem value="comfortable" id="r2" label="Comfortable" />
|
||||
<RadioGroupCardItem value="compact" id="r3" label="Compact" />
|
||||
</RadioGroupCard>
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### With children
|
||||
|
||||
<ComponentPreview name="radio-group-card-with-children" />
|
||||
|
||||
### Form
|
||||
|
||||
<ComponentPreview name="radio-group-card-form" />
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
title: Radio Group Stacked
|
||||
description: A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time.
|
||||
component: true
|
||||
links:
|
||||
doc: https://www.radix-ui.com/docs/primitives/components/radio-group
|
||||
api: https://www.radix-ui.com/docs/primitives/components/radio-group#api-reference
|
||||
source:
|
||||
radix: true
|
||||
---
|
||||
|
||||
<ComponentPreview name="radio-group-stacked-demo" peekCode wide />
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { RadioGroupStacked, RadioGroupStackedItem } from 'ui'
|
||||
```
|
||||
|
||||
```tsx
|
||||
<RadioGroupStacked defaultValue="comfortable">
|
||||
<RadioGroupStackedItem value="default" id="r1" label="Default" />
|
||||
<RadioGroupStackedItem value="comfortable" id="r2" label="Comfortable" />
|
||||
<RadioGroupStackedItem value="compact" id="r3" label="Compact" />
|
||||
</RadioGroupStacked>
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Form
|
||||
|
||||
<ComponentPreview name="radio-group-stacked-form" />
|
||||
@@ -16,6 +16,20 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
...(process.env.NEXT_PUBLIC_BASE_PATH?.length
|
||||
? [
|
||||
{
|
||||
source: '/',
|
||||
destination: process.env.NEXT_PUBLIC_BASE_PATH,
|
||||
basePath: false,
|
||||
permanent: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
export default withContentlayer(nextConfig)
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn } from 'ui'
|
||||
// import { Icons } from '@/components/icons'
|
||||
import {
|
||||
NavigationMenu,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RadioGroupCard, RadioGroupCardItem } from 'ui'
|
||||
|
||||
export default function RadioGroupDemo() {
|
||||
return (
|
||||
<RadioGroupCard defaultValue="comfortable" className="flex flex-wrap gap-3">
|
||||
<RadioGroupCardItem value="default" id="r1" label="Default" />
|
||||
<RadioGroupCardItem value="comfortable" id="r2" label="Comfortable" />
|
||||
<RadioGroupCardItem value="compact" id="r3" label="Compact" />
|
||||
</RadioGroupCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Button, RadioGroupCard, RadioGroupCardItem } from 'ui'
|
||||
import {
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormItem_Shadcn_,
|
||||
FormLabel_Shadcn_,
|
||||
FormMessage_Shadcn_,
|
||||
} from 'ui'
|
||||
import { toast } from 'ui'
|
||||
|
||||
const FormSchema = z.object({
|
||||
type: z.enum(['all', 'mentions', 'none'], {
|
||||
required_error: 'You need to select a notification type.',
|
||||
}),
|
||||
})
|
||||
|
||||
export default function RadioGroupForm() {
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
})
|
||||
|
||||
function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
console.log(JSON.stringify(data, null, 2))
|
||||
toast({
|
||||
title: 'You submitted the following values:',
|
||||
description: (
|
||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
||||
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
||||
</pre>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="w-2/3 space-y-6">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_ className="space-y-4">
|
||||
<FormLabel_Shadcn_>Notify me about...</FormLabel_Shadcn_>
|
||||
<FormControl_Shadcn_>
|
||||
<RadioGroupCard
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-wrap gap-3"
|
||||
>
|
||||
<FormItem_Shadcn_ asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<RadioGroupCardItem value="all" label="All new messages" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItem_Shadcn_>
|
||||
<FormItem_Shadcn_ asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<RadioGroupCardItem value="mentions" label="Direct messages and mentions" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItem_Shadcn_>
|
||||
<FormItem_Shadcn_ asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<RadioGroupCardItem value="none" label="Nothing" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItem_Shadcn_>
|
||||
</RadioGroupCard>
|
||||
</FormControl_Shadcn_>
|
||||
<FormMessage_Shadcn_ />
|
||||
</FormItem_Shadcn_>
|
||||
)}
|
||||
/>
|
||||
<Button htmlType="submit" type="secondary" size="small">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { RadioGroupCard, RadioGroupCardItem } from 'ui'
|
||||
import SVG from 'react-inlinesvg'
|
||||
|
||||
export default function RadioGroupDemo() {
|
||||
const singleThemes = [
|
||||
{ name: 'Dark', value: 'dark' }, // Classic Supabase dark
|
||||
{ name: 'Deep Dark', value: 'deep-dark' }, // Deep Dark Supabase dark
|
||||
{ name: 'Light', value: 'light' }, // Classic Supabase light
|
||||
{ name: 'System', value: 'system' }, // Classic Supabase light
|
||||
] as const
|
||||
|
||||
return (
|
||||
<RadioGroupCard defaultValue="comfortable" className="flex flex-wrap gap-3">
|
||||
{singleThemes.map((theme) => (
|
||||
<RadioGroupCardItem key={theme.value} value={theme.value} label={theme.name}>
|
||||
<SVG src={`${process.env.NEXT_PUBLIC_BASE_PATH}/img/themes/${theme.value}.svg`} />
|
||||
</RadioGroupCardItem>
|
||||
))}
|
||||
</RadioGroupCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { RadioGroupStacked, RadioGroupStackedItem } from 'ui'
|
||||
|
||||
export default function RadioGroupDemo() {
|
||||
return (
|
||||
<RadioGroupStacked defaultValue="comfortable">
|
||||
<RadioGroupStackedItem
|
||||
value="default"
|
||||
id="r1"
|
||||
label="Default"
|
||||
description="The default option is the most spacious and comfortable."
|
||||
/>
|
||||
<RadioGroupStackedItem
|
||||
value="comfortable"
|
||||
id="r2"
|
||||
label="Comfortable"
|
||||
description="The comfortable option is a bit more compact than the default option."
|
||||
/>
|
||||
<RadioGroupStackedItem
|
||||
value="compact"
|
||||
id="r3"
|
||||
label="Compact"
|
||||
description="The compact option is the most compact and space-efficient."
|
||||
/>
|
||||
</RadioGroupStacked>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Button, RadioGroupStacked, RadioGroupStackedItem } from 'ui'
|
||||
import {
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormItem_Shadcn_,
|
||||
FormLabel_Shadcn_,
|
||||
FormMessage_Shadcn_,
|
||||
} from 'ui'
|
||||
import { toast } from 'ui'
|
||||
|
||||
const FormSchema = z.object({
|
||||
type: z.enum(['all', 'mentions', 'none'], {
|
||||
required_error: 'You need to select a notification type.',
|
||||
}),
|
||||
})
|
||||
|
||||
export default function RadioGroupForm() {
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
})
|
||||
|
||||
function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
console.log(JSON.stringify(data, null, 2))
|
||||
toast({
|
||||
title: 'You submitted the following values:',
|
||||
description: (
|
||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
||||
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
||||
</pre>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="w-2/3 space-y-6">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_ className="space-y-4">
|
||||
<FormLabel_Shadcn_>Notify me about...</FormLabel_Shadcn_>
|
||||
<FormControl_Shadcn_>
|
||||
<RadioGroupStacked onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormItem_Shadcn_ asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<RadioGroupStackedItem value="all" label="All new messages" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItem_Shadcn_>
|
||||
<FormItem_Shadcn_ asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<RadioGroupStackedItem
|
||||
value="mentions"
|
||||
label="Direct messages and mentions"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItem_Shadcn_>
|
||||
<FormItem_Shadcn_ asChild>
|
||||
<FormControl_Shadcn_>
|
||||
<RadioGroupStackedItem value="none" label="Nothing" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItem_Shadcn_>
|
||||
</RadioGroupStacked>
|
||||
</FormControl_Shadcn_>
|
||||
<FormMessage_Shadcn_ />
|
||||
</FormItem_Shadcn_>
|
||||
)}
|
||||
/>
|
||||
<Button htmlType="submit" type="secondary" size="small">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -521,6 +521,36 @@ export const examples: Registry = [
|
||||
registryDependencies: ['radio-group', 'form'],
|
||||
files: ['example/radio-group-form.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'radio-group-stacked-demo',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['radio-group'],
|
||||
files: ['example/radio-group-stacked-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'radio-group-stacked-form',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['radio-group', 'form'],
|
||||
files: ['example/radio-group-stacked-form.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'radio-group-card-demo',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['radio-group'],
|
||||
files: ['example/radio-group-card-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'radio-group-card-form',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['radio-group', 'form'],
|
||||
files: ['example/radio-group-card-form.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'radio-group-card-with-children',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['radio-group', 'form'],
|
||||
files: ['example/radio-group-card-with-children.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'resizable-demo',
|
||||
type: 'components:example',
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
|
||||
[data-rehype-pretty-code-fragment] [data-line-numbers] > .line::before {
|
||||
@apply text-zinc-50/40 text-xs;
|
||||
@apply text-foreground-muted text-xs;
|
||||
counter-increment: line;
|
||||
content: counter(line);
|
||||
display: inline-block;
|
||||
@@ -43,7 +43,11 @@
|
||||
}
|
||||
|
||||
[data-rehype-pretty-code-fragment] .line--highlighted {
|
||||
@apply bg-surface-200 dark:bg-surface-100;
|
||||
@apply relative bg-surface-200 dark:bg-surface-100;
|
||||
}
|
||||
|
||||
[data-rehype-pretty-code-fragment] .line--highlighted {
|
||||
@apply border-foreground-muted border-l-2;
|
||||
}
|
||||
|
||||
[data-rehype-pretty-code-fragment] .line-highlighted span {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Default",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"inlineSources": false,
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "node",
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -6726,20 +6726,19 @@ functions:
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```js
|
||||
supabase
|
||||
.channel('room1')
|
||||
.on('broadcast', { event: 'cursor-pos' }, payload => {
|
||||
console.log('Cursor position received!', payload)
|
||||
})
|
||||
.subscribe((status) => {
|
||||
if (status === 'SUBSCRIBED') {
|
||||
channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'cursor-pos',
|
||||
payload: { x: Math.random(), y: Math.random() },
|
||||
})
|
||||
}
|
||||
})
|
||||
const channel = supabase.channel("room1")
|
||||
|
||||
channel.on("broadcast", { event: "cursor-pos" }, (payload) => {
|
||||
console.log("Cursor position received!", payload);
|
||||
}).subscribe((status) => {
|
||||
if (status === "SUBSCRIBED") {
|
||||
channel.send({
|
||||
type: "broadcast",
|
||||
event: "cursor-pos",
|
||||
payload: { x: Math.random(), y: Math.random() },
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
- id: listen-to-presence-sync
|
||||
name: Listen to presence sync
|
||||
@@ -6909,6 +6908,10 @@ functions:
|
||||
}
|
||||
})
|
||||
```
|
||||
response: |
|
||||
```
|
||||
ok | timed out | error
|
||||
```
|
||||
- id: send-a-message-via-rest
|
||||
name: Send a message via REST
|
||||
isSpotlight: true
|
||||
@@ -6981,6 +6984,26 @@ functions:
|
||||
.storage
|
||||
.listBuckets()
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "avatars",
|
||||
"name": "avatars",
|
||||
"owner": "",
|
||||
"public": false,
|
||||
"file_size_limit": 1024,
|
||||
"allowed_mime_types": [
|
||||
"image/png"
|
||||
],
|
||||
"created_at": "2024-05-22T22:26:05.100Z",
|
||||
"updated_at": "2024-05-22T22:26:05.100Z"
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
- id: get-bucket
|
||||
title: getBucket()
|
||||
@@ -7000,6 +7023,24 @@ functions:
|
||||
.storage
|
||||
.getBucket('avatars')
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "avatars",
|
||||
"name": "avatars",
|
||||
"owner": "",
|
||||
"public": false,
|
||||
"file_size_limit": 1024,
|
||||
"allowed_mime_types": [
|
||||
"image/png"
|
||||
],
|
||||
"created_at": "2024-05-22T22:26:05.100Z",
|
||||
"updated_at": "2024-05-22T22:26:05.100Z"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
- id: create-bucket
|
||||
title: createBucket()
|
||||
@@ -7023,6 +7064,15 @@ functions:
|
||||
fileSizeLimit: 1024
|
||||
})
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"name": "avatars"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
- id: empty-bucket
|
||||
title: emptyBucket()
|
||||
@@ -7042,6 +7092,15 @@ functions:
|
||||
.storage
|
||||
.emptyBucket('avatars')
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"message": "Successfully emptied"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
- id: update-bucket
|
||||
title: updateBucket()
|
||||
$ref: '@supabase/storage-js.packages/StorageBucketApi.default.updateBucket'
|
||||
@@ -7064,6 +7123,15 @@ functions:
|
||||
fileSizeLimit: 1024
|
||||
})
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"message": "Successfully updated"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
- id: delete-bucket
|
||||
title: deleteBucket()
|
||||
@@ -7083,6 +7151,15 @@ functions:
|
||||
.storage
|
||||
.deleteBucket('avatars')
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"message": "Successfully deleted"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
- id: from-upload
|
||||
title: from.upload()
|
||||
@@ -7108,6 +7185,14 @@ functions:
|
||||
upsert: false
|
||||
})
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"path": "avatars/public/avatar1.png"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
- id: upload-file-using-arraybuffer-from-base64-file-data
|
||||
name: Upload file using `ArrayBuffer` from base64 file data
|
||||
code: |
|
||||
@@ -7146,6 +7231,15 @@ functions:
|
||||
upsert: true
|
||||
})
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"path": "avatars/public/avatar1.png"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
- id: update-file-using-arraybuffer-from-base64-file-data
|
||||
name: Update file using `ArrayBuffer` from base64 file data
|
||||
code: |
|
||||
@@ -7179,6 +7273,15 @@ functions:
|
||||
.from('avatars')
|
||||
.move('public/avatar1.png', 'private/avatar2.png')
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"message": "Successfully moved"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
- id: from-copy
|
||||
title: from.copy()
|
||||
@@ -7199,6 +7302,15 @@ functions:
|
||||
.from('avatars')
|
||||
.copy('public/avatar1.png', 'private/avatar2.png')
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"path": "avatars/private/avatar2.png"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
- id: from-create-signed-url
|
||||
title: from.createSignedUrl()
|
||||
@@ -7219,6 +7331,15 @@ functions:
|
||||
.from('avatars')
|
||||
.createSignedUrl('folder/avatar1.png', 60)
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"signedUrl": "https://example.supabase.co/storage/v1/object/sign/avatars/folder/avatar1.png?token=<TOKEN>"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
- id: create-signed-url-with-transformations
|
||||
name: Create a signed URL for an asset with transformations
|
||||
isSpotlight: true
|
||||
@@ -7266,6 +7387,26 @@ functions:
|
||||
.from('avatars')
|
||||
.createSignedUrls(['folder/avatar1.png', 'folder/avatar2.png'], 60)
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"error": null,
|
||||
"path": "folder/avatar1.png",
|
||||
"signedURL": "/object/sign/avatars/folder/avatar1.png?token=<TOKEN>",
|
||||
"signedUrl": "https://example.supabase.co/storage/v1/object/sign/avatars/folder/avatar1.png?token=<TOKEN>"
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"path": "folder/avatar2.png",
|
||||
"signedURL": "/object/sign/avatars/folder/avatar2.png?token=<TOKEN>",
|
||||
"signedUrl": "https://example.supabase.co/storage/v1/object/sign/avatars/folder/avatar2.png?token=<TOKEN>"
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
- id: from-create-signed-upload-url
|
||||
title: from.createSignedUploadUrl()
|
||||
@@ -7286,6 +7427,17 @@ functions:
|
||||
.from('avatars')
|
||||
.createSignedUploadUrl('folder/cat.jpg')
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"signedUrl": "https://example.supabase.co/storage/v1/object/upload/sign/avatars/folder/cat.jpg?token=<TOKEN>",
|
||||
"path": "folder/cat.jpg",
|
||||
"token": "<TOKEN>"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
- id: from-upload-to-signed-url
|
||||
title: from.uploadToSignedUrl()
|
||||
@@ -7306,6 +7458,16 @@ functions:
|
||||
.from('avatars')
|
||||
.uploadToSignedUrl('folder/cat.jpg', 'token-from-createSignedUploadUrl', file)
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"path": "folder/cat.jpg",
|
||||
"fullPath": "avatars/folder/cat.jpg"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
- id: from-get-public-url
|
||||
title: from.getPublicUrl()
|
||||
@@ -7327,6 +7489,14 @@ functions:
|
||||
.from('public-bucket')
|
||||
.getPublicUrl('folder/avatar1.png')
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"publicUrl": "https://example.supabase.co/storage/v1/object/public/public-bucket/folder/avatar1.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
- id: transform-asset-in-public-bucket
|
||||
name: Returns the URL for an asset in a public bucket with transformations
|
||||
isSpotlight: true
|
||||
@@ -7374,6 +7544,13 @@ functions:
|
||||
.from('avatars')
|
||||
.download('folder/avatar1.png')
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": <BLOB>,
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
- id: download-file-with-transformations
|
||||
name: Download file with transformations
|
||||
isSpotlight: true
|
||||
@@ -7410,6 +7587,13 @@ functions:
|
||||
.from('avatars')
|
||||
.remove(['folder/avatar1.png'])
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": [],
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
- id: from-list
|
||||
title: from.list()
|
||||
@@ -7434,6 +7618,30 @@ functions:
|
||||
sortBy: { column: 'name', order: 'asc' },
|
||||
})
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"name": "avatar1.png",
|
||||
"id": "e668cf7f-821b-4a2f-9dce-7dfa5dd1cfd2",
|
||||
"updated_at": "2024-05-22T23:06:05.580Z",
|
||||
"created_at": "2024-05-22T23:04:34.443Z",
|
||||
"last_accessed_at": "2024-05-22T23:04:34.443Z",
|
||||
"metadata": {
|
||||
"eTag": "\"c5e8c553235d9af30ef4f6e280790b92\"",
|
||||
"size": 32175,
|
||||
"mimetype": "image/png",
|
||||
"cacheControl": "max-age=3600",
|
||||
"lastModified": "2024-05-22T23:06:05.574Z",
|
||||
"contentLength": 32175,
|
||||
"httpStatusCode": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
- id: search-files-in-a-bucket
|
||||
name: Search files in a bucket
|
||||
code: |
|
||||
|
||||
@@ -4,7 +4,7 @@ import dayjs from 'dayjs'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IconAlertCircle, IconClock, Modal } from 'ui'
|
||||
import { IconAlertCircle, IconClock, AlertDescription_Shadcn_, Alert_Shadcn_ } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
@@ -18,6 +18,7 @@ import { useCheckPermissions, useSelectedOrganization } from 'hooks'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import BackupItem from './BackupItem'
|
||||
import BackupsEmpty from './BackupsEmpty'
|
||||
import BackupsStorageAlert from './BackupsStorageAlert'
|
||||
|
||||
const BackupsList = () => {
|
||||
const router = useRouter()
|
||||
@@ -88,6 +89,7 @@ const BackupsList = () => {
|
||||
title="You need additional permissions to trigger a scheduled backup"
|
||||
/>
|
||||
)}
|
||||
<BackupsStorageAlert />
|
||||
<Panel>
|
||||
{sortedBackups?.map((x, i: number) => {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AlertDescription_Shadcn_, Alert_Shadcn_ } from 'ui'
|
||||
import { WarningIcon } from 'ui-patterns/Icons/StatusIcons'
|
||||
|
||||
const BackupsStorageAlert = () => {
|
||||
return (
|
||||
<Alert_Shadcn_ variant="default">
|
||||
<WarningIcon />
|
||||
<AlertDescription_Shadcn_>
|
||||
Database backups do not include objects stored via the Storage API, as the database only
|
||||
includes metadata about these objects. Restoring an old backup does not restore objects that
|
||||
have been deleted since then.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
export default BackupsStorageAlert
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import PITRStatus from './PITRStatus'
|
||||
import TimeInput from './TimeInput'
|
||||
import { TimezoneSelection } from './TimezoneSelection'
|
||||
import BackupsStorageAlert from '../BackupsStorageAlert'
|
||||
|
||||
const PITRSelection = () => {
|
||||
const router = useRouter()
|
||||
@@ -138,6 +139,7 @@ const PITRSelection = () => {
|
||||
title="Restore your database from a backup"
|
||||
description="Database changes are watched and recorded, so that you can restore your database to any point in time"
|
||||
/>
|
||||
<BackupsStorageAlert />
|
||||
{hasNoBackupsAvailable ? (
|
||||
<BackupsEmpty />
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useFlag } from 'hooks'
|
||||
import useLatest from 'hooks/misc/useLatest'
|
||||
import { detectBrowser } from 'lib/helpers'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { canSendTelemetry } from 'lib/telemetry'
|
||||
import { AlertCircle, ExternalLink, HelpCircle, Loader2, Mail, Plus, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
@@ -79,9 +78,7 @@ const SupportForm = ({ setSentCategory, setSelectedProject }: SupportFormProps)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to submit support ticket: ${error.message}`)
|
||||
if (canSendTelemetry()) {
|
||||
Sentry.captureMessage('Failed to submit Support Form: ' + error.message)
|
||||
}
|
||||
Sentry.captureMessage('Failed to submit Support Form: ' + error.message)
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/nextjs'
|
||||
import { API_URL, IS_PLATFORM } from 'lib/constants'
|
||||
import { getAccessToken } from 'lib/gotrue'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { canSendTelemetry } from 'lib/telemetry'
|
||||
import createClient from 'openapi-fetch'
|
||||
import type { paths } from './api' // generated from openapi-typescript
|
||||
|
||||
@@ -161,13 +160,9 @@ export const handleError = (error: any): never => {
|
||||
}
|
||||
|
||||
console.error(error.stack)
|
||||
if (canSendTelemetry()) {
|
||||
// the error doesn't have a message or msg property, so we can't throw it as an error. Log it via Sentry so that we can
|
||||
// add handling for it.
|
||||
Sentry.captureMessage(
|
||||
`Unable to throw an object as error. The object has the following keys: ${Object.keys(error)}.`
|
||||
)
|
||||
}
|
||||
// the error doesn't have a message or msg property, so we can't throw it as an error. Log it via Sentry so that we can
|
||||
// add handling for it.
|
||||
Sentry.captureException(error)
|
||||
|
||||
// throw a generic error if we don't know what the error is. The message is intentionally vague because it might show
|
||||
// up in the UI.
|
||||
|
||||
@@ -1,923 +1,42 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { executeSql } from '../sql/execute-sql-query'
|
||||
import { components } from 'api-types'
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import { ResponseError } from 'types'
|
||||
import { lintKeys } from './keys'
|
||||
import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query'
|
||||
|
||||
export const LINT_SQL = /* SQL */ `set local search_path = '';
|
||||
|
||||
(
|
||||
with foreign_keys as (
|
||||
select
|
||||
cl.relnamespace::regnamespace::text as schema_name,
|
||||
cl.relname as table_name,
|
||||
cl.oid as table_oid,
|
||||
ct.conname as fkey_name,
|
||||
ct.conkey as col_attnums
|
||||
from
|
||||
pg_catalog.pg_constraint ct
|
||||
join pg_catalog.pg_class cl -- fkey owning table
|
||||
on ct.conrelid = cl.oid
|
||||
left join pg_catalog.pg_depend d
|
||||
on d.objid = cl.oid
|
||||
and d.deptype = 'e'
|
||||
where
|
||||
ct.contype = 'f' -- foreign key constraints
|
||||
and d.objid is null -- exclude tables that are dependencies of extensions
|
||||
and cl.relnamespace::regnamespace::text not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions'
|
||||
)
|
||||
),
|
||||
index_ as (
|
||||
select
|
||||
pi.indrelid as table_oid,
|
||||
indexrelid::regclass as index_,
|
||||
string_to_array(indkey::text, ' ')::smallint[] as col_attnums
|
||||
from
|
||||
pg_catalog.pg_index pi
|
||||
where
|
||||
indisvalid
|
||||
)
|
||||
select
|
||||
'unindexed_foreign_keys' as name,
|
||||
'Unindexed foreign keys' as title,
|
||||
'INFO' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['PERFORMANCE'] as categories,
|
||||
'Identifies foreign key constraints without a covering index, which can impact database performance.' as description,
|
||||
format(
|
||||
'Table \`%s.%s\` has a foreign key \`%s\` without a covering index. This can lead to suboptimal query performance.',
|
||||
fk.schema_name,
|
||||
fk.table_name,
|
||||
fk.fkey_name
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', fk.schema_name,
|
||||
'name', fk.table_name,
|
||||
'type', 'table',
|
||||
'fkey_name', fk.fkey_name,
|
||||
'fkey_columns', fk.col_attnums
|
||||
) as metadata,
|
||||
format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as cache_key
|
||||
from
|
||||
foreign_keys fk
|
||||
left join index_ idx
|
||||
on fk.table_oid = idx.table_oid
|
||||
and fk.col_attnums = idx.col_attnums
|
||||
left join pg_catalog.pg_depend dep
|
||||
on idx.table_oid = dep.objid
|
||||
and dep.deptype = 'e'
|
||||
where
|
||||
idx.index_ is null
|
||||
and fk.schema_name not in (
|
||||
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
and dep.objid is null -- exclude tables owned by extensions
|
||||
order by
|
||||
fk.schema_name,
|
||||
fk.table_name,
|
||||
fk.fkey_name)
|
||||
union all
|
||||
(
|
||||
select
|
||||
'auth_users_exposed' as name,
|
||||
'Exposed Auth Users' as title,
|
||||
'ERROR' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['SECURITY'] as categories,
|
||||
'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as description,
|
||||
format(
|
||||
'View/Materialized View "%s" in the public schema may expose \`auth.users\` data to anon or authenticated roles.',
|
||||
c.relname
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', n.nspname,
|
||||
'name', c.relname,
|
||||
'type', 'view',
|
||||
'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null)
|
||||
) as metadata,
|
||||
format('auth_users_exposed_%s_%s', n.nspname, c.relname) as cache_key
|
||||
from
|
||||
-- Identify the oid for auth.users
|
||||
pg_catalog.pg_class auth_users_pg_class
|
||||
join pg_catalog.pg_namespace auth_users_pg_namespace
|
||||
on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid
|
||||
and auth_users_pg_class.relname = 'users'
|
||||
and auth_users_pg_namespace.nspname = 'auth'
|
||||
-- Depends on auth.users
|
||||
join pg_catalog.pg_depend d
|
||||
on d.refobjid = auth_users_pg_class.oid
|
||||
join pg_catalog.pg_rewrite r
|
||||
on r.oid = d.objid
|
||||
join pg_catalog.pg_class c
|
||||
on c.oid = r.ev_class
|
||||
join pg_catalog.pg_namespace n
|
||||
on n.oid = c.relnamespace
|
||||
join pg_catalog.pg_class pg_class_auth_users
|
||||
on d.refobjid = pg_class_auth_users.oid
|
||||
where
|
||||
d.deptype = 'n'
|
||||
and (
|
||||
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
|
||||
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
|
||||
)
|
||||
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
|
||||
-- Exclude self
|
||||
and c.relname <> '0002_auth_users_exposed'
|
||||
-- There are 3 insecure configurations
|
||||
and
|
||||
(
|
||||
-- Materialized views don't support RLS so this is insecure by default
|
||||
(c.relkind in ('m')) -- m for materialized view
|
||||
or
|
||||
-- Standard View, accessible to anon or authenticated that is security_definer
|
||||
(
|
||||
c.relkind = 'v' -- v for view
|
||||
-- Exclude security invoker views
|
||||
and not (
|
||||
lower(coalesce(c.reloptions::text,'{}'))::text[]
|
||||
&& array[
|
||||
'security_invoker=1',
|
||||
'security_invoker=true',
|
||||
'security_invoker=yes',
|
||||
'security_invoker=on'
|
||||
]
|
||||
)
|
||||
)
|
||||
or
|
||||
-- Standard View, security invoker, but no RLS enabled on auth.users
|
||||
(
|
||||
c.relkind in ('v') -- v for view
|
||||
-- is security invoker
|
||||
and (
|
||||
lower(coalesce(c.reloptions::text,'{}'))::text[]
|
||||
&& array[
|
||||
'security_invoker=1',
|
||||
'security_invoker=true',
|
||||
'security_invoker=yes',
|
||||
'security_invoker=on'
|
||||
]
|
||||
)
|
||||
and not pg_class_auth_users.relrowsecurity
|
||||
)
|
||||
)
|
||||
group by
|
||||
n.nspname,
|
||||
c.relname,
|
||||
c.oid)
|
||||
union all
|
||||
(
|
||||
with policies as (
|
||||
select
|
||||
nsp.nspname as schema_name,
|
||||
pb.tablename as table_name,
|
||||
pc.relrowsecurity as is_rls_active,
|
||||
polname as policy_name,
|
||||
polpermissive as is_permissive, -- if not, then restrictive
|
||||
(select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles,
|
||||
case polcmd
|
||||
when 'r' then 'SELECT'
|
||||
when 'a' then 'INSERT'
|
||||
when 'w' then 'UPDATE'
|
||||
when 'd' then 'DELETE'
|
||||
when '*' then 'ALL'
|
||||
end as command,
|
||||
qual,
|
||||
with_check
|
||||
from
|
||||
pg_catalog.pg_policy pa
|
||||
join pg_catalog.pg_class pc
|
||||
on pa.polrelid = pc.oid
|
||||
join pg_catalog.pg_namespace nsp
|
||||
on pc.relnamespace = nsp.oid
|
||||
join pg_catalog.pg_policies pb
|
||||
on pc.relname = pb.tablename
|
||||
and nsp.nspname = pb.schemaname
|
||||
and pa.polname = pb.policyname
|
||||
)
|
||||
select
|
||||
'auth_rls_initplan' as name,
|
||||
'Auth RLS Initialization Plan' as title,
|
||||
'WARN' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['PERFORMANCE'] as categories,
|
||||
'Detects if calls to \`auth.<function>()\` in RLS policies are being unnecessarily re-evaluated for each row' as description,
|
||||
format(
|
||||
'Table \`%s.%s\` has a row level security policy \`%s\` that re-evaluates an auth.<function>() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \`auth.<function>()\` with \`(select auth.<function>())\`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.',
|
||||
schema_name,
|
||||
table_name,
|
||||
policy_name
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', schema_name,
|
||||
'name', table_name,
|
||||
'type', 'table'
|
||||
) as metadata,
|
||||
format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as cache_key
|
||||
from
|
||||
policies
|
||||
where
|
||||
is_rls_active
|
||||
and schema_name not in (
|
||||
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
and (
|
||||
-- Example: auth.uid()
|
||||
(
|
||||
qual like '%auth.uid()%'
|
||||
and lower(qual) not like '%select auth.uid()%'
|
||||
)
|
||||
or (
|
||||
qual like '%auth.jwt()%'
|
||||
and lower(qual) not like '%select auth.jwt()%'
|
||||
)
|
||||
or (
|
||||
qual like '%auth.role()%'
|
||||
and lower(qual) not like '%select auth.role()%'
|
||||
)
|
||||
or (
|
||||
qual like '%auth.email()%'
|
||||
and lower(qual) not like '%select auth.email()%'
|
||||
)
|
||||
or (
|
||||
with_check like '%auth.uid()%'
|
||||
and lower(with_check) not like '%select auth.uid()%'
|
||||
)
|
||||
or (
|
||||
with_check like '%auth.jwt()%'
|
||||
and lower(with_check) not like '%select auth.jwt()%'
|
||||
)
|
||||
or (
|
||||
with_check like '%auth.role()%'
|
||||
and lower(with_check) not like '%select auth.role()%'
|
||||
)
|
||||
or (
|
||||
with_check like '%auth.email()%'
|
||||
and lower(with_check) not like '%select auth.email()%'
|
||||
)
|
||||
))
|
||||
union all
|
||||
(
|
||||
select
|
||||
'no_primary_key' as name,
|
||||
'No Primary Key' as title,
|
||||
'INFO' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['PERFORMANCE'] as categories,
|
||||
'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as description,
|
||||
format(
|
||||
'Table \`%s.%s\` does not have a primary key',
|
||||
pgns.nspname,
|
||||
pgc.relname
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', pgns.nspname,
|
||||
'name', pgc.relname,
|
||||
'type', 'table'
|
||||
) as metadata,
|
||||
format(
|
||||
'no_primary_key_%s_%s',
|
||||
pgns.nspname,
|
||||
pgc.relname
|
||||
) as cache_key
|
||||
from
|
||||
pg_catalog.pg_class pgc
|
||||
join pg_catalog.pg_namespace pgns
|
||||
on pgns.oid = pgc.relnamespace
|
||||
left join pg_catalog.pg_index pgi
|
||||
on pgi.indrelid = pgc.oid
|
||||
left join pg_catalog.pg_depend dep
|
||||
on pgc.oid = dep.objid
|
||||
and dep.deptype = 'e'
|
||||
where
|
||||
pgc.relkind = 'r' -- regular tables
|
||||
and pgns.nspname not in (
|
||||
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
and dep.objid is null -- exclude tables owned by extensions
|
||||
group by
|
||||
pgc.oid,
|
||||
pgns.nspname,
|
||||
pgc.relname
|
||||
having
|
||||
max(coalesce(pgi.indisprimary, false)::int) = 0)
|
||||
union all
|
||||
(
|
||||
select
|
||||
'unused_index' as name,
|
||||
'Unused Index' as title,
|
||||
'INFO' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['PERFORMANCE'] as categories,
|
||||
'Detects if an index has never been used and may be a candidate for removal.' as description,
|
||||
format(
|
||||
'Index \`%s\` on table \`%s.%s\` has not been used',
|
||||
psui.indexrelname,
|
||||
psui.schemaname,
|
||||
psui.relname
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', psui.schemaname,
|
||||
'name', psui.relname,
|
||||
'type', 'table'
|
||||
) as metadata,
|
||||
format(
|
||||
'unused_index_%s_%s_%s',
|
||||
psui.schemaname,
|
||||
psui.relname,
|
||||
psui.indexrelname
|
||||
) as cache_key
|
||||
|
||||
from
|
||||
pg_catalog.pg_stat_user_indexes psui
|
||||
join pg_catalog.pg_index pi
|
||||
on psui.indexrelid = pi.indexrelid
|
||||
left join pg_catalog.pg_depend dep
|
||||
on psui.relid = dep.objid
|
||||
and dep.deptype = 'e'
|
||||
where
|
||||
psui.idx_scan = 0
|
||||
and not pi.indisunique
|
||||
and not pi.indisprimary
|
||||
and dep.objid is null -- exclude tables owned by extensions
|
||||
and psui.schemaname not in (
|
||||
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
))
|
||||
union all
|
||||
(
|
||||
select
|
||||
'multiple_permissive_policies' as name,
|
||||
'Multiple Permissive Policies' as title,
|
||||
'WARN' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['PERFORMANCE'] as categories,
|
||||
'Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as description,
|
||||
format(
|
||||
'Table \`%s.%s\` has multiple permissive policies for role \`%s\` for action \`%s\`. Policies include \`%s\`',
|
||||
n.nspname,
|
||||
c.relname,
|
||||
r.rolname,
|
||||
act.cmd,
|
||||
array_agg(p.polname order by p.polname)
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', n.nspname,
|
||||
'name', c.relname,
|
||||
'type', 'table'
|
||||
) as metadata,
|
||||
format(
|
||||
'multiple_permissive_policies_%s_%s_%s_%s',
|
||||
n.nspname,
|
||||
c.relname,
|
||||
r.rolname,
|
||||
act.cmd
|
||||
) as cache_key
|
||||
from
|
||||
pg_catalog.pg_policy p
|
||||
join pg_catalog.pg_class c
|
||||
on p.polrelid = c.oid
|
||||
join pg_catalog.pg_namespace n
|
||||
on c.relnamespace = n.oid
|
||||
join pg_catalog.pg_roles r
|
||||
on p.polroles @> array[r.oid]
|
||||
or p.polroles = array[0::oid]
|
||||
left join pg_catalog.pg_depend dep
|
||||
on c.oid = dep.objid
|
||||
and dep.deptype = 'e',
|
||||
lateral (
|
||||
select x.cmd
|
||||
from unnest((
|
||||
select
|
||||
case p.polcmd
|
||||
when 'r' then array['SELECT']
|
||||
when 'a' then array['INSERT']
|
||||
when 'w' then array['UPDATE']
|
||||
when 'd' then array['DELETE']
|
||||
when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE']
|
||||
else array['ERROR']
|
||||
end as actions
|
||||
)) x(cmd)
|
||||
) act(cmd)
|
||||
where
|
||||
c.relkind = 'r' -- regular tables
|
||||
and p.polpermissive -- policy is permissive
|
||||
and n.nspname not in (
|
||||
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
and r.rolname not like 'pg_%'
|
||||
and r.rolname not like 'supabase%admin'
|
||||
and not r.rolbypassrls
|
||||
and dep.objid is null -- exclude tables owned by extensions
|
||||
group by
|
||||
n.nspname,
|
||||
c.relname,
|
||||
r.rolname,
|
||||
act.cmd
|
||||
having
|
||||
count(1) > 1)
|
||||
union all
|
||||
(
|
||||
select
|
||||
'policy_exists_rls_disabled' as name,
|
||||
'Policy Exists RLS Disabled' as title,
|
||||
'ERROR' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['SECURITY'] as categories,
|
||||
'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as description,
|
||||
format(
|
||||
'Table \`%s.%s\` has RLS policies but RLS is not enabled on the table. Policies include %s.',
|
||||
n.nspname,
|
||||
c.relname,
|
||||
array_agg(p.polname order by p.polname)
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', n.nspname,
|
||||
'name', c.relname,
|
||||
'type', 'table'
|
||||
) as metadata,
|
||||
format(
|
||||
'policy_exists_rls_disabled_%s_%s',
|
||||
n.nspname,
|
||||
c.relname
|
||||
) as cache_key
|
||||
from
|
||||
pg_catalog.pg_policy p
|
||||
join pg_catalog.pg_class c
|
||||
on p.polrelid = c.oid
|
||||
join pg_catalog.pg_namespace n
|
||||
on c.relnamespace = n.oid
|
||||
left join pg_catalog.pg_depend dep
|
||||
on c.oid = dep.objid
|
||||
and dep.deptype = 'e'
|
||||
where
|
||||
c.relkind = 'r' -- regular tables
|
||||
and n.nspname not in (
|
||||
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
-- RLS is disabled
|
||||
and not c.relrowsecurity
|
||||
and dep.objid is null -- exclude tables owned by extensions
|
||||
group by
|
||||
n.nspname,
|
||||
c.relname)
|
||||
union all
|
||||
(
|
||||
select
|
||||
'rls_enabled_no_policy' as name,
|
||||
'RLS Enabled No Policy' as title,
|
||||
'INFO' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['SECURITY'] as categories,
|
||||
'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as description,
|
||||
format(
|
||||
'Table \`%s.%s\` has RLS enabled, but no policies exist',
|
||||
n.nspname,
|
||||
c.relname
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', n.nspname,
|
||||
'name', c.relname,
|
||||
'type', 'table'
|
||||
) as metadata,
|
||||
format(
|
||||
'rls_enabled_no_policy_%s_%s',
|
||||
n.nspname,
|
||||
c.relname
|
||||
) as cache_key
|
||||
from
|
||||
pg_catalog.pg_class c
|
||||
left join pg_catalog.pg_policy p
|
||||
on p.polrelid = c.oid
|
||||
join pg_catalog.pg_namespace n
|
||||
on c.relnamespace = n.oid
|
||||
left join pg_catalog.pg_depend dep
|
||||
on c.oid = dep.objid
|
||||
and dep.deptype = 'e'
|
||||
where
|
||||
c.relkind = 'r' -- regular tables
|
||||
and n.nspname not in (
|
||||
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
-- RLS is enabled
|
||||
and c.relrowsecurity
|
||||
and p.polname is null
|
||||
and dep.objid is null -- exclude tables owned by extensions
|
||||
group by
|
||||
n.nspname,
|
||||
c.relname)
|
||||
union all
|
||||
(
|
||||
select
|
||||
'duplicate_index' as name,
|
||||
'Duplicate Index' as title,
|
||||
'WARN' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['PERFORMANCE'] as categories,
|
||||
'Detects cases where two ore more identical indexes exist.' as description,
|
||||
format(
|
||||
'Table \`%s.%s\` has identical indexes %s. Drop all except one of them',
|
||||
n.nspname,
|
||||
c.relname,
|
||||
array_agg(pi.indexname order by pi.indexname)
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', n.nspname,
|
||||
'name', c.relname,
|
||||
'type', case
|
||||
when c.relkind = 'r' then 'table'
|
||||
when c.relkind = 'm' then 'materialized view'
|
||||
else 'ERROR'
|
||||
end,
|
||||
'indexes', array_agg(pi.indexname order by pi.indexname)
|
||||
) as metadata,
|
||||
format(
|
||||
'duplicate_index_%s_%s_%s',
|
||||
n.nspname,
|
||||
c.relname,
|
||||
array_agg(pi.indexname order by pi.indexname)
|
||||
) as cache_key
|
||||
from
|
||||
pg_catalog.pg_indexes pi
|
||||
join pg_catalog.pg_namespace n
|
||||
on n.nspname = pi.schemaname
|
||||
join pg_catalog.pg_class c
|
||||
on pi.tablename = c.relname
|
||||
and n.oid = c.relnamespace
|
||||
left join pg_catalog.pg_depend dep
|
||||
on c.oid = dep.objid
|
||||
and dep.deptype = 'e'
|
||||
where
|
||||
c.relkind in ('r', 'm') -- tables and materialized views
|
||||
and n.nspname not in (
|
||||
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
and dep.objid is null -- exclude tables owned by extensions
|
||||
group by
|
||||
n.nspname,
|
||||
c.relkind,
|
||||
c.relname,
|
||||
replace(pi.indexdef, pi.indexname, '')
|
||||
having
|
||||
count(*) > 1)
|
||||
union all
|
||||
(
|
||||
select
|
||||
'security_definer_view' as name,
|
||||
'Security Definer View' as title,
|
||||
'ERROR' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['SECURITY'] as categories,
|
||||
'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as description,
|
||||
format(
|
||||
'View \`%s.%s\` is defined with the SECURITY DEFINER property',
|
||||
n.nspname,
|
||||
c.relname
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', n.nspname,
|
||||
'name', c.relname,
|
||||
'type', 'view'
|
||||
) as metadata,
|
||||
format(
|
||||
'security_definer_view_%s_%s',
|
||||
n.nspname,
|
||||
c.relname
|
||||
) as cache_key
|
||||
from
|
||||
pg_catalog.pg_class c
|
||||
join pg_catalog.pg_namespace n
|
||||
on n.oid = c.relnamespace
|
||||
left join pg_catalog.pg_depend dep
|
||||
on c.oid = dep.objid
|
||||
and dep.deptype = 'e'
|
||||
where
|
||||
c.relkind = 'v'
|
||||
and (
|
||||
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
|
||||
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
|
||||
)
|
||||
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
|
||||
and n.nspname not in (
|
||||
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
and dep.objid is null -- exclude views owned by extensions
|
||||
and not (
|
||||
lower(coalesce(c.reloptions::text,'{}'))::text[]
|
||||
&& array[
|
||||
'security_invoker=1',
|
||||
'security_invoker=true',
|
||||
'security_invoker=yes',
|
||||
'security_invoker=on'
|
||||
]
|
||||
))
|
||||
union all
|
||||
(
|
||||
select
|
||||
'function_search_path_mutable' as name,
|
||||
'Function Search Path Mutable' as title,
|
||||
'WARN' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['SECURITY'] as categories,
|
||||
'Detects functions with a mutable search_path parameter which could fail to execute successfully for some roles.' as description,
|
||||
format(
|
||||
'Function \`%s.%s\` has a role mutable search_path',
|
||||
n.nspname,
|
||||
p.proname
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', n.nspname,
|
||||
'name', p.proname,
|
||||
'type', 'function'
|
||||
) as metadata,
|
||||
format(
|
||||
'function_search_path_mutable_%s_%s_%s',
|
||||
n.nspname,
|
||||
p.proname,
|
||||
md5(p.prosrc) -- required when function is polymorphic
|
||||
) as cache_key
|
||||
from
|
||||
pg_catalog.pg_proc p
|
||||
join pg_catalog.pg_namespace n
|
||||
on p.pronamespace = n.oid
|
||||
left join pg_catalog.pg_depend dep
|
||||
on p.oid = dep.objid
|
||||
and dep.deptype = 'e'
|
||||
where
|
||||
n.nspname not in (
|
||||
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
and dep.objid is null -- exclude functions owned by extensions
|
||||
-- Search path not set to ''
|
||||
and not coalesce(p.proconfig, '{}') && array['search_path=""'])
|
||||
union all
|
||||
(
|
||||
select
|
||||
'rls_disabled_in_public' as name,
|
||||
'RLS Disabled in Public' as title,
|
||||
'ERROR' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['SECURITY'] as categories,
|
||||
'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as description,
|
||||
format(
|
||||
'Table \`%s.%s\` is public, but RLS has not been enabled.',
|
||||
n.nspname,
|
||||
c.relname
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', n.nspname,
|
||||
'name', c.relname,
|
||||
'type', 'table'
|
||||
) as metadata,
|
||||
format(
|
||||
'rls_disabled_in_public_%s_%s',
|
||||
n.nspname,
|
||||
c.relname
|
||||
) as cache_key
|
||||
from
|
||||
pg_catalog.pg_class c
|
||||
join pg_catalog.pg_namespace n
|
||||
on c.relnamespace = n.oid
|
||||
where
|
||||
c.relkind = 'r' -- regular tables
|
||||
-- RLS is disabled
|
||||
and not c.relrowsecurity
|
||||
and (
|
||||
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
|
||||
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
|
||||
)
|
||||
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
|
||||
and n.nspname not in (
|
||||
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
))
|
||||
union all
|
||||
(
|
||||
select
|
||||
'extension_in_public' as name,
|
||||
'Extension in Public' as title,
|
||||
'WARN' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['SECURITY'] as categories,
|
||||
'Detects extensions installed in the \`public\` schema.' as description,
|
||||
format(
|
||||
'Extension \`%s\` is installed in the public schema. Move it to another schema.',
|
||||
pe.extname
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', pe.extnamespace::regnamespace,
|
||||
'name', pe.extname,
|
||||
'type', 'extension'
|
||||
) as metadata,
|
||||
format(
|
||||
'extension_in_public_%s',
|
||||
pe.extname
|
||||
) as cache_key
|
||||
from
|
||||
pg_catalog.pg_extension pe
|
||||
where
|
||||
-- plpgsql is installed by default in public and outside user control
|
||||
-- confirmed safe
|
||||
pe.extname not in ('plpgsql')
|
||||
-- Scoping this to public is not optimal. Ideally we would use the postgres
|
||||
-- search path. That currently isn't available via SQL. In other lints
|
||||
-- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that
|
||||
-- is not appropriate here as it would evaluate true for the extensions schema
|
||||
and pe.extnamespace::regnamespace::text = 'public')
|
||||
union all
|
||||
(
|
||||
with policies as (
|
||||
select
|
||||
nsp.nspname as schema_name,
|
||||
pb.tablename as table_name,
|
||||
polname as policy_name,
|
||||
qual,
|
||||
with_check
|
||||
from
|
||||
pg_catalog.pg_policy pa
|
||||
join pg_catalog.pg_class pc
|
||||
on pa.polrelid = pc.oid
|
||||
join pg_catalog.pg_namespace nsp
|
||||
on pc.relnamespace = nsp.oid
|
||||
join pg_catalog.pg_policies pb
|
||||
on pc.relname = pb.tablename
|
||||
and nsp.nspname = pb.schemaname
|
||||
and pa.polname = pb.policyname
|
||||
)
|
||||
select
|
||||
'rls_references_user_metadata' as name,
|
||||
'RLS references user metadata' as title,
|
||||
'ERROR' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['SECURITY'] as categories,
|
||||
'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as description,
|
||||
format(
|
||||
'Table \`%s.%s\` has a row level security policy \`%s\` that references Supabase Auth \`user_metadata\`. \`user_metadata\` is editable by end users and should never be used in a security context.',
|
||||
schema_name,
|
||||
table_name,
|
||||
policy_name
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', schema_name,
|
||||
'name', table_name,
|
||||
'type', 'table'
|
||||
) as metadata,
|
||||
format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as cache_key
|
||||
from
|
||||
policies
|
||||
where
|
||||
schema_name not in (
|
||||
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
and (
|
||||
-- Example: auth.jwt() -> 'user_metadata'
|
||||
-- False positives are possible, but it isn't practical to string match
|
||||
-- If false positive rate is too high, this expression can iterate
|
||||
qual like '%auth.jwt()%user_metadata%'
|
||||
or qual like '%current_setting(%request.jwt.claims%)%user_metadata%'
|
||||
or with_check like '%auth.jwt()%user_metadata%'
|
||||
or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%'
|
||||
))
|
||||
union all
|
||||
(
|
||||
select
|
||||
'materialized_view_in_api' as name,
|
||||
'Materialized View in API' as title,
|
||||
'WARN' as level,
|
||||
'EXTERNAL' as facing,
|
||||
array['SECURITY'] as categories,
|
||||
'Detects materialized views that are potentially accessible over the Data APIs.' as description,
|
||||
format(
|
||||
'Materialized view \`%s.%s\` is selectable by anon or authenticated roles',
|
||||
n.nspname,
|
||||
c.relname
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as remediation,
|
||||
jsonb_build_object(
|
||||
'schema', n.nspname,
|
||||
'name', c.relname,
|
||||
'type', 'materialized view'
|
||||
) as metadata,
|
||||
format(
|
||||
'materialized_view_in_api_%s_%s',
|
||||
n.nspname,
|
||||
c.relname
|
||||
) as cache_key
|
||||
from
|
||||
pg_catalog.pg_class c
|
||||
join pg_catalog.pg_namespace n
|
||||
on n.oid = c.relnamespace
|
||||
left join pg_catalog.pg_depend dep
|
||||
on c.oid = dep.objid
|
||||
and dep.deptype = 'e'
|
||||
where
|
||||
c.relkind = 'm'
|
||||
and (
|
||||
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
|
||||
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
|
||||
)
|
||||
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
|
||||
and n.nspname not in (
|
||||
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
|
||||
)
|
||||
and dep.objid is null)`.trim()
|
||||
|
||||
// Array of all lint rules we handle right now.
|
||||
export const LINT_TYPES = [
|
||||
'unindexed_foreign_keys',
|
||||
'auth_users_exposed',
|
||||
'auth_rls_initplan',
|
||||
'no_primary_key',
|
||||
'unused_index',
|
||||
'multiple_permissive_policies',
|
||||
'policy_exists_rls_disabled',
|
||||
'rls_enabled_no_policy',
|
||||
'duplicate_index',
|
||||
'security_definer_view',
|
||||
'function_search_path_mutable',
|
||||
'rls_disabled_in_public',
|
||||
'extension_in_public',
|
||||
'auth_otp_long_expiry',
|
||||
'auth_otp_short_length',
|
||||
'rls_references_user_metadata',
|
||||
] as const
|
||||
|
||||
export type LINT_TYPES = (typeof LINT_TYPES)[number]
|
||||
|
||||
export type Lint = {
|
||||
name: LINT_TYPES
|
||||
level: 'ERROR' | 'WARN' | 'INFO'
|
||||
facing: string
|
||||
categories: ['PERFORMANCE' | 'SECURITY']
|
||||
description: string
|
||||
detail: string
|
||||
remediation: any
|
||||
metadata: {
|
||||
schema?: string
|
||||
name?: string
|
||||
entity?: string
|
||||
type?: 'table' | 'view' | 'auth' | 'function' | 'extension'
|
||||
fkey_name?: string
|
||||
fkey_columns?: number[]
|
||||
} | null
|
||||
cache_key: string
|
||||
}
|
||||
|
||||
export type ProjectLintsVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
exposedSchemas?: string
|
||||
}
|
||||
export type ProjectLintResponse = components['schemas']['ProjectLintResponse']
|
||||
export type Lint = ProjectLintResponse
|
||||
export type LINT_TYPES = ProjectLintResponse['name']
|
||||
|
||||
export async function getProjectLints({ projectRef }: ProjectLintsVariables, signal?: AbortSignal) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
const { data, error } = await get(`/platform/projects/{ref}/run-lints`, {
|
||||
params: { path: { ref: projectRef } },
|
||||
signal,
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
const getProjectLints = async (
|
||||
{ projectRef, connectionString, exposedSchemas }: ProjectLintsVariables,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const exposedSchemasPrepend = `set local pgrst.db_schemas='${exposedSchemas}';`
|
||||
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: exposedSchemasPrepend?.concat(LINT_SQL),
|
||||
queryKey: lintKeys.lint(projectRef),
|
||||
},
|
||||
signal
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export type ProjectLintsData = Lint[]
|
||||
export type ProjectLintsError = unknown
|
||||
export type ProjectLintsData = Awaited<ReturnType<typeof getProjectLints>>
|
||||
export type ProjectLintsError = ResponseError
|
||||
|
||||
export const useProjectLintsQuery = <TData = ProjectLintsData>(
|
||||
{ projectRef, connectionString }: Omit<ProjectLintsVariables, 'exposedSchemas'>,
|
||||
{ projectRef }: ProjectLintsVariables,
|
||||
{ enabled = true, ...options }: UseQueryOptions<ProjectLintsData, ProjectLintsError, TData> = {}
|
||||
) => {
|
||||
const { data, isSuccess } = useProjectPostgrestConfigQuery({ projectRef })
|
||||
|
||||
const exposedSchemas = data?.db_schema
|
||||
|
||||
return useQuery<ProjectLintsData, ProjectLintsError, TData>(
|
||||
) =>
|
||||
useQuery<ProjectLintsData, ProjectLintsError, TData>(
|
||||
lintKeys.lint(projectRef),
|
||||
({ signal }) =>
|
||||
getProjectLints({ projectRef, connectionString, exposedSchemas: exposedSchemas! }, signal),
|
||||
({ signal }) => getProjectLints({ projectRef }, signal),
|
||||
{
|
||||
enabled: enabled && isSuccess && typeof projectRef !== 'undefined',
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -114,18 +114,6 @@ const sendActivity = (
|
||||
return post(`${API_URL}/telemetry/activity`, properties)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has consented to sending telemetry data. Mainly used for Sentry reports.
|
||||
*/
|
||||
export const canSendTelemetry = () => {
|
||||
const consent =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
|
||||
: null
|
||||
|
||||
return IS_PLATFORM && consent === 'true'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique identifier for an anonymous user based on their gotrue id.
|
||||
*/
|
||||
|
||||
@@ -52,7 +52,7 @@ import { useRootQueryClient } from 'data/query-client'
|
||||
import { AuthProvider } from 'lib/auth'
|
||||
import { BASE_PATH, IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { ProfileProvider } from 'lib/profile'
|
||||
import { canSendTelemetry, getAnonId } from 'lib/telemetry'
|
||||
import { getAnonId } from 'lib/telemetry'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import HCaptchaLoadedStore from 'stores/hcaptcha-loaded-store'
|
||||
import { AppPropsWithLayout } from 'types'
|
||||
@@ -112,12 +112,10 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
)
|
||||
|
||||
const errorBoundaryHandler = (error: Error, info: ErrorInfo) => {
|
||||
if (canSendTelemetry()) {
|
||||
Sentry.withScope(function (scope) {
|
||||
scope.setTag('globalErrorBoundary', true)
|
||||
Sentry.captureException(error)
|
||||
})
|
||||
}
|
||||
Sentry.withScope(function (scope) {
|
||||
scope.setTag('globalErrorBoundary', true)
|
||||
Sentry.captureException(error)
|
||||
})
|
||||
|
||||
console.error(error.stack)
|
||||
}
|
||||
@@ -152,11 +150,8 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
|
||||
const user = useUser()
|
||||
useEffect(() => {
|
||||
// don't set the sentry user id if
|
||||
// - on self-hosted or CLI
|
||||
// - the user has not consented to telemetry
|
||||
// - the user hasn't logged in (so that Sentry errors show null user id instead of anonymous id)
|
||||
if (!canSendTelemetry() || !user?.id) {
|
||||
// don't set the sentry user id if the user hasn't logged in (so that Sentry errors show null user id instead of anonymous id)
|
||||
if (!user?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
|
||||
const { data, isLoading, isRefetching, refetch } = useProjectLintsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const activeLints = data?.filter((x) => x.categories.includes('PERFORMANCE')) || []
|
||||
|
||||
@@ -38,7 +38,6 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
refetch: refetchLintsQuery,
|
||||
} = useProjectLintsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -58,44 +57,6 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
|
||||
let clientLints: Lint[] = []
|
||||
|
||||
// [Alaister]: checking this client side for speed, but should be moved into the query if possible
|
||||
if (authConfig?.EXTERNAL_EMAIL_ENABLED) {
|
||||
if (authConfig.MAILER_OTP_EXP > 3600) {
|
||||
clientLints.push({
|
||||
name: 'auth_otp_long_expiry',
|
||||
level: 'WARN',
|
||||
facing: 'EXTERNAL',
|
||||
categories: ['SECURITY'],
|
||||
description: 'OTP expiry exceeds recommended threshold',
|
||||
detail:
|
||||
'We have detected that you have enabled the email provider with the OTP expiry set to more than an hour. It is recommended to set this value to less than an hour.',
|
||||
cache_key: 'auth_otp_long_expiry',
|
||||
remediation: 'https://supabase.com/docs/guides/platform/going-into-prod#security',
|
||||
metadata: {
|
||||
type: 'auth',
|
||||
entity: 'Auth',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (authConfig.EXTERNAL_PHONE_ENABLED && authConfig.SMS_OTP_LENGTH < 6) {
|
||||
clientLints.push({
|
||||
name: 'auth_otp_short_length',
|
||||
level: 'WARN',
|
||||
facing: 'EXTERNAL',
|
||||
categories: ['SECURITY'],
|
||||
description: 'OTP length is less than recommended threshold',
|
||||
detail: 'We have detected that you have set the OTP length to less than 6 characters',
|
||||
cache_key: 'auth_otp_short_length',
|
||||
remediation: 'https://supabase.com/docs/guides/platform/going-into-prod#security',
|
||||
metadata: {
|
||||
type: 'auth',
|
||||
entity: 'Auth',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const activeLints = [...(data ?? []), ...clientLints]?.filter((x) =>
|
||||
x.categories.includes('SECURITY')
|
||||
)
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { IS_PLATFORM } from 'common/constants/environment'
|
||||
import { LOCAL_STORAGE_KEYS } from 'common/constants/local-storage'
|
||||
import { match } from 'path-to-regexp'
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 0.01,
|
||||
debug: false,
|
||||
beforeSend(event) {
|
||||
const consent =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
|
||||
: null
|
||||
|
||||
if (IS_PLATFORM && consent === 'true') {
|
||||
return event
|
||||
}
|
||||
return null
|
||||
},
|
||||
integrations: [
|
||||
new Sentry.BrowserTracing({
|
||||
// TODO: update gotrue + api to support Access-Control-Request-Headers: authorization,baggage,sentry-trace,x-client-info
|
||||
|
||||
@@ -61,13 +61,13 @@
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/auth</loc>
|
||||
<loc>https://supabase.com/blog/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/blog/</loc>
|
||||
<loc>https://supabase.com/auth</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
|
||||
@@ -239,6 +239,9 @@ export { Separator } from './src/components/shadcn/ui/separator'
|
||||
|
||||
export * from './src/components/shadcn/ui/resizable'
|
||||
|
||||
export * from './src/components/radio-group-stacked'
|
||||
export * from './src/components/radio-group-card'
|
||||
|
||||
// links
|
||||
|
||||
export * from './src/components/TextLink'
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
|
||||
import { Circle } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '../lib/utils/cn'
|
||||
|
||||
const RadioGroupCard = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return <RadioGroupPrimitive.Root className={cn('grid gap-2', className)} {...props} ref={ref} />
|
||||
})
|
||||
RadioGroupCard.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
interface RadioGroupCardItemProps {
|
||||
image?: React.ReactNode
|
||||
label: string
|
||||
showIndicator?: boolean
|
||||
}
|
||||
|
||||
const RadioGroupCardItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
RadioGroupCardItemProps & React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ image, label, showIndicator = true, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex flex-col gap-2',
|
||||
'w-48',
|
||||
'bg-overlay/50',
|
||||
'rounded-md',
|
||||
'border',
|
||||
'p-2',
|
||||
'shadow-sm',
|
||||
'hover:bg-surface-300',
|
||||
'hover:border-foreground-muted',
|
||||
'hover:z-[1] focus-visible:z-[1]',
|
||||
'data-[state=checked]:z-[1]',
|
||||
'data-[state=checked]:ring-1 data-[state=checked]:ring-border',
|
||||
'data-[state=checked]:bg-surface-300',
|
||||
'data-[state=checked]:border-foreground-muted',
|
||||
'transition-colors',
|
||||
'group',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
<div className="flex gap-2 w-full">
|
||||
{showIndicator && (
|
||||
<div
|
||||
className="
|
||||
aspect-square h-4 w-4
|
||||
rounded-full border group-data-[state=checked]:border-foreground-muted
|
||||
group-focus:border-foreground-muted
|
||||
group-hover:border-foreground-muted
|
||||
ring-offset-background
|
||||
group-focus:outline-none
|
||||
group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2
|
||||
group-disabled:cursor-not-allowed group-disabled:opacity-50
|
||||
flex items-center justify-center
|
||||
transition
|
||||
"
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label
|
||||
htmlFor={props.value}
|
||||
className={cn(
|
||||
'text-xs transition-colors text-left',
|
||||
'text-light',
|
||||
'group-hover:text-foreground group-data-[state=checked]:text-foreground',
|
||||
props.disabled ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
|
||||
RadioGroupCardItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroupCard, RadioGroupCardItem }
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '../lib/utils/cn'
|
||||
import { Label_Shadcn_ } from '../..'
|
||||
import { Circle } from 'lucide-react'
|
||||
|
||||
const RadioGroupStacked = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn('flex flex-col -space-y-px w-full', className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
RadioGroupStacked.displayName = 'RadioGroupStacked'
|
||||
|
||||
interface RadioGroupStackedItemProps {
|
||||
image?: React.ReactNode
|
||||
label: string
|
||||
showIndicator?: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
const RadioGroupStackedItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
RadioGroupStackedItemProps & React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ image, label, showIndicator = true, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex flex-col gap-2',
|
||||
'w-full',
|
||||
'bg-overlay/50',
|
||||
'border',
|
||||
'first-of-type:rounded-t-md last-of-type:rounded-b-md',
|
||||
'shadow-sm',
|
||||
'hover:bg-surface-300',
|
||||
'hover:border-foreground-muted',
|
||||
'hover:z-[1] focus-visible:z-[1]',
|
||||
'data-[state=checked]:z-[1]',
|
||||
'data-[state=checked]:ring-1 data-[state=checked]:ring-border',
|
||||
'data-[state=checked]:bg-surface-300',
|
||||
'data-[state=checked]:border-foreground-muted',
|
||||
'transition',
|
||||
'group',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-3 w-full px-[21px] py-[16px]">
|
||||
{showIndicator && (
|
||||
<div
|
||||
className="
|
||||
aspect-square h-4 w-4
|
||||
rounded-full border group-data-[state=checked]:border-foreground-muted
|
||||
group-focus:border-foreground-muted
|
||||
group-hover:border-foreground-muted
|
||||
ring-offset-background
|
||||
group-focus:outline-none
|
||||
group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2
|
||||
group-disabled:cursor-not-allowed group-disabled:opacity-50
|
||||
flex items-center justify-center
|
||||
transition
|
||||
"
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label_Shadcn_
|
||||
htmlFor={props.value}
|
||||
className={cn(
|
||||
'block',
|
||||
'-mt-[0.05rem]',
|
||||
'text-sm transition-colors text-left',
|
||||
'text-light',
|
||||
'group-hover:text-foreground group-data-[state=checked]:text-foreground',
|
||||
props.disabled ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Label_Shadcn_>
|
||||
{props.description && (
|
||||
<p className="text-xs text-foreground-lighter">{props.description}</p>
|
||||
)}
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
|
||||
RadioGroupStackedItem.displayName = 'RadioGroupStackedItem'
|
||||
|
||||
export { RadioGroupStacked, RadioGroupStackedItem }
|
||||
@@ -69,17 +69,20 @@ type FormItemContextValue = {
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
|
||||
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { asChild?: boolean }
|
||||
>(({ asChild, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
const Comp = asChild ? Slot : 'div'
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<Comp ref={ref} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = 'FormItem'
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
|
||||
Reference in new issue
Block a user