mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
Remove unused code for scoped tokens
This commit is contained in:
10 files changed
-845
No files matched your search
@@ -1,140 +0,0 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Control, ControllerRenderProps } from 'react-hook-form'
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import {
|
||||
CUSTOM_EXPIRY_VALUE,
|
||||
EXPIRES_AT_OPTIONS,
|
||||
NON_EXPIRING_TOKEN_VALUE,
|
||||
} from '../../AccessToken.constants'
|
||||
import { type TokenFormValues } from '../../AccessToken.schemas'
|
||||
import { DatePicker } from '@/components/ui/DatePicker'
|
||||
|
||||
interface BasicInfoProps {
|
||||
control: Control<TokenFormValues>
|
||||
expirationDate: string
|
||||
onCustomDateChange?: (date: { date: string } | undefined) => void
|
||||
onCustomExpiryChange?: (isCustom: boolean) => void
|
||||
}
|
||||
|
||||
export const BasicInfo = ({
|
||||
control,
|
||||
expirationDate,
|
||||
onCustomDateChange,
|
||||
onCustomExpiryChange,
|
||||
}: BasicInfoProps) => {
|
||||
const [customDate, setCustomDate] = useState<Date>()
|
||||
const [isCustomSelected, setIsCustomSelected] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const isCustom = expirationDate === CUSTOM_EXPIRY_VALUE
|
||||
setIsCustomSelected(isCustom)
|
||||
onCustomExpiryChange?.(isCustom)
|
||||
}, [expirationDate, onCustomExpiryChange])
|
||||
|
||||
const handleCustomDateChange = (date: Date | undefined) => {
|
||||
setCustomDate(date)
|
||||
if (date) {
|
||||
onCustomDateChange?.({ date: date.toISOString() })
|
||||
} else {
|
||||
onCustomDateChange?.(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExpiryChange = (
|
||||
value: string,
|
||||
field: ControllerRenderProps<TokenFormValues, 'expiresAt'>
|
||||
) => {
|
||||
const isCustom = value === CUSTOM_EXPIRY_VALUE
|
||||
setIsCustomSelected(isCustom)
|
||||
onCustomExpiryChange?.(isCustom)
|
||||
field.onChange(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-5 sm:px-6 py-6">
|
||||
<FormField
|
||||
key="tokenName"
|
||||
name="tokenName"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="tokenName" label="Name">
|
||||
<FormControl>
|
||||
<Input id="tokenName" {...field} placeholder="Provide a name for your token" />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
key="expiresAt"
|
||||
name="expiresAt"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="expiresAt" label="Expires in">
|
||||
<div className="flex gap-2">
|
||||
<FormControl className="grow">
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => handleExpiryChange(value, field)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Expires at" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(EXPIRES_AT_OPTIONS).map(
|
||||
(option: { value: string; label: string }) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{isCustomSelected && (
|
||||
<DatePicker
|
||||
selectsRange={false}
|
||||
triggerButtonSize="small"
|
||||
contentSide="top"
|
||||
minDate={new Date()}
|
||||
maxDate={dayjs().add(1, 'year').toDate()}
|
||||
onChange={(date) => {
|
||||
const selectedDate = date.to || date.from
|
||||
if (selectedDate) {
|
||||
handleCustomDateChange(new Date(selectedDate))
|
||||
} else {
|
||||
handleCustomDateChange(undefined)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{customDate ? `${dayjs(customDate).format('DD MMM, HH:mm')}` : 'Select date'}
|
||||
</DatePicker>
|
||||
)}
|
||||
</div>
|
||||
{field.value === NON_EXPIRING_TOKEN_VALUE && (
|
||||
<div className="w-full flex gap-x-2 items-center mt-3 mx-0.5">
|
||||
<WarningIcon />
|
||||
<span className="text-xs text-left text-foreground-lighter">
|
||||
Make sure to keep your non-expiring token safe and secure.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
import { Key, Plus } from 'lucide-react'
|
||||
import { Path, PathValue } from 'react-hook-form'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import {
|
||||
PermissionResource,
|
||||
PermissionResourceSelectorProps,
|
||||
PermissionRow,
|
||||
PermissionsFormValues,
|
||||
} from './Permissions.types'
|
||||
import { togglePermissionResource } from './Permissions.utils'
|
||||
import { ACCESS_TOKEN_RESOURCES } from '@/components/interfaces/Account/AccessTokens/AccessToken.constants'
|
||||
|
||||
export const PermissionResourceSelector = <TFormValues extends PermissionsFormValues>({
|
||||
open,
|
||||
onOpenChange,
|
||||
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>>
|
||||
)
|
||||
}
|
||||
|
||||
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={() => handleToggleResource(resource)}
|
||||
className="text-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => handleToggleResource(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>
|
||||
)
|
||||
}
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
import { ChevronDown, RotateCcw, X } from 'lucide-react'
|
||||
import { Path, PathValue } from 'react-hook-form'
|
||||
import { Button, Checkbox, Popover, PopoverContent, PopoverTrigger, WarningIcon } from 'ui'
|
||||
|
||||
import { PermissionResourceSelector } from './PermissionResourceSelector'
|
||||
import { PermissionRow, PermissionsFormValues, 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,
|
||||
resourceSearchOpen,
|
||||
setResourceSearchOpen,
|
||||
}: PermissionsProps<TFormValues>) => {
|
||||
const permissionRows = (watch('permissionRows' as Path<TFormValues>) || []) as PermissionRow[]
|
||||
|
||||
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={() => {
|
||||
setValue(
|
||||
'permissionRows' as Path<TFormValues>,
|
||||
[] as PathValue<TFormValues, Path<TFormValues>>
|
||||
)
|
||||
}}
|
||||
icon={<RotateCcw size={16} />}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'top',
|
||||
align: 'center',
|
||||
alignOffset: -10,
|
||||
text: 'Reset all permissions',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PermissionResourceSelector
|
||||
open={resourceSearchOpen}
|
||||
onOpenChange={setResourceSearchOpen}
|
||||
permissionRows={permissionRows}
|
||||
setValue={setValue}
|
||||
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 (
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import { FieldValues, UseFormSetValue, UseFormWatch } from 'react-hook-form'
|
||||
|
||||
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<
|
||||
TFormValues extends PermissionsFormValues = PermissionsFormValues,
|
||||
> {
|
||||
setValue: UseFormSetValue<TFormValues>
|
||||
watch: UseFormWatch<TFormValues>
|
||||
resourceSearchOpen: boolean
|
||||
setResourceSearchOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export interface PermissionResourceSelectorProps<TFormValues extends PermissionsFormValues> {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
permissionRows: PermissionRow[]
|
||||
setValue: UseFormSetValue<TFormValues>
|
||||
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