mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 10:55:06 +03:00
chore(studio): delete orphaned first-iteration scoped token form
Scoped/Form/Permissions/, Scoped/Form/ResourceAccess/, and AccessToken.schemas.ts (+ test) belonged to the superseded scoped-form design and had no live importers; the schemas file also still encoded the removed non-expiring token semantics. PERMISSION_LIST / ACCESS_TOKEN_RESOURCES stay: AccessTokenNewBanner still consumes them via useGroupedPermissions.
This commit is contained in:
11 files changed
-942
No files matched your search
@@ -1,193 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { PermissionRowSchema, TokenSchema } from './AccessToken.schemas'
|
||||
|
||||
const validTokenData = {
|
||||
tokenName: 'My Token',
|
||||
expiresAt: 'day',
|
||||
resourceAccess: 'all-orgs' as const,
|
||||
permissionRows: [{ resource: 'organization:billing', actions: ['read'] }],
|
||||
}
|
||||
|
||||
// --- PermissionRowSchema ---
|
||||
|
||||
describe('PermissionRowSchema', () => {
|
||||
it('should pass for a valid permission row', () => {
|
||||
const result = PermissionRowSchema.safeParse({
|
||||
resource: 'organization:billing',
|
||||
actions: ['read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail when resource is empty', () => {
|
||||
const result = PermissionRowSchema.safeParse({
|
||||
resource: '',
|
||||
actions: ['read'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Please select a resource')
|
||||
}
|
||||
})
|
||||
|
||||
it('should fail when actions is empty', () => {
|
||||
const result = PermissionRowSchema.safeParse({
|
||||
resource: 'organization:billing',
|
||||
actions: [],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Please select at least one action')
|
||||
}
|
||||
})
|
||||
|
||||
it('should fail when resource is missing', () => {
|
||||
const result = PermissionRowSchema.safeParse({ actions: ['read'] })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should fail when actions is missing', () => {
|
||||
const result = PermissionRowSchema.safeParse({ resource: 'organization:billing' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- TokenSchema ---
|
||||
|
||||
describe('TokenSchema', () => {
|
||||
it('should pass for valid token data', () => {
|
||||
const result = TokenSchema.safeParse(validTokenData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail when tokenName is empty', () => {
|
||||
const result = TokenSchema.safeParse({ ...validTokenData, tokenName: '' })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const nameError = result.error.issues.find((i) => i.path.includes('tokenName'))
|
||||
expect(nameError?.message).toBe('Please enter a name for the token')
|
||||
}
|
||||
})
|
||||
|
||||
it('should fail when tokenName is missing', () => {
|
||||
const { tokenName, ...rest } = validTokenData
|
||||
const result = TokenSchema.safeParse(rest)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should fail when permissionRows is empty', () => {
|
||||
const result = TokenSchema.safeParse({ ...validTokenData, permissionRows: [] })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const permError = result.error.issues.find((i) => i.path.includes('permissionRows'))
|
||||
expect(permError?.message).toBe('Please configure at least one permission')
|
||||
}
|
||||
})
|
||||
|
||||
it('should fail when resourceAccess is not a valid enum value', () => {
|
||||
const result = TokenSchema.safeParse({ ...validTokenData, resourceAccess: 'invalid' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept all valid resourceAccess enum values', () => {
|
||||
for (const value of ['all-orgs', 'selected-orgs', 'selected-projects'] as const) {
|
||||
const result = TokenSchema.safeParse({ ...validTokenData, resourceAccess: value })
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
describe('expiresAt preprocessing', () => {
|
||||
it('should convert "never" to undefined', () => {
|
||||
const result = TokenSchema.safeParse({ ...validTokenData, expiresAt: 'never' })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.expiresAt).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('should pass through other string values', () => {
|
||||
const result = TokenSchema.safeParse({ ...validTokenData, expiresAt: 'day' })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.expiresAt).toBe('day')
|
||||
}
|
||||
})
|
||||
|
||||
it('should allow expiresAt to be omitted', () => {
|
||||
const { expiresAt, ...rest } = validTokenData
|
||||
const result = TokenSchema.safeParse(rest)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom expiry refinement', () => {
|
||||
it('should fail when expiresAt is "custom" and customExpiryDate is not provided', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
expiresAt: 'custom',
|
||||
customExpiryDate: undefined,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const customError = result.error.issues.find((i) => i.path.includes('expiresAt'))
|
||||
expect(customError?.message).toBe('Please select a custom expiry date')
|
||||
}
|
||||
})
|
||||
|
||||
it('should fail when expiresAt is "custom" and customExpiryDate is empty string', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
expiresAt: 'custom',
|
||||
customExpiryDate: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should pass when expiresAt is "custom" and customExpiryDate is provided', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
expiresAt: 'custom',
|
||||
customExpiryDate: '2026-12-31T00:00:00Z',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should pass when expiresAt is not "custom" even without customExpiryDate', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
expiresAt: 'day',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nested permissionRows validation', () => {
|
||||
it('should fail when a permission row has an empty resource', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
permissionRows: [{ resource: '', actions: ['read'] }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should fail when a permission row has empty actions', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
permissionRows: [{ resource: 'organization:billing', actions: [] }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should pass with multiple valid permission rows', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
permissionRows: [
|
||||
{ resource: 'organization:billing', actions: ['read'] },
|
||||
{ resource: 'organization:members', actions: ['read', 'write'] },
|
||||
],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const PermissionRowSchema = z.object({
|
||||
resource: z.string().min(1, 'Please select a resource'),
|
||||
actions: z.array(z.string()).min(1, 'Please select at least one action'),
|
||||
})
|
||||
|
||||
export const TokenSchema = z
|
||||
.object({
|
||||
tokenName: z.string().min(1, 'Please enter a name for the token'),
|
||||
expiresAt: z.preprocess((val) => (val === 'never' ? undefined : val), z.string().optional()),
|
||||
customExpiryDate: z.string().optional(),
|
||||
resourceAccess: z.enum(['all-orgs', 'selected-orgs', 'selected-projects']),
|
||||
selectedOrganizations: z.array(z.string()).optional(),
|
||||
selectedProjects: z.array(z.string()).optional(),
|
||||
permissionRows: z.array(PermissionRowSchema).min(1, 'Please configure at least one permission'),
|
||||
})
|
||||
.refine((data) => !(data.expiresAt === 'custom' && !data.customExpiryDate), {
|
||||
message: 'Please select a custom expiry date',
|
||||
path: ['expiresAt'],
|
||||
})
|
||||
|
||||
export type TokenFormValues = z.infer<typeof TokenSchema>
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
import { Key, Plus } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { PermissionResourceSelectorProps, PermissionRow } from './Permissions.types'
|
||||
import { ACCESS_TOKEN_RESOURCES } from '@/components/interfaces/Account/AccessTokens/AccessToken.constants'
|
||||
|
||||
export const PermissionResourceSelector = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onResourceToggled,
|
||||
permissionRows,
|
||||
align = 'center',
|
||||
}: PermissionResourceSelectorProps) => {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange} modal={true}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="default" size="tiny" icon={<Plus className="h-4 w-4" />}>
|
||||
Add permission
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align={align}>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search resources..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No resources found.</CommandEmpty>
|
||||
|
||||
<CommandGroup className="[&>div]:text-left">
|
||||
<div className="max-h-[210px] overflow-y-auto">
|
||||
{ACCESS_TOKEN_RESOURCES.map((resource) => {
|
||||
const isChecked = permissionRows.some(
|
||||
(row: PermissionRow) => row.resource === resource.resource
|
||||
)
|
||||
return (
|
||||
<CommandItem
|
||||
key={resource.resource}
|
||||
value={`${resource.resource} ${resource.title}`}
|
||||
onSelect={() => onResourceToggled(resource)}
|
||||
className="text-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => onResourceToggled(resource)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Key size={12} className="text-foreground-lighter" />
|
||||
<div className="flex flex-col text-left flex-1">
|
||||
<span className="font-medium text-foreground capitalize">
|
||||
{resource.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
import { ChevronDown, RotateCcw, X } from 'lucide-react'
|
||||
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 { 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 = ({
|
||||
control,
|
||||
resourceSearchOpen,
|
||||
setResourceSearchOpen,
|
||||
}: 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">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">Configure permissions</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{permissionRows.length > 0 && (
|
||||
<ButtonTooltip
|
||||
variant="default"
|
||||
size="tiny"
|
||||
className="p-1"
|
||||
onClick={() => remove()}
|
||||
icon={<RotateCcw size={16} />}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'top',
|
||||
align: 'center',
|
||||
alignOffset: -10,
|
||||
text: 'Reset all permissions',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PermissionResourceSelector
|
||||
open={resourceSearchOpen}
|
||||
onOpenChange={setResourceSearchOpen}
|
||||
permissionRows={permissionRows}
|
||||
onResourceToggled={(resource) => {
|
||||
const index = permissionRows.findIndex((p) => p.resource === resource.resource)
|
||||
if (index > -1) {
|
||||
return remove(index)
|
||||
}
|
||||
append(resource)
|
||||
}}
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{permissionRows.length === 0 ? (
|
||||
<div className="text-center py-8 border border-dashed border-border rounded-lg">
|
||||
<p className="text-sm text-foreground-light">No permissions configured yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg">
|
||||
{permissionRows.map((row, index) => {
|
||||
const selectedResource = ACCESS_TOKEN_RESOURCES.find(
|
||||
(r) => r.resource === row.resource
|
||||
)
|
||||
return (
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-x-2 items-center">
|
||||
<WarningIcon />
|
||||
<span className="text-xs text-left text-foreground-lighter">
|
||||
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>
|
||||
)
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import { Control, FieldValues } from 'react-hook-form'
|
||||
|
||||
import { TokenFormValues } from '../../../AccessToken.schemas'
|
||||
|
||||
export interface PermissionResource {
|
||||
resource: string
|
||||
title: string
|
||||
actions: string[]
|
||||
}
|
||||
|
||||
export interface PermissionRow {
|
||||
resource: string
|
||||
actions: string[]
|
||||
}
|
||||
|
||||
export interface PermissionsFormValues extends FieldValues {
|
||||
permissionRows?: PermissionRow[]
|
||||
}
|
||||
|
||||
export interface PermissionsProps {
|
||||
control: Control<TokenFormValues>
|
||||
resourceSearchOpen: boolean
|
||||
setResourceSearchOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export interface PermissionResourceSelectorProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
permissionRows: PermissionRow[]
|
||||
onResourceToggled: (resource: PermissionResource) => void
|
||||
align?: 'center' | 'end' | 'start'
|
||||
}
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { PermissionResource, PermissionRow } from './Permissions.types'
|
||||
import { sortActions, togglePermissionResource } from './Permissions.utils'
|
||||
|
||||
// --- sortActions ---
|
||||
|
||||
describe('sortActions', () => {
|
||||
it('should sort actions by priority order: read, write, create, delete', () => {
|
||||
expect(sortActions(['delete', 'create', 'write', 'read'])).toEqual([
|
||||
'read',
|
||||
'write',
|
||||
'create',
|
||||
'delete',
|
||||
])
|
||||
})
|
||||
|
||||
it('should return empty array for empty input', () => {
|
||||
expect(sortActions([])).toEqual([])
|
||||
})
|
||||
|
||||
it('should keep a single action as-is', () => {
|
||||
expect(sortActions(['write'])).toEqual(['write'])
|
||||
})
|
||||
|
||||
it('should place unknown actions after the priority ones', () => {
|
||||
expect(sortActions(['custom-action', 'read', 'delete'])).toEqual([
|
||||
'read',
|
||||
'delete',
|
||||
'custom-action',
|
||||
])
|
||||
})
|
||||
|
||||
it('should preserve relative order of multiple unknown actions', () => {
|
||||
expect(sortActions(['zeta', 'alpha', 'read'])).toEqual(['read', 'zeta', 'alpha'])
|
||||
})
|
||||
|
||||
it('should handle only unknown actions', () => {
|
||||
expect(sortActions(['foo', 'bar'])).toEqual(['foo', 'bar'])
|
||||
})
|
||||
|
||||
it('should handle a subset of priority actions', () => {
|
||||
expect(sortActions(['delete', 'read'])).toEqual(['read', 'delete'])
|
||||
})
|
||||
|
||||
it('should not mutate the original array', () => {
|
||||
const original = ['delete', 'read']
|
||||
sortActions(original)
|
||||
expect(original).toEqual(['delete', 'read'])
|
||||
})
|
||||
})
|
||||
|
||||
// --- togglePermissionResource ---
|
||||
|
||||
describe('togglePermissionResource', () => {
|
||||
const billingResource: PermissionResource = {
|
||||
resource: 'organization:billing',
|
||||
title: 'Billing',
|
||||
actions: ['read', 'write'],
|
||||
}
|
||||
|
||||
const membersResource: PermissionResource = {
|
||||
resource: 'organization:members',
|
||||
title: 'Members',
|
||||
actions: ['read', 'write', 'create', 'delete'],
|
||||
}
|
||||
|
||||
const storageResource: PermissionResource = {
|
||||
resource: 'project:storage',
|
||||
title: 'Storage',
|
||||
actions: ['write', 'create'],
|
||||
}
|
||||
|
||||
it('should add a resource with "read" as the default action when available', () => {
|
||||
const result = togglePermissionResource([], billingResource)
|
||||
expect(result).toEqual([{ resource: 'organization:billing', actions: ['read'] }])
|
||||
})
|
||||
|
||||
it('should add a resource with the first action as default when "read" is not available', () => {
|
||||
const result = togglePermissionResource([], storageResource)
|
||||
expect(result).toEqual([{ resource: 'project:storage', actions: ['write'] }])
|
||||
})
|
||||
|
||||
it('should remove a resource if it is already in the list', () => {
|
||||
const existing: PermissionRow[] = [
|
||||
{ resource: 'organization:billing', actions: ['read'] },
|
||||
{ resource: 'organization:members', actions: ['read', 'write'] },
|
||||
]
|
||||
const result = togglePermissionResource(existing, billingResource)
|
||||
expect(result).toEqual([{ resource: 'organization:members', actions: ['read', 'write'] }])
|
||||
})
|
||||
|
||||
it('should not modify other rows when removing a resource', () => {
|
||||
const existing: PermissionRow[] = [
|
||||
{ resource: 'organization:billing', actions: ['read'] },
|
||||
{ resource: 'organization:members', actions: ['read', 'write'] },
|
||||
]
|
||||
const result = togglePermissionResource(existing, billingResource)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({ resource: 'organization:members', actions: ['read', 'write'] })
|
||||
})
|
||||
|
||||
it('should append to existing rows when adding', () => {
|
||||
const existing: PermissionRow[] = [{ resource: 'organization:billing', actions: ['read'] }]
|
||||
const result = togglePermissionResource(existing, membersResource)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[1]).toEqual({ resource: 'organization:members', actions: ['read'] })
|
||||
})
|
||||
|
||||
it('should not mutate the original array', () => {
|
||||
const existing: PermissionRow[] = [{ resource: 'organization:billing', actions: ['read'] }]
|
||||
const original = [...existing]
|
||||
togglePermissionResource(existing, membersResource)
|
||||
expect(existing).toEqual(original)
|
||||
})
|
||||
|
||||
it('should toggle off then on again correctly', () => {
|
||||
const step1 = togglePermissionResource([], billingResource)
|
||||
expect(step1).toHaveLength(1)
|
||||
|
||||
const step2 = togglePermissionResource(step1, billingResource)
|
||||
expect(step2).toHaveLength(0)
|
||||
|
||||
const step3 = togglePermissionResource(step2, billingResource)
|
||||
expect(step3).toHaveLength(1)
|
||||
expect(step3[0]).toEqual({ resource: 'organization:billing', actions: ['read'] })
|
||||
})
|
||||
})
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import { PermissionResource, PermissionRow } from './Permissions.types'
|
||||
|
||||
export const sortActions = (actions: string[]): string[] => {
|
||||
const sorted: string[] = []
|
||||
const remaining = [...actions]
|
||||
const priority = ['read', 'write', 'create', 'delete']
|
||||
|
||||
for (const action of priority) {
|
||||
const index = remaining.indexOf(action)
|
||||
if (index !== -1) {
|
||||
sorted.push(action)
|
||||
remaining.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
sorted.push(...remaining)
|
||||
return sorted
|
||||
}
|
||||
|
||||
export const togglePermissionResource = (
|
||||
permissionRows: PermissionRow[],
|
||||
resource: PermissionResource
|
||||
): PermissionRow[] => {
|
||||
const isAlreadyAdded = permissionRows.some((row) => row.resource === resource.resource)
|
||||
|
||||
if (isAlreadyAdded) {
|
||||
return permissionRows.filter((row) => row.resource !== resource.resource)
|
||||
}
|
||||
|
||||
const defaultActions = resource.actions.includes('read') ? ['read'] : [resource.actions[0]]
|
||||
return [...permissionRows, { resource: resource.resource, actions: defaultActions }]
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
import { FormControl, FormField } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { ResourceAccessProps } from './ResourceAccess.types'
|
||||
import { ResourceMultiSelector } from './ResourceMultiSelector'
|
||||
import { ResourceOption } from './ResourceOption'
|
||||
import { useOrgAndProjectData } from '@/components/interfaces/Account/AccessTokens/hooks/useOrgAndProjectData'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
|
||||
const RESOURCE_OPTIONS = [
|
||||
{
|
||||
value: 'all-orgs' as const,
|
||||
label: 'Everything',
|
||||
description: 'Access to all projects across all organizations you have access to.',
|
||||
},
|
||||
{
|
||||
value: 'selected-orgs' as const,
|
||||
label: 'Selected orgs',
|
||||
description: 'Access only to the organizations you have specified.',
|
||||
},
|
||||
{
|
||||
value: 'selected-projects' as const,
|
||||
label: 'Selected projects',
|
||||
description: 'Access only to the projects you have specified.',
|
||||
},
|
||||
]
|
||||
|
||||
export const ResourceAccess = ({ control, resourceAccess, setValue }: ResourceAccessProps) => {
|
||||
const { profile } = useProfile()
|
||||
|
||||
const { organizations, projects, isLoadingOrgs, isLoadingProjects } = useOrgAndProjectData({
|
||||
enabled: !!profile,
|
||||
})
|
||||
|
||||
const handleResourceAccessChange = (newValue: string, currentValue: string) => {
|
||||
if (newValue !== currentValue) {
|
||||
setValue('selectedOrganizations', [])
|
||||
setValue('selectedProjects', [])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-5 sm:px-6 py-6">
|
||||
<FormField
|
||||
key="resourceAccess"
|
||||
name="resourceAccess"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="resourceAccess" label="Resource access">
|
||||
<FormControl>
|
||||
<div className="space-y-3">
|
||||
<fieldset className="flex gap-3">
|
||||
<legend className="sr-only">Resource access level</legend>
|
||||
{RESOURCE_OPTIONS.map((option) => (
|
||||
<ResourceOption
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
isSelected={field.value === option.value}
|
||||
onChange={() => {
|
||||
handleResourceAccessChange(option.value, field.value)
|
||||
field.onChange(option.value)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<p className="text-foreground-light text-sm">
|
||||
{RESOURCE_OPTIONS.find((opt) => opt.value === field.value)?.description}
|
||||
</p>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
{resourceAccess === 'selected-orgs' && (
|
||||
<FormField
|
||||
key="selectedOrganizations"
|
||||
name="selectedOrganizations"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ResourceMultiSelector
|
||||
field={field}
|
||||
items={organizations.map((org) => ({ id: org.slug, name: org.name }))}
|
||||
isLoading={isLoadingOrgs}
|
||||
fieldName="selectedOrganizations"
|
||||
label="Select organizations"
|
||||
loadingMessage="Loading organizations..."
|
||||
emptyMessage="No organizations available"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resourceAccess === 'selected-projects' && (
|
||||
<FormField
|
||||
key="selectedProjects"
|
||||
name="selectedProjects"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ResourceMultiSelector
|
||||
field={field}
|
||||
items={projects.map((project) => ({ id: project.ref, name: project.name }))}
|
||||
isLoading={isLoadingProjects}
|
||||
fieldName="selectedProjects"
|
||||
label="Select projects"
|
||||
loadingMessage="Loading projects..."
|
||||
emptyMessage="No projects available"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
import { Control, ControllerRenderProps, UseFormSetValue } from 'react-hook-form'
|
||||
|
||||
import { TokenFormValues } from '@/components/interfaces/Account/AccessTokens/AccessToken.schemas'
|
||||
|
||||
export interface ResourceAccessProps {
|
||||
control: Control<TokenFormValues>
|
||||
resourceAccess: string
|
||||
setValue: UseFormSetValue<TokenFormValues>
|
||||
}
|
||||
|
||||
export interface ResourceItem {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ResourceMultiSelectorProps {
|
||||
field: ControllerRenderProps<any, any>
|
||||
items: ResourceItem[]
|
||||
isLoading: boolean
|
||||
fieldName: string
|
||||
label: string
|
||||
loadingMessage: string
|
||||
emptyMessage: string
|
||||
}
|
||||
|
||||
export interface ResourceOptionProps {
|
||||
value: string
|
||||
label: string
|
||||
isSelected: boolean
|
||||
onChange: () => void
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { FormControl } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import {
|
||||
MultiSelector,
|
||||
MultiSelectorContent,
|
||||
MultiSelectorItem,
|
||||
MultiSelectorList,
|
||||
MultiSelectorTrigger,
|
||||
} from 'ui-patterns/multi-select'
|
||||
|
||||
import { ResourceMultiSelectorProps } from './ResourceAccess.types'
|
||||
|
||||
export const ResourceMultiSelector = ({
|
||||
field,
|
||||
items,
|
||||
isLoading,
|
||||
fieldName,
|
||||
label,
|
||||
loadingMessage,
|
||||
emptyMessage,
|
||||
}: ResourceMultiSelectorProps) => {
|
||||
const idToName = useMemo(() => new Map(items.map((item) => [item.id, item.name])), [items])
|
||||
|
||||
const displayValues = (field.value || []).map((id: string) => idToName.get(id) || id)
|
||||
|
||||
const handleValuesChange = (names: string[]) => {
|
||||
const ids = names
|
||||
.map((name) => {
|
||||
for (const [id, itemName] of idToName.entries()) {
|
||||
if (itemName === name) return id
|
||||
}
|
||||
return name
|
||||
})
|
||||
.filter(Boolean)
|
||||
field.onChange(ids)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItemLayout name={fieldName} label={label}>
|
||||
<FormControl className="overflow-visible">
|
||||
<MultiSelector values={displayValues} onValuesChange={handleValuesChange}>
|
||||
<MultiSelectorTrigger
|
||||
deletableBadge
|
||||
showIcon={false}
|
||||
mode="inline-combobox"
|
||||
label={label}
|
||||
badgeLimit="wrap"
|
||||
/>
|
||||
<MultiSelectorContent className="z-50">
|
||||
{isLoading ? (
|
||||
<div className="px-3 py-2 text-sm text-foreground-light">{loadingMessage}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-foreground-light">{emptyMessage}</div>
|
||||
) : (
|
||||
<MultiSelectorList>
|
||||
{items.map((item) => (
|
||||
<MultiSelectorItem key={item.id} value={item.name}>
|
||||
{item.name}
|
||||
</MultiSelectorItem>
|
||||
))}
|
||||
</MultiSelectorList>
|
||||
)}
|
||||
</MultiSelectorContent>
|
||||
</MultiSelector>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
import { Box, Check } from 'lucide-react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
export const ResourceOption = ({
|
||||
value,
|
||||
label,
|
||||
isSelected,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
label: string
|
||||
isSelected: boolean
|
||||
onChange: () => void
|
||||
}) => (
|
||||
<label
|
||||
className={cn(
|
||||
'border border-default rounded-md bg-surface-200 hover:bg-overlay-hover hover:border-control px-4 py-3 cursor-pointer transition-colors flex-1 flex flex-col',
|
||||
isSelected && 'border-foreground-muted hover:border-foreground-muted bg-surface-300'
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<Box size={16} className="text-foreground-light" />
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center p-0.5 bg-foreground text-background rounded-full">
|
||||
<Check size={12} strokeWidth="4" className="text-background" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={cn('text-sm', isSelected ? 'text-foreground' : 'text-foreground-light')}>
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
type="radio"
|
||||
name="resourceAccess"
|
||||
value={value}
|
||||
checked={isSelected}
|
||||
onChange={onChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
Reference in new issue
Block a user