mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
fix: scoped PAT creation form error messages are hidden (#48011)
## Problem When creating a new scoped PAT, if users didn't add at least one permission or have a misconfigured permission (no access selected), the form does not submit but no error message is shown. The UI looks broken. ## Solution This is because there's a zod validation happening but its messages are not displayed for permissions. The proper fix is to use react-hook-form field array. <img width="541" height="633" alt="image" src="https://github.com/user-attachments/assets/89cab58d-761e-4131-9bce-460625067f8a" /> <img width="540" height="594" alt="image" src="https://github.com/user-attachments/assets/ed95cee0-06b5-4233-9a23-6819fb0e1a17" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved permission selection and toggling behavior in the scoped access token flow. * Enhanced validation feedback for permission rows and action selections, keeping error states in sync after changes. * Updated error handling to surface permission-related messages more reliably. * **Refactor** * Reworked the permissions UI to use a more reliable control-based rendering approach for rows, selection changes, and error presentation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
dc3c8684cc
commit
dba31df91d
4 files changed
+148
-127
No files matched your search
+6
-21
@@ -1,5 +1,4 @@
|
||||
import { Key, Plus } from 'lucide-react'
|
||||
import { Path, PathValue } from 'react-hook-form'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -14,30 +13,16 @@ import {
|
||||
PopoverTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import {
|
||||
PermissionResource,
|
||||
PermissionResourceSelectorProps,
|
||||
PermissionRow,
|
||||
PermissionsFormValues,
|
||||
} from './Permissions.types'
|
||||
import { togglePermissionResource } from './Permissions.utils'
|
||||
import { PermissionResourceSelectorProps, PermissionRow } from './Permissions.types'
|
||||
import { ACCESS_TOKEN_RESOURCES } from '@/components/interfaces/Account/AccessTokens/AccessToken.constants'
|
||||
|
||||
export const PermissionResourceSelector = <TFormValues extends PermissionsFormValues>({
|
||||
export const PermissionResourceSelector = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onResourceToggled,
|
||||
permissionRows,
|
||||
setValue,
|
||||
align = 'center',
|
||||
}: PermissionResourceSelectorProps<TFormValues>) => {
|
||||
const handleToggleResource = (resource: PermissionResource) => {
|
||||
const newRows = togglePermissionResource(permissionRows, resource)
|
||||
setValue(
|
||||
'permissionRows' as Path<TFormValues>,
|
||||
newRows as PathValue<TFormValues, Path<TFormValues>>
|
||||
)
|
||||
}
|
||||
|
||||
}: PermissionResourceSelectorProps) => {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange} modal={true}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -61,13 +46,13 @@ export const PermissionResourceSelector = <TFormValues extends PermissionsFormVa
|
||||
<CommandItem
|
||||
key={resource.resource}
|
||||
value={`${resource.resource} ${resource.title}`}
|
||||
onSelect={() => handleToggleResource(resource)}
|
||||
onSelect={() => onResourceToggled(resource)}
|
||||
className="text-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => handleToggleResource(resource)}
|
||||
onCheckedChange={() => onResourceToggled(resource)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Key size={12} className="text-foreground-lighter" />
|
||||
|
||||
+133
-96
@@ -1,21 +1,39 @@
|
||||
import { ChevronDown, RotateCcw, X } from 'lucide-react'
|
||||
import { Path, PathValue } from 'react-hook-form'
|
||||
import { Button, Checkbox, Popover, PopoverContent, PopoverTrigger, WarningIcon } from 'ui'
|
||||
import { useFieldArray, useFormState } from 'react-hook-form'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormMessage,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
|
||||
import { TokenFormValues } from '../../../AccessToken.schemas'
|
||||
import { PermissionResourceSelector } from './PermissionResourceSelector'
|
||||
import { PermissionRow, PermissionsFormValues, PermissionsProps } from './Permissions.types'
|
||||
import { PermissionsProps } from './Permissions.types'
|
||||
import { sortActions } from './Permissions.utils'
|
||||
import { ACCESS_TOKEN_RESOURCES } from '@/components/interfaces/Account/AccessTokens/AccessToken.constants'
|
||||
import { formatAccessText } from '@/components/interfaces/Account/AccessTokens/AccessToken.utils'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
|
||||
export const Permissions = <TFormValues extends PermissionsFormValues = PermissionsFormValues>({
|
||||
setValue,
|
||||
watch,
|
||||
export const Permissions = ({
|
||||
control,
|
||||
resourceSearchOpen,
|
||||
setResourceSearchOpen,
|
||||
}: PermissionsProps<TFormValues>) => {
|
||||
const permissionRows = (watch('permissionRows' as Path<TFormValues>) || []) as PermissionRow[]
|
||||
}: PermissionsProps) => {
|
||||
const {
|
||||
fields: permissionRows,
|
||||
append,
|
||||
remove,
|
||||
} = useFieldArray<TokenFormValues>({
|
||||
name: 'permissionRows',
|
||||
control,
|
||||
})
|
||||
const { errors } = useFormState({ control, name: 'permissionRows' })
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-5 sm:px-6 py-6">
|
||||
@@ -28,12 +46,7 @@ export const Permissions = <TFormValues extends PermissionsFormValues = Permissi
|
||||
variant="default"
|
||||
size="tiny"
|
||||
className="p-1"
|
||||
onClick={() => {
|
||||
setValue(
|
||||
'permissionRows' as Path<TFormValues>,
|
||||
[] as PathValue<TFormValues, Path<TFormValues>>
|
||||
)
|
||||
}}
|
||||
onClick={() => remove()}
|
||||
icon={<RotateCcw size={16} />}
|
||||
tooltip={{
|
||||
content: {
|
||||
@@ -49,7 +62,13 @@ export const Permissions = <TFormValues extends PermissionsFormValues = Permissi
|
||||
open={resourceSearchOpen}
|
||||
onOpenChange={setResourceSearchOpen}
|
||||
permissionRows={permissionRows}
|
||||
setValue={setValue}
|
||||
onResourceToggled={(resource) => {
|
||||
const index = permissionRows.findIndex((p) => p.resource === resource.resource)
|
||||
if (index > -1) {
|
||||
return remove(index)
|
||||
}
|
||||
append(resource)
|
||||
}}
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
@@ -66,88 +85,101 @@ export const Permissions = <TFormValues extends PermissionsFormValues = Permissi
|
||||
(r) => r.resource === row.resource
|
||||
)
|
||||
return (
|
||||
<div key={row.resource ?? `resource-${index}`}>
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium truncate max-w-[36ch] capitalize">
|
||||
{selectedResource?.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedResource && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="tiny"
|
||||
className="w-[150px] flex text-sm justify-between h-7 "
|
||||
iconRight={
|
||||
<ChevronDown size={14} className="text-foreground-muted" />
|
||||
}
|
||||
>
|
||||
{row.actions.length === 0 ? (
|
||||
<span className="text-foreground-lighter">Select access</span>
|
||||
) : row.actions.length === 1 ? (
|
||||
formatAccessText(row.actions[0])
|
||||
) : (
|
||||
`${row.actions.length} selected`
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-2" align="end">
|
||||
<div className="space-y-2">
|
||||
{sortActions(selectedResource.actions).map((action) => (
|
||||
<label
|
||||
key={action}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={row.actions.includes(action)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newActions = checked
|
||||
? [...row.actions, action]
|
||||
: row.actions.filter((a) => a !== action)
|
||||
const newRows: PermissionRow[] = permissionRows.map((r, i) =>
|
||||
i === index
|
||||
? { resource: r.resource, actions: newActions }
|
||||
: r
|
||||
)
|
||||
setValue(
|
||||
'permissionRows' as Path<TFormValues>,
|
||||
newRows as PathValue<TFormValues, Path<TFormValues>>,
|
||||
{ shouldValidate: true, shouldDirty: true }
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">{formatAccessText(action)}</span>
|
||||
</label>
|
||||
))}
|
||||
<FormField
|
||||
key={row.id}
|
||||
name={`permissionRows.${index}.actions`}
|
||||
render={({ field, fieldState }) => {
|
||||
const fieldValue = field.value || []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium truncate max-w-[36ch] capitalize">
|
||||
{selectedResource?.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="p-1"
|
||||
onClick={() => {
|
||||
const newRows = permissionRows.filter((_, i) => i !== index)
|
||||
setValue(
|
||||
'permissionRows' as Path<TFormValues>,
|
||||
newRows as PathValue<TFormValues, Path<TFormValues>>,
|
||||
{ shouldValidate: true, shouldDirty: true }
|
||||
)
|
||||
}}
|
||||
icon={<X size={16} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{index < permissionRows.length - 1 && <div className="border-t border-border" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedResource && (
|
||||
<Popover>
|
||||
<FormControl>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={`permissionRows.${index}.actions`}
|
||||
aria-describedby={
|
||||
fieldState.invalid
|
||||
? `permissionRows.${index}.actions.error`
|
||||
: undefined
|
||||
}
|
||||
variant="default"
|
||||
size="tiny"
|
||||
className="w-[150px] flex text-sm justify-between h-7 "
|
||||
iconRight={
|
||||
<ChevronDown size={14} className="text-foreground-muted" />
|
||||
}
|
||||
ref={field.ref}
|
||||
>
|
||||
{fieldValue.length === 0 ? (
|
||||
<span className="text-foreground-lighter">
|
||||
Select access
|
||||
</span>
|
||||
) : fieldValue.length === 1 ? (
|
||||
formatAccessText(fieldValue[0])
|
||||
) : (
|
||||
`${fieldValue.length} selected`
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</FormControl>
|
||||
<PopoverContent className="w-[180px] p-2" align="end">
|
||||
<div className="space-y-2">
|
||||
{sortActions(selectedResource.actions).map((action) => (
|
||||
<label
|
||||
key={action}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={fieldValue.includes(action)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newActions = checked
|
||||
? [...fieldValue, action]
|
||||
: fieldValue.filter((a: string) => a !== action)
|
||||
field.onChange(newActions)
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">{formatAccessText(action)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="p-1"
|
||||
onClick={() => {
|
||||
remove(index)
|
||||
}}
|
||||
icon={<X size={16} />}
|
||||
aria-label="Remove"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 pt-0">
|
||||
<FormMessage id={`permissionRows.${index}.actions.error`} />
|
||||
</div>
|
||||
{index < permissionRows.length - 1 && (
|
||||
<div className="border-t border-border" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -160,6 +192,11 @@ export const Permissions = <TFormValues extends PermissionsFormValues = Permissi
|
||||
Once you've set these permissions, you cannot edit them.
|
||||
</span>
|
||||
</div>
|
||||
{errors.permissionRows?.message || errors.permissionRows?.root?.message ? (
|
||||
<p role="alert" className="mt-2 text-sm text-destructive">
|
||||
{errors.permissionRows?.message || errors.permissionRows?.root?.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+7
-8
@@ -1,4 +1,6 @@
|
||||
import { FieldValues, UseFormSetValue, UseFormWatch } from 'react-hook-form'
|
||||
import { Control, FieldValues } from 'react-hook-form'
|
||||
|
||||
import { TokenFormValues } from '../../../AccessToken.schemas'
|
||||
|
||||
export interface PermissionResource {
|
||||
resource: string
|
||||
@@ -15,19 +17,16 @@ export interface PermissionsFormValues extends FieldValues {
|
||||
permissionRows?: PermissionRow[]
|
||||
}
|
||||
|
||||
export interface PermissionsProps<
|
||||
TFormValues extends PermissionsFormValues = PermissionsFormValues,
|
||||
> {
|
||||
setValue: UseFormSetValue<TFormValues>
|
||||
watch: UseFormWatch<TFormValues>
|
||||
export interface PermissionsProps {
|
||||
control: Control<TokenFormValues>
|
||||
resourceSearchOpen: boolean
|
||||
setResourceSearchOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export interface PermissionResourceSelectorProps<TFormValues extends PermissionsFormValues> {
|
||||
export interface PermissionResourceSelectorProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
permissionRows: PermissionRow[]
|
||||
setValue: UseFormSetValue<TFormValues>
|
||||
onResourceToggled: (resource: PermissionResource) => void
|
||||
align?: 'center' | 'end' | 'start'
|
||||
}
|
||||
+2
-2
@@ -66,6 +66,7 @@ export const NewScopedTokenSheet = ({
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const track = useTrack()
|
||||
const { mutate: createAccessToken, isPending } = useAccessTokenCreateMutation()
|
||||
|
||||
@@ -303,8 +304,7 @@ export const NewScopedTokenSheet = ({
|
||||
/>
|
||||
<Separator />
|
||||
<Permissions
|
||||
setValue={form.setValue}
|
||||
watch={form.watch}
|
||||
control={form.control}
|
||||
resourceSearchOpen={resourceSearchOpen}
|
||||
setResourceSearchOpen={setResourceSearchOpen}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user