Chore/rls assistant iteration templates (#21447)

* Add templates to RLS assistant

* Update button

* lint

* Chore/rls assistant iteration templates styling (#21459)

* Update index.tsx

* update icon pack

* add new classes

* expose hover card

* add slight shadow to scroll area

* monaco editor updated

* tabs border-b updated

* message styling updated to use circle AI icon

* templates side panel updated to use new tailwind classes and ScrollArea

* Updated tab label to "Assistant" and adjusted background colors

* header updated to use simpler open/close button

* divide-y styling added to chat

* fix tabs bg state

* shadcn tooltip updated to use correct bg color and added default delay duration

* updated styling of codeblocks in chat

* updated open / close button. also added a tooltip

* divide border styling

* add 'slide in' animation option for Hover Card

* update code block

* switch to Card Button

* Update CardButton to expose props and allow any props to be applied

* init

* Apply suggestions from code review

* Add entry step when clicking on create new policy for rls ai

* templates code replacement state highlight selected template

* Ai message code replacement state highlight selected message

* Opt to leave templates panel open

* Fix templates to use template name in policy sql

* Fixes

* fix

* Remove unused imports

---------

Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
This commit is contained in:
Joshen LimandJonathan Summers-Muir authored and GitHub committed 2024-02-28 14:44:17 +08:00
1 parent c1bde9a1a7
commit 8deb0865c5
22 files changed
+587 -180

No files matched your search

@@ -25,14 +25,16 @@ import Message from './Message'
interface AIPolicyChatProps {
messages: MessageWithDebug[]
selectedMessage?: string
loading: boolean
onSubmit: (s: string) => void
onDiff: (s: string) => void
onDiff: (message: { id: string; content: string }) => void
onChange: (value: boolean) => void
}
export const AIPolicyChat = ({
messages,
selectedMessage,
loading,
onSubmit,
onDiff,
@@ -78,13 +80,13 @@ export const AIPolicyChat = ({
return (
<div id={'ai-chat-assistant'} className="flex flex-col h-full max-w-full">
<div className="overflow-auto flex-1">
<div className="overflow-auto flex-1 divide-y divide-border">
<Message
key="zero"
role="assistant"
content={`Hi${
name ? ' ' + name : ''
}, how can I help you? I'm powered by AI, so surprises and mistakes are possible.
}, I can help you to write RLS policies. I'm powered by AI, so surprises and mistakes are possible.
Make sure to verify any generated code or suggestions, and share feedback so that we can
learn and improve.`}
>
@@ -107,7 +109,8 @@ export const AIPolicyChat = ({
content={m.content}
createdAt={new Date(m.createdAt || new Date()).getTime()}
isDebug={m.isDebug}
onDiff={onDiff}
isSelected={m.id === selectedMessage}
onDiff={(content) => onDiff({ id: m.id, content })}
/>
))}
@@ -1,12 +1,15 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
import styles from '@ui/layout/ai-icon-animation/ai-icon-animation-style.module.css'
import clsx from 'clsx'
import { X } from 'lucide-react'
import { AiIcon, Button, SheetClose_Shadcn_, SheetHeader_Shadcn_, SheetTitle_Shadcn_, cn } from 'ui'
import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks'
import { PanelLeftClose, PanelRightClose, X } from 'lucide-react'
import {
SheetClose_Shadcn_,
SheetHeader_Shadcn_,
SheetTitle_Shadcn_,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
Tooltip_Shadcn_,
cn,
} from 'ui'
export const AIPolicyHeader = ({
selectedPolicy,
@@ -17,51 +20,55 @@ export const AIPolicyHeader = ({
assistantVisible: boolean
setAssistantVisible: (v: boolean) => void
}) => {
// Customers on HIPAA plans should not have access to Supabase AI
const organization = useSelectedOrganization()
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const hasHipaaAddon = subscriptionHasHipaaAddon(subscription)
return (
<SheetHeader_Shadcn_
className={`${
selectedPolicy !== undefined ? 'pt-3 pb-0' : 'py-3'
} flex flex-row justify-between items-center`}
className={cn(
selectedPolicy !== undefined ? 'pt-3 pb-0' : 'py-3',
'flex flex-row justify-between items-center'
)}
>
<div className="flex flex-row gap-3 items-center max-w-[75%]">
<SheetClose_Shadcn_
className={clsx(
'text-light hover:text ring-offset-background transition-opacity hover:opacity-100',
'text-muted hover:text ring-offset-background transition-opacity hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:pointer-events-none data-[state=open]:bg-secondary'
'disabled:pointer-events-none data-[state=open]:bg-secondary',
'transition'
)}
>
<X className="h-3 w-3" />
<span className="sr-only">Close</span>
</SheetClose_Shadcn_>
<div className="h-[24px] w-[1px] bg-border" />
<div className="h-[24px] w-[1px] bg-border-overlay" />
<SheetTitle_Shadcn_ className="truncate">
{selectedPolicy !== undefined
? `Update policy: ${selectedPolicy.name}`
: 'Create a new Row Level Security policy'}
</SheetTitle_Shadcn_>
</div>
{!hasHipaaAddon && (
<Button
aria-expanded={assistantVisible}
aria-controls="ai-chat-assistant"
size="tiny"
type="outline"
className={cn('group', styles['ai-icon__container--allow-hover-effect'], 'px-3 py-0.5')}
rounded
icon={
<AiIcon className="scale-75 [&>div>div]:border-foreground-light [&>div>div]:group-hover:border-foreground -mr-0.5" />
}
onClick={() => setAssistantVisible(!assistantVisible)}
>
{assistantVisible ? <>Close Assistant</> : <>Open Assistant</>}
</Button>
)}
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<button
aria-expanded={assistantVisible}
aria-controls="ai-chat-assistant"
className={cn(
!assistantVisible ? 'text-foreground-lighter' : 'text-light',
'hover:text-foreground',
'transition'
)}
onClick={() => setAssistantVisible(!assistantVisible)}
>
{!assistantVisible ? (
<PanelLeftClose size={19} strokeWidth={1} />
) : (
<PanelRightClose size={19} strokeWidth={1} />
)}
</button>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="left">
{assistantVisible ? 'Hide' : 'Show'} tools
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
</SheetHeader_Shadcn_>
)
}
@@ -44,7 +44,7 @@ export const AIPolicyPre = ({ onDiff, children, className }: AAIPolicyPreProps)
value={formatted}
language="sql"
className={cn(
'!bg-transparent !py-3 !px-3.5 prose dark:prose-dark',
'!py-3 !px-3.5 prose dark:prose-dark transition',
// change the look of the code block. The flex hack is so that the code is wrapping since
// every word is a separate span
'[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap'
@@ -52,12 +52,25 @@ export const AIPolicyPre = ({ onDiff, children, className }: AAIPolicyPreProps)
hideCopy
hideLineNumbers
/>
<div className="absolute top-5 right-2 bg-surface-100 border-muted border rounded-lg h-[28px] hidden group-hover:block">
<div
className={cn(
'absolute',
'top-0 right-2',
'bg-surface-300',
'border border-strong',
'rounded',
'h-[28px]',
'opacity-0 group-hover:opacity-100',
'group-hover:top-2',
'transition-all'
)}
>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button
type="text"
size="tiny"
className={cn('text-foreground-lighter hover:text-foreground', 'transition')}
onClick={() => {
onDiff(formatted)
Telemetry.sendEvent(
@@ -71,7 +84,7 @@ export const AIPolicyPre = ({ onDiff, children, className }: AAIPolicyPreProps)
)
}}
>
<FileDiff className="h-4 w-4" />
<FileDiff className={cn('h-4 w-4')} />
</Button>
</Tooltip.Trigger>
<Tooltip.Portal>
@@ -93,6 +106,7 @@ export const AIPolicyPre = ({ onDiff, children, className }: AAIPolicyPreProps)
<Button
type="text"
size="tiny"
className={cn('text-foreground-lighter hover:text-foreground', 'transition')}
onClick={() => {
handleCopy(formatted)
Telemetry.sendEvent(
@@ -106,7 +120,11 @@ export const AIPolicyPre = ({ onDiff, children, className }: AAIPolicyPreProps)
)
}}
>
{copied ? <Check size={16} className="text-brand-600" /> : <Copy size={16} />}
{copied ? (
<Check size={16} className="text-brand-600" />
) : (
<Copy size={16} className={cn('h-4 w-4')} />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Portal>
@@ -4,7 +4,7 @@ import Image from 'next/image'
import { PropsWithChildren, memo, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { AiIconAnimation, Badge, markdownComponents } from 'ui'
import { AiIconAnimation, Badge, cn, markdownComponents } from 'ui'
import { useProfile } from 'lib/profile'
import { AIPolicyPre } from './AIPolicyPre'
@@ -15,6 +15,7 @@ interface MessageProps {
content?: string
createdAt?: number
isDebug?: boolean
isSelected?: boolean
onDiff?: (s: string) => void
}
@@ -24,6 +25,7 @@ const Message = memo(function Message({
content,
createdAt,
isDebug,
isSelected,
onDiff = noop,
children,
}: PropsWithChildren<MessageProps>) {
@@ -31,12 +33,21 @@ const Message = memo(function Message({
const icon = useMemo(() => {
return role === 'assistant' ? (
<AiIconAnimation
loading={content === 'Thinking...'}
className="[&>div>div]:border-black dark:[&>div>div]:border-white"
/>
<div
className={cn(
'bg-foreground border border-foreground-light rounded-full',
'w-8 h-8',
'flex items-center justify-center'
)}
>
<AiIconAnimation
loading={content === 'Thinking...'}
className={cn('scale-75', '[&>div>div]:border-background')}
/>
</div>
) : (
<div className="relative border shadow-lg w-8 h-8 rounded-full overflow-hidden">
{/* // TODO: this only works for GitHub profiles */}
<Image
src={`https://github.com/${profile?.username}.png` || ''}
width={30}
@@ -51,10 +62,12 @@ const Message = memo(function Message({
if (!content) return null
return (
<div className="flex flex-col py-4 gap-4 border-t px-5 text-foreground-light text-sm">
<div className="flex flex-col py-4 gap-4 px-5 text-foreground-light text-sm">
<div className="flex flex-row gap-3 items-center">
{icon}
<span className="text-sm">{role === 'assistant' ? 'Assistant' : name ? name : 'You'}</span>
<span className="text-foreground">
{role === 'assistant' ? 'Assistant' : name ? name : 'You'}
</span>
{createdAt && (
<span className="text-xs text-foreground-muted">{dayjs(createdAt).fromNow()}</span>
)}
@@ -67,7 +80,12 @@ const Message = memo(function Message({
...markdownComponents,
pre: (props: any) => {
return (
<AIPolicyPre onDiff={onDiff} className="pt-3">
<AIPolicyPre
onDiff={onDiff}
className={
isSelected ? '[&>div>pre]:!border-stronger [&>div>pre]:!bg-surface-200' : ''
}
>
{props.children[0].props.children}
</AIPolicyPre>
)
@@ -0,0 +1,117 @@
import { Search } from 'lucide-react'
import { useState } from 'react'
import { Badge, HoverCard, HoverCardContent, HoverCardTrigger, Input, cn } from 'ui'
import { Markdown } from 'components/interfaces/Markdown'
import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock'
import CardButton from 'components/ui/CardButton'
import CopyButton from 'components/ui/CopyButton'
import NoSearchResults from 'components/ui/NoSearchResults'
import { getGeneralPolicyTemplates } from '../PolicyEditorModal/PolicyEditorModal.constants'
import { PolicyTemplate } from '../PolicyTemplates/PolicyTemplates.constants'
interface PolicyTemplatesProps {
selectedTemplate?: string
onSelectTemplate: (template: { id: string; content: string }) => void
}
export const PolicyTemplates = ({ selectedTemplate, onSelectTemplate }: PolicyTemplatesProps) => {
const [search, setSearch] = useState('')
const templates = getGeneralPolicyTemplates('schema_name', 'table_name')
const filteredTemplates =
search.length > 0
? templates.filter(
(template) =>
template.name.toLowerCase().includes(search.toLowerCase()) ||
template.command.toLowerCase().includes(search.toLowerCase())
)
: templates
return (
<div className="h-full px-content py-content flex flex-col gap-3">
<label className="sr-only" htmlFor="template-search">
Search templates
</label>
<Input
size="small"
id="template-search"
icon={<Search size={16} className="text-foreground-muted" />}
placeholder="Search templates"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
{search.length > 0 && filteredTemplates.length === 0 && (
<NoSearchResults searchString={search} className="min-w-full" />
)}
<div className="flex flex-col gap-1.5">
{filteredTemplates.map((template) => {
return (
<HoverCard key={template.id} openDelay={100} closeDelay={0}>
<HoverCardTrigger>
<CardButton
title={template.name}
titleClass="text-sm"
className={cn(
'transition',
template.id === selectedTemplate
? '!border-stronger bg-surface-200 hover:!border-stronger'
: ''
)}
key={template.id}
onClick={() => onSelectTemplate({ id: template.id, content: template.statement })}
hideChevron
fixedHeight={false}
icon={
<div className="min-w-16">
<Badge
className="!rounded font-mono"
color={
template.command === 'ALL'
? 'scale'
: template.command === 'SELECT'
? 'green'
: template.command === 'UPDATE'
? 'blue'
: template.command === 'DELETE'
? 'tomato'
: 'yellow'
}
>
{template.command}
</Badge>
</div>
}
>
<Markdown content={template.description} className="[&>p]:m-0 space-y-2" />
</CardButton>
</HoverCardTrigger>
<HoverCardContent
hideWhenDetached
side="left"
align="center"
className="w-96 flex"
animate="slide-in"
>
<SimpleCodeBlock
showCopy={false}
className="sql"
parentClassName="!p-0 [&>div>span]:text-xs"
>
{template.statement}
</SimpleCodeBlock>
<CopyButton
iconOnly
type="default"
className="px-1 absolute top-1.5 right-1.5"
text={template.statement}
/>
</HoverCardContent>
</HoverCard>
)
})}
</div>
</div>
)
}
@@ -81,7 +81,6 @@ const RLSCodeEditor = ({
readOnly: false,
minimap: { enabled: false },
wordWrap: 'on' as const,
fixedOverflowWidgets: true,
contextmenu: true,
lineNumbers: undefined,
glyphMargin: undefined,
@@ -8,7 +8,21 @@ import dynamic from 'next/dynamic'
import { useRouter } from 'next/router'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { Button, Modal, SheetContent_Shadcn_, SheetFooter_Shadcn_, Sheet_Shadcn_, cn } from 'ui'
import {
Button,
IconEdit,
IconGrid,
Modal,
ScrollArea,
SheetContent_Shadcn_,
SheetFooter_Shadcn_,
Sheet_Shadcn_,
TabsContent_Shadcn_,
TabsList_Shadcn_,
TabsTrigger_Shadcn_,
Tabs_Shadcn_,
cn,
} from 'ui'
import {
IStandaloneCodeEditor,
@@ -35,6 +49,10 @@ import { AIPolicyHeader } from './AIPolicyHeader'
import PolicyDetails from './PolicyDetails'
import QueryError from './QueryError'
import RLSCodeEditor from './RLSCodeEditor'
import { PolicyTemplates } from './PolicyTemplates'
import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import CardButton from 'components/ui/CardButton'
const DiffEditor = dynamic(
() => import('@monaco-editor/react').then(({ DiffEditor }) => DiffEditor),
@@ -72,15 +90,20 @@ export const AIPolicyEditorPanel = memo(function ({
const isOptedInToAI = selectedOrganization?.opt_in_tags?.includes(OPT_IN_TAGS.AI_SQL) ?? false
const [error, setError] = useState<QueryResponseError>()
const [errorPanelOpen, setErrorPanelOpen] = useState(true)
const [showDetails, setShowDetails] = useState(false)
const [errorPanelOpen, setErrorPanelOpen] = useState<boolean>(true)
const [showDetails, setShowDetails] = useState<boolean>(false)
const [selectedDiff, setSelectedDiff] = useState<string>()
// [Joshen] Separate state here as there's a delay between submitting and the API updating the loading status
const [debugThread, setDebugThread] = useState<MessageWithDebug[]>([])
const [assistantVisible, setAssistantPanel] = useState(false)
const [isAssistantChatInputEmpty, setIsAssistantChatInputEmpty] = useState(true)
const [assistantVisible, setAssistantPanel] = useState<boolean>(false)
const [isAssistantChatInputEmpty, setIsAssistantChatInputEmpty] = useState<boolean>(true)
const [incomingChange, setIncomingChange] = useState<string | undefined>(undefined)
// used for confirmation when closing the panel with unsaved changes
const [isClosingPolicyEditorPanel, setIsClosingPolicyEditorPanel] = useState(false)
const [isClosingPolicyEditorPanel, setIsClosingPolicyEditorPanel] = useState<boolean>(false)
// Customers on HIPAA plans should not have access to Supabase AI
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganization?.slug })
const hasHipaaAddon = subscriptionHasHipaaAddon(subscription)
const { data: entities } = useEntityDefinitionsQuery(
{
@@ -222,6 +245,24 @@ export const AIPolicyEditorPanel = memo(function ({
setDebugThread(cleanedMessages)
}
const updateEditorWithCheckForDiff = (value: { id: string; content: string }) => {
const editorModel = editorRef.current?.getModel()
if (!editorModel) return
const existingValue = editorRef.current?.getValue() ?? ''
if (existingValue.length === 0) {
editorRef.current?.executeEdits('apply-template', [
{
text: value.content,
range: editorModel.getFullModelRange(),
},
])
} else {
setSelectedDiff(value.id)
setIncomingChange(value.content)
}
}
// when the panel is closed, reset all values
useEffect(() => {
if (!visible) {
@@ -233,6 +274,9 @@ export const AIPolicyEditorPanel = memo(function ({
setDebugThread([])
setChatId(uuidv4())
setShowDetails(false)
setSelectedDiff(undefined)
} else {
setAssistantPanel(true)
}
}, [visible])
@@ -251,8 +295,9 @@ export const AIPolicyEditorPanel = memo(function ({
<SheetContent_Shadcn_
size={assistantVisible ? 'lg' : 'default'}
className={cn(
'bg-surface-200',
'p-0 flex flex-row gap-0',
assistantVisible ? '!min-w-[1024px]' : '!min-w-[600px]'
assistantVisible ? '!min-w-[1200px]' : '!min-w-[600px]'
)}
>
<div className={cn('flex flex-col grow w-full', assistantVisible && 'w-[60%]')}>
@@ -261,16 +306,14 @@ export const AIPolicyEditorPanel = memo(function ({
assistantVisible={assistantVisible}
setAssistantVisible={setAssistantPanel}
/>
<PolicyDetails
policy={selectedPolicy}
showDetails={showDetails}
toggleShowDetails={() => setShowDetails(!showDetails)}
/>
<div className="flex flex-col h-full w-full justify-between">
{incomingChange ? (
<div className="px-5 py-3 flex justify-between gap-3 bg-muted">
<div className="px-5 py-3 flex justify-between gap-3 bg-surface-75">
<div className="flex gap-2 items-center text-foreground-light">
<FileDiff className="h-4 w-4" />
<span className="text-sm">Accept changes from assistant</span>
@@ -280,6 +323,7 @@ export const AIPolicyEditorPanel = memo(function ({
type="default"
onClick={() => {
setIncomingChange(undefined)
setSelectedDiff(undefined)
Telemetry.sendEvent(
{
category: 'rls_editor',
@@ -297,6 +341,7 @@ export const AIPolicyEditorPanel = memo(function ({
type="primary"
onClick={() => {
acceptChange()
setSelectedDiff(undefined)
Telemetry.sendEvent(
{
category: 'rls_editor',
@@ -377,20 +422,66 @@ export const AIPolicyEditorPanel = memo(function ({
</div>
</div>
{assistantVisible && (
<div className={cn('flex border-l grow w-full', assistantVisible && 'w-[40%]')}>
<AIPolicyChat
messages={messages}
onSubmit={(message) =>
append({
content: message,
role: 'user',
createdAt: new Date(),
})
}
onDiff={setIncomingChange}
onChange={setIsAssistantChatInputEmpty}
loading={isLoading || isDebugSqlLoading}
/>
<div
className={cn(
'border-l shadow-[rgba(0,0,0,0.13)_-4px_0px_6px_0px] z-10',
assistantVisible && 'w-[50%]',
'bg-studio'
)}
>
<Tabs_Shadcn_ defaultValue="templates" className="flex flex-col h-full w-full">
<TabsList_Shadcn_ className="flex gap-4 px-content pt-2">
<TabsTrigger_Shadcn_
key="templates"
value="templates"
className="px-0 data-[state=active]:bg-transparent"
>
Templates
</TabsTrigger_Shadcn_>
{!hasHipaaAddon && (
<TabsTrigger_Shadcn_
key="conversation"
value="conversation"
className="px-0 data-[state=active]:bg-transparent"
>
Assistant
</TabsTrigger_Shadcn_>
)}
</TabsList_Shadcn_>
<TabsContent_Shadcn_
value="templates"
className={cn(
'!mt-0 overflow-y-auto',
'data-[state=active]:flex data-[state=active]:grow'
)}
>
<ScrollArea className="h-full w-full">
<PolicyTemplates
selectedTemplate={selectedDiff}
onSelectTemplate={updateEditorWithCheckForDiff}
/>
</ScrollArea>
</TabsContent_Shadcn_>
<TabsContent_Shadcn_
value="conversation"
className="flex grow !mt-0 overflow-y-auto"
>
<AIPolicyChat
messages={messages}
selectedMessage={selectedDiff}
onSubmit={(message) =>
append({
content: message,
role: 'user',
createdAt: new Date(),
})
}
onDiff={updateEditorWithCheckForDiff}
onChange={setIsAssistantChatInputEmpty}
loading={isLoading || isDebugSqlLoading}
/>
</TabsContent_Shadcn_>
</Tabs_Shadcn_>
</div>
)}
@@ -170,6 +170,43 @@ const Policies = ({
})
}
if (tables.length === 0) {
return (
<div className="flex-grow flex items-center justify-center">
<ProductEmptyState
size="large"
title="Row-Level Security (RLS) Policies"
ctaButtonLabel="Create a table"
infoButtonLabel="What is RLS?"
infoButtonUrl="https://supabase.com/docs/guides/auth/row-level-security"
onClickCta={() => router.push(`/project/${ref}/editor`)}
>
<div className="space-y-4">
<InformationBox
title="What are policies?"
icon={<IconHelpCircle strokeWidth={2} />}
description={
<div className="space-y-2">
<p className="text-sm">
Policies restrict, on a per-user basis, which rows can be returned by normal
queries, or inserted, updated, or deleted by data modification commands.
</p>
<p className="text-sm">
This is also known as Row-Level Security (RLS). Each policy is attached to a
table, and the policy is executed each time its accessed.
</p>
</div>
}
/>
<p className="text-sm text-foreground-light">
Create a table in this schema first before creating a policy.
</p>
</div>
</ProductEmptyState>
</div>
)
}
return (
<>
<div className="flex flex-col gap-y-4 pb-4">
@@ -189,40 +226,7 @@ const Policies = ({
))
) : hasTables ? (
<NoSearchResults />
) : (
<div className="flex-grow">
<ProductEmptyState
size="large"
title="Row-Level Security (RLS) Policies"
ctaButtonLabel="Create a table"
infoButtonLabel="What is RLS?"
infoButtonUrl="https://supabase.com/docs/guides/auth/row-level-security"
onClickCta={() => router.push(`/project/${ref}/editor`)}
>
<div className="space-y-4">
<InformationBox
title="What are policies?"
icon={<IconHelpCircle strokeWidth={2} />}
description={
<div className="space-y-2">
<p className="text-sm">
Policies restrict, on a per-user basis, which rows can be returned by normal
queries, or inserted, updated, or deleted by data modification commands.
</p>
<p className="text-sm">
This is also known as Row-Level Security (RLS). Each policy is attached to a
table, and the policy is executed each time its accessed.
</p>
</div>
}
/>
<p className="text-sm text-foreground-light">
Create a table in this schema first before creating a policy.
</p>
</div>
</ProductEmptyState>
</div>
)}
) : null}
</div>
<PolicyEditorModal
@@ -17,15 +17,14 @@ import { PolicyTemplate } from '../PolicyTemplates/PolicyTemplates.constants'
export const getGeneralPolicyTemplates = (schema: string, table: string): PolicyTemplate[] => [
{
id: 'policy-1',
preview: false,
templateName: 'Enable read access to everyone',
description:
'This policy gives read access to your table for all users via the SELECT operation.',
statement: `
CREATE POLICY "policy_name"
ON ${schema}.${table}
FOR SELECT USING (
true
);`.trim(),
create policy "Enable read access for all users"
on ${schema}.${table}
for select using (true);`.trim(),
name: 'Enable read access for all users',
definition: 'true',
check: '',
@@ -34,14 +33,14 @@ FOR SELECT USING (
},
{
id: 'policy-2',
preview: false,
templateName: 'Enable insert access for authenticated users only',
description: 'This policy gives insert access to your table for all authenticated users only.',
statement: `
CREATE POLICY "policy_name"
ON ${schema}.${table}
FOR INSERT
TO authenticated
WITH CHECK (true);`.trim(),
create policy "Enable insert for authenticated users only"
on ${schema}.${table}
for insert to authenticated
with check (true);`.trim(),
name: 'Enable insert for authenticated users only',
definition: '',
check: 'true',
@@ -50,15 +49,16 @@ WITH CHECK (true);`.trim(),
},
{
id: 'policy-3',
preview: false,
templateName: 'Enable update access for users based on their email *',
description:
'This policy assumes that your table has a column "email", and allows users to update rows which the "email" column matches their email.',
statement: `
CREATE POLICY "policy_name"
ON ${schema}.${table}
FOR UPDATE USING (
create policy "Enable update for users based on email"
on ${schema}.${table}
for update using (
auth.jwt() ->> 'email' = email
) WITH CHECK (
) with check (
auth.jwt() ->> 'email' = email
);`.trim(),
name: 'Enable update for users based on email',
@@ -69,13 +69,14 @@ FOR UPDATE USING (
},
{
id: 'policy-4',
preview: false,
templateName: 'Enable delete access for users based on their user ID *',
description:
'This policy assumes that your table has a column "user_id", and allows users to delete rows which the "user_id" column matches their ID',
statement: `
CREATE POLICY "policy_name"
ON ${schema}.${table}
FOR DELETE USING (
create policy "Enable delete for users based on user_id"
on ${schema}.${table}
for delete using (
auth.uid() = user_id
);`.trim(),
name: 'Enable delete for users based on user_id',
@@ -86,15 +87,16 @@ FOR DELETE USING (
},
{
id: 'policy-5',
preview: false,
templateName: 'Enable insert access for users based on their user ID *',
description:
'This policy assumes that your table has a column "user_id", and allows users to insert rows which the "user_id" column matches their ID',
statement: `
CREATE POLICY "policy_name"
ON ${schema}.${table}
FOR INSERT USING (
create policy "Enable insert for users based on user_id"
on ${schema}.${table}
for insert using (
auth.uid() = user_id
) WITH CHECK (
) with check (
auth.uid() = user_id
);`.trim(),
name: 'Enable insert for users based on user_id',
@@ -103,4 +105,73 @@ FOR INSERT USING (
command: 'INSERT',
roles: [],
},
{
id: 'policy-6',
preview: true,
name: 'Policy with table joins',
templateName: 'Policy with table joins',
description: `
Query across tables to build more advanced RLS rules
Assuming 2 tables called \`teams\` and \`members\`, you can query both tables in the policy to control access to the members table.`,
statement: `
create policy "Team members can update team details if they belong to the team"
on teams for update using (
auth.uid() in (
select user_id from members where team_id = id
)
);
`.trim(),
definition: `auth.uid() in (select user_id from members where team_id = id)`,
check: '',
command: 'UPDATE',
roles: [],
},
{
id: 'policy-7',
preview: true,
templateName: 'Policy with security definer functions',
description: `
Useful in a many-to-many relationship where you want to restrict access to the linking table.
Assuming 2 tables called \`teams\` and \`members\`, you can use a security definer function in combination with a policy to control access to the members table.`.trim(),
statement: `
create or replace function get_teams_for_user(user_id uuid)
returns setof bigint as $$
select team_id from members where user_id = $1
$$ stable language sql security definer;
create policy "Team members can update team members if they belong to the team"
on members
for all using (
team_id in (select get_teams_for_user(auth.uid()))
);
`.trim(),
name: 'Policy with security definer functions',
definition: 'team_id in (select get_teams_for_user(auth.uid()))',
check: '',
command: 'ALL',
roles: [],
},
{
id: 'policy-8',
preview: true,
name: 'Policy to implement Time To Live (TTL)',
templateName: 'Policy to implement Time To Live (TTL)',
description: `
Implement a TTL-like feature that you see in Instagram stories or Snapchat where messages expire after a day.
Assuming a \`stories\` table, rows under the table are available only if they have been created within the last 24 hours.`,
statement: `
create policy "Stories are live for a day"
on stories
for select using (
created_at > (current_timestamp - interval '1 day')
);
`.trim(),
definition: `created_at > (current_timestamp - interval '1 day')`,
check: '',
command: 'SELECT',
roles: [],
},
]
@@ -237,7 +237,7 @@ const PolicyEditorModal = ({
/>
) : view === POLICY_MODAL_VIEWS.TEMPLATES ? (
<PolicyTemplates
templates={getGeneralPolicyTemplates(schema, table)}
templates={getGeneralPolicyTemplates(schema, table).filter((policy) => !policy.preview)}
templatesNote="* References a specific column in the table"
onUseTemplate={onUseTemplate}
/>
@@ -1,5 +1,6 @@
export interface PolicyTemplate {
id: string
preview: boolean
templateName: string
description: string
name: string
@@ -1,5 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { partition } from 'lodash'
import { observer } from 'mobx-react-lite'
import { useRouter } from 'next/router'
import { useMemo, useState } from 'react'
@@ -34,7 +35,6 @@ import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { useSnippets, useSqlEditorStateSnapshot } from 'state/sql-editor'
import QueryItem from './QueryItem'
import { partition, uniq, uniqBy } from 'lodash'
import { selectItemsInRange } from './SQLEditorLayout.utils'
const SideBarContent = observer(() => {
+78 -28
View File
@@ -1,9 +1,9 @@
import Link from 'next/link'
import React, { PropsWithChildren } from 'react'
import { IconChevronRight, IconLoader } from 'ui'
import { IconChevronRight, IconLoader, cn } from 'ui'
interface CardButtonProps {
title: string | React.ReactNode
title?: string | React.ReactNode
description?: string
footer?: React.ReactNode
url?: string
@@ -14,8 +14,32 @@ interface CardButtonProps {
icon?: React.ReactNode
loading?: boolean
className?: string
fixedHeight?: boolean
hideChevron?: boolean
titleClass?: string
}
// Define separate interfaces for each type of container
interface LinkContainerProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'title'> {
href: string
}
interface UrlContainerProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'title'> {
href: string
}
interface NonLinkContainerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {}
interface ButtonContainerProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {}
// Union of all container props
type ContainerProps =
| LinkContainerProps
| UrlContainerProps
| NonLinkContainerProps
| ButtonContainerProps
const CardButton = ({
title,
description,
@@ -25,28 +49,44 @@ const CardButton = ({
linkHref = '',
imgUrl,
imgAlt,
onClick,
icon,
className,
loading = false,
}: PropsWithChildren<CardButtonProps>) => {
const LinkContainer = ({ children }: { children: React.ReactNode }) => (
<Link href={linkHref}>{children}</Link>
)
const UrlContainer = ({ children }: { children: React.ReactNode }) => <a href={url}>{children}</a>
const NonLinkContainer = ({ children }: { children: React.ReactNode }) => <div>{children}</div>
const ButtonContainer = ({ children }: { children: React.ReactNode }) => (
<button onClick={onClick}>{children}</button>
)
fixedHeight = true,
hideChevron = false,
titleClass = '',
...props
}: PropsWithChildren<CardButtonProps & ContainerProps>) => {
const isLink = url || linkHref || props.onClick
const isLink = url || linkHref || onClick
let Container: React.ElementType
let containerProps: ContainerProps = {}
if (props.onClick) {
Container = 'button'
containerProps = props
} else if (linkHref) {
Container = Link
containerProps = {
href: linkHref,
...props,
}
} else if (url) {
Container = 'a'
containerProps = {
href: url,
...props,
}
} else {
Container = 'div'
containerProps = props
}
let containerClasses = [
className,
'group relative text-left',
'bg-surface-100',
'border border-surface',
'rounded-md p-5 flex flex-row h-32',
'rounded-md p-5 flex flex-row',
'transition ease-in-out duration-150',
]
@@ -59,12 +99,16 @@ const CardButton = ({
]
}
if (fixedHeight) {
containerClasses = [...containerClasses, 'h-32']
}
const ImageContainer = ({ children }: { children: React.ReactNode }) => {
return <div className="mr-4 flex flex-col">{children}</div>
}
const contents = (
<div className={containerClasses.join(' ')}>
<>
{imgUrl && (
<ImageContainer>
<img
@@ -80,7 +124,11 @@ const CardButton = ({
)}
{icon && <ImageContainer>{icon}</ImageContainer>}
<div className="flex h-full w-full flex-col space-y-2">
{typeof title === 'string' ? <h5 className="text-foreground">{title}</h5> : title}
{typeof title === 'string' ? (
<h5 className={`text-foreground ${titleClass}`}>{title}</h5>
) : (
title
)}
{(children || description) && (
<div className="flex w-full flex-1 flex-col">
<p className="text-sm text-foreground-light">{description}</p>
@@ -102,21 +150,23 @@ const CardButton = ({
group-hover:text-foreground
"
>
{loading ? <IconLoader className="animate-spin" /> : <IconChevronRight />}
{loading ? (
<IconLoader className="animate-spin" />
) : !hideChevron ? (
<IconChevronRight />
) : (
<></>
)}
</div>
)}
</div>
</>
)
if (onClick) {
return <ButtonContainer>{contents}</ButtonContainer>
} else if (linkHref) {
return <LinkContainer>{contents}</LinkContainer>
} else if (url) {
return <UrlContainer>{contents}</UrlContainer>
} else {
return <NonLinkContainer>{contents}</NonLinkContainer>
}
return (
<Container {...containerProps} className={cn(containerClasses, className)}>
{contents}
</Container>
)
}
export default CardButton
+11
View File
@@ -3,6 +3,17 @@
-webkit-text-size-adjust: 100%;
}
.monaco-editor,
.monaco-diff-editor {
--vscode-editor-background: hsl(var(--background-surface-200)) !important;
--vscode-editorGutter-background: hsl(var(--background-surface-200)) !important;
}
.gutter {
@apply bg-border-control;
cursor: row-resize;
}
.grid-monaco-editor,
.monaco-editor p,
label,
+15 -6
View File
@@ -87,7 +87,7 @@ function kebabToNested(obj) {
*/
const uiConfig = ui({
mode: 'JIT',
darkMode: [ 'class', '[data-theme*="dark"]' ],
darkMode: ['class', '[data-theme*="dark"]'],
theme: {
/**
* Spread all theme colors and custom generated colors into theme
@@ -102,7 +102,7 @@ const uiConfig = ui({
/*
* custom background re-maps
*/
"studio": `hsl(var(--background-200)/ <alpha-value>)`
studio: `hsl(var(--background-200)/ <alpha-value>)`,
}),
borderColor: (theme) => ({
...theme('colors'),
@@ -361,8 +361,8 @@ const uiConfig = ui({
xs: '480px',
},
fontFamily: {
sans: [ 'Circular', 'custom-font', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif' ],
mono: [ 'Office Code Pro', 'Source Code Pro', 'Menlo', 'monospace' ],
sans: ['Circular', 'custom-font', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
mono: ['Office Code Pro', 'Source Code Pro', 'Menlo', 'monospace'],
},
// shadcn defaults START
@@ -376,6 +376,15 @@ const uiConfig = ui({
'flash-code': 'flash-code 1s forwards',
'flash-code-slow': 'flash-code 2s forwards',
},
borderRadius: {
// lg: `var(--radius)`,
// md: `calc(var(--radius) - 2px)`,
// sm: 'calc(var(--radius) - 4px)',
panel: '6px',
},
padding: {
content: '21px',
},
// borderRadius: {
// lg: `var(--radius)`,
// md: `calc(var(--radius) - 2px)`,
@@ -397,8 +406,8 @@ const uiConfig = ui({
// shadcn defaults END
},
},
plugins: [ require('@tailwindcss/typography'), require('tailwindcss-animate') ],
});
plugins: [require('@tailwindcss/typography'), require('tailwindcss-animate')],
})
function arrayMergeFn(destinationArray, sourceArray) {
return destinationArray.concat(sourceArray).reduce((acc, cur) => {
+2
View File
@@ -174,6 +174,8 @@ export { Checkbox as Checkbox_Shadcn_ } from './src/components/shadcn/ui/checkbo
export * from './src/components/shadcn/ui/scroll-area'
export * from './src/components/shadcn/ui/hover-card'
export {
Collapsible as Collapsible_Shadcn_,
CollapsibleTrigger as CollapsibleTrigger_Shadcn_,
@@ -2,7 +2,7 @@ import { Children } from 'react'
import * as CopyToClipboard from 'react-copy-to-clipboard'
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'
import { monokaiCustomTheme } from './CodeBlock.utils'
import { Button, IconCheck, IconCopy } from 'ui'
import { Button, IconCheck, IconCopy, cn } from 'ui'
import js from 'react-syntax-highlighter/dist/cjs/languages/hljs/javascript'
import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript'
@@ -97,12 +97,12 @@ export const CodeBlock = ({
wrapLines={true}
// @ts-ignore
style={monokaiTheme}
className={[
'code-block border p-4 w-full !my-0 !bg-surface-100',
className={cn(
'code-block border border-surface p-4 w-full !my-0 bg-surface-100',
`${!title ? '!rounded-md' : '!rounded-t-none !rounded-b-md'}`,
`${!showLineNumbers ? 'pl-6' : ''}`,
className,
].join(' ')}
className
)}
customStyle={{
fontSize: large ? 18 : 13,
lineHeight: large ? 1.5 : 1.4,
@@ -3,7 +3,6 @@ export const monokaiCustomTheme = (isDarkMode: boolean) => {
hljs: {
display: 'block',
overflowX: 'auto',
background: '#272822',
color: isDarkMode ? '#ddd' : '#888',
},
'hljs-tag': {
@@ -11,14 +11,19 @@ const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & {
animate?: 'zoom-in' | 'slide-in'
}
>(({ className, align = 'center', animate = 'zoom-in', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-64 rounded-md border bg-overlay p-4 text-popover-foreground shadow-md outline-none animate-in zoom-in-90',
'z-50 w-64 rounded-md border bg-overlay p-4 text-popover-foreground shadow-md outline-none',
animate === 'zoom-in'
? 'animate-in zoom-in-[99%]'
: 'animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
className
)}
{...props}
@@ -14,7 +14,7 @@ const ScrollArea = React.forwardRef<
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] shadow-[0_2px_10px]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
@@ -13,7 +13,7 @@ const TabsList = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn('flex items-center border-b border-secondary', className)}
className={cn('flex items-center border-b', className)}
{...props}
/>
))
@@ -7,7 +7,9 @@ import { cn } from '../../../lib/utils/cn'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const Tooltip = (props: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Root>) => (
<TooltipPrimitive.Root delayDuration={180} {...props} />
)
const TooltipTrigger = TooltipPrimitive.Trigger
@@ -18,11 +20,11 @@ const TooltipContent = React.forwardRef<
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
{...props}
className={cn(
'z-50 overflow-hidden rounded-md border bg-overlay px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
'z-50 overflow-hidden rounded-md border bg-alternative px-3 py-1.5 text-xs text-foreground shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName