mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): scoped pat ui (#38170)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Introduces UI for Scoped PAT's. ## Additional context WIP. Testing endpoints. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Scoped access tokens: create, view, delete, and manage granular resource/action permissions with custom expiry options and scoped creation flows. * New scoped token UI: modal/sheet creation flow, resource access selectors, permissions editor, and a scoped token list. * **UI Improvements** * Tabbed Classic vs Scoped tokens layout, refreshed tokens page, improved forms, search/filtering, and table tweaks. * **Bug Fixes / Maintenance** * New data hooks and utilities for scoped tokens and token handling. * **Removed** * Legacy account settings layout and legacy token banner. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
7f13c269ac
commit
8ffdcfc145
43 files changed
+2656
-568
No files matched your search
@@ -0,0 +1,75 @@
|
||||
import { components } from 'api-types'
|
||||
import { permissions } from '@supabase/shared-types'
|
||||
|
||||
export type ScopedAccessTokenPermission =
|
||||
components['schemas']['CreateScopedAccessTokenBody']['permissions'][number]
|
||||
|
||||
export const NON_EXPIRING_TOKEN_VALUE = 'never'
|
||||
export const CUSTOM_EXPIRY_VALUE = 'custom'
|
||||
|
||||
export const EXPIRES_AT_OPTIONS = {
|
||||
hour: { value: 'hour', label: '1 hour' },
|
||||
day: { value: 'day', label: '1 day' },
|
||||
week: { value: 'week', label: '7 days' },
|
||||
month: { value: 'month', label: '30 days' },
|
||||
never: { value: NON_EXPIRING_TOKEN_VALUE, label: 'Never' },
|
||||
custom: { value: CUSTOM_EXPIRY_VALUE, label: 'Custom' },
|
||||
} as const
|
||||
|
||||
const FGA = permissions.FgaPermissions
|
||||
|
||||
const getAction = (key: string): string => {
|
||||
if (key.endsWith('_READ')) return 'read'
|
||||
if (key.endsWith('_WRITE')) return 'write'
|
||||
if (key.endsWith('_CREATE')) return 'create'
|
||||
if (key.endsWith('_DELETE')) return 'delete'
|
||||
return 'read'
|
||||
}
|
||||
|
||||
const getResource = (key: string): string => {
|
||||
return key.replace(/_(READ|WRITE|CREATE|DELETE)$/, '').toLowerCase()
|
||||
}
|
||||
|
||||
const buildPermissionList = () => {
|
||||
const list: Array<{
|
||||
scope: string
|
||||
resource: string
|
||||
action: string
|
||||
id: string
|
||||
title: string
|
||||
}> = []
|
||||
|
||||
for (const [scope, scopePerms] of Object.entries(FGA)) {
|
||||
for (const [key, perm] of Object.entries(scopePerms)) {
|
||||
list.push({
|
||||
scope: scope.toLowerCase(),
|
||||
resource: getResource(key),
|
||||
action: getAction(key),
|
||||
id: perm.id,
|
||||
title: perm.title,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
export const PERMISSION_LIST = buildPermissionList()
|
||||
|
||||
export const ACCESS_TOKEN_RESOURCES = (() => {
|
||||
const resourceMap = new Map<string, { resource: string; title: string; actions: string[] }>()
|
||||
|
||||
for (const p of PERMISSION_LIST) {
|
||||
const key = `${p.scope}:${p.resource}`
|
||||
if (!resourceMap.has(key)) {
|
||||
const cleanTitle = p.title.replace(/^(Read|Manage|Create|Delete)\s+/i, '')
|
||||
resourceMap.set(key, { resource: key, title: cleanTitle, actions: [] })
|
||||
}
|
||||
const entry = resourceMap.get(key)!
|
||||
if (!entry.actions.includes(p.action)) {
|
||||
entry.actions.push(p.action)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(resourceMap.values())
|
||||
})()
|
||||
@@ -0,0 +1,23 @@
|
||||
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>
|
||||
@@ -0,0 +1,25 @@
|
||||
export const ACCESS_TOKEN_SORT_VALUES = [
|
||||
'created_at:asc',
|
||||
'created_at:desc',
|
||||
'last_used_at:asc',
|
||||
'last_used_at:desc',
|
||||
'expires_at:asc',
|
||||
'expires_at:desc',
|
||||
] as const
|
||||
|
||||
export type AccessTokenSort = (typeof ACCESS_TOKEN_SORT_VALUES)[number]
|
||||
export type AccessTokenSortColumn = AccessTokenSort extends `${infer Column}:${string}`
|
||||
? Column
|
||||
: never
|
||||
export type AccessTokenSortOrder = AccessTokenSort extends `${string}:${infer Order}`
|
||||
? Order
|
||||
: never
|
||||
|
||||
export interface BaseToken {
|
||||
id: string | number
|
||||
name: string
|
||||
token_alias: string
|
||||
created_at: string
|
||||
last_used_at?: string | null
|
||||
expires_at?: string | null
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
AccessTokenSort,
|
||||
AccessTokenSortColumn,
|
||||
AccessTokenSortOrder,
|
||||
BaseToken,
|
||||
} from './AccessToken.types'
|
||||
import { PERMISSION_LIST, ScopedAccessTokenPermission } from './AccessToken.constants'
|
||||
|
||||
export const handleSortChange = (
|
||||
currentSort: AccessTokenSort,
|
||||
column: AccessTokenSortColumn,
|
||||
setSort: (sort: AccessTokenSort) => void
|
||||
) => {
|
||||
const [currentCol, currentOrder] = currentSort.split(':') as [
|
||||
AccessTokenSortColumn,
|
||||
AccessTokenSortOrder,
|
||||
]
|
||||
if (currentCol === column) {
|
||||
if (currentOrder === 'asc') {
|
||||
setSort(`${column}:desc` as AccessTokenSort)
|
||||
} else {
|
||||
setSort('created_at:desc')
|
||||
}
|
||||
} else {
|
||||
setSort(`${column}:asc` as AccessTokenSort)
|
||||
}
|
||||
}
|
||||
|
||||
export const filterAndSortTokens = <T extends BaseToken>(
|
||||
tokens: T[] | undefined,
|
||||
searchString: string,
|
||||
sort: AccessTokenSort
|
||||
): T[] | undefined => {
|
||||
const filtered = !searchString
|
||||
? tokens
|
||||
: tokens?.filter((token) => token.name.toLowerCase().includes(searchString.toLowerCase()))
|
||||
|
||||
if (!filtered) return filtered
|
||||
|
||||
const [sortCol, sortOrder] = sort.split(':') as [AccessTokenSortColumn, AccessTokenSortOrder]
|
||||
const orderMultiplier = sortOrder === 'asc' ? 1 : -1
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (sortCol === 'created_at') {
|
||||
return (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * orderMultiplier
|
||||
}
|
||||
if (sortCol === 'last_used_at') {
|
||||
if (!a.last_used_at && !b.last_used_at) return 0
|
||||
if (!a.last_used_at) return 1
|
||||
if (!b.last_used_at) return -1
|
||||
return (
|
||||
(new Date(a.last_used_at).getTime() - new Date(b.last_used_at).getTime()) * orderMultiplier
|
||||
)
|
||||
}
|
||||
if (sortCol === 'expires_at') {
|
||||
if (!a.expires_at && !b.expires_at) return 0
|
||||
if (!a.expires_at) return 1
|
||||
if (!b.expires_at) return -1
|
||||
return (new Date(a.expires_at).getTime() - new Date(b.expires_at).getTime()) * orderMultiplier
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
export const mapPermissionToFGA = (
|
||||
resourceKey: string,
|
||||
action: string
|
||||
): ScopedAccessTokenPermission[] => {
|
||||
const [scope, resource] = resourceKey.split(':')
|
||||
const match = PERMISSION_LIST.find(
|
||||
(p) => p.scope === scope && p.resource === resource && p.action === action
|
||||
)
|
||||
return match ? [match.id as ScopedAccessTokenPermission] : []
|
||||
}
|
||||
|
||||
// [kemal]: Not sure how efficient this will be, but it should get permissions from shared types and transform them whenever @supabase/shared-types updates.
|
||||
export const getResourcePermissions = (
|
||||
resourceKey: string
|
||||
): Record<string, ScopedAccessTokenPermission[]> => {
|
||||
const [scope, resource] = resourceKey.split(':')
|
||||
const result: Record<string, ScopedAccessTokenPermission[]> = { 'no access': [] }
|
||||
|
||||
PERMISSION_LIST.filter((p) => p.scope === scope && p.resource === resource).forEach((p) => {
|
||||
result[p.action] = [p.id as ScopedAccessTokenPermission]
|
||||
})
|
||||
|
||||
if (result['read'] && result['write']) {
|
||||
result['read-write'] = [...result['read'], ...result['write']]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const getRealAccess = (resource: string, tokenPermissions: string[]) => {
|
||||
const resourcePermissions = getResourcePermissions(resource)
|
||||
const actionTypes = ['read', 'write', 'create', 'delete'] as const
|
||||
const grantedActions = actionTypes.filter((action) =>
|
||||
resourcePermissions[action]?.some((p) => tokenPermissions.includes(p))
|
||||
)
|
||||
|
||||
if (grantedActions.length === 0) {
|
||||
return 'no access'
|
||||
}
|
||||
|
||||
if (grantedActions.length === 1) {
|
||||
return grantedActions[0]
|
||||
}
|
||||
|
||||
if (
|
||||
grantedActions.length === 2 &&
|
||||
grantedActions[0] === 'read' &&
|
||||
grantedActions[1] === 'write'
|
||||
) {
|
||||
return 'read-write'
|
||||
}
|
||||
|
||||
return grantedActions.join('-')
|
||||
}
|
||||
|
||||
export const formatAccessText = (action: string): string => {
|
||||
switch (action) {
|
||||
case 'no access':
|
||||
return 'No access'
|
||||
default:
|
||||
return action
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join('-')
|
||||
}
|
||||
}
|
||||
|
||||
export const getExpirationDate = (key: string): string | undefined => {
|
||||
switch (key) {
|
||||
case 'hour':
|
||||
return dayjs().add(1, 'hours').toISOString()
|
||||
case 'day':
|
||||
return dayjs().add(1, 'day').toISOString()
|
||||
case 'week':
|
||||
return dayjs().add(7, 'days').toISOString()
|
||||
case 'month':
|
||||
return dayjs().add(30, 'days').toISOString()
|
||||
case 'never':
|
||||
return undefined
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,100 +1,29 @@
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { useAccessTokenDeleteMutation } from 'data/access-tokens/access-tokens-delete-mutation'
|
||||
import { AccessToken, useAccessTokensQuery } from 'data/access-tokens/access-tokens-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { MoreVertical, Trash } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Skeleton,
|
||||
} from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableHeadSort,
|
||||
TableRow,
|
||||
} from 'ui/src/components/shadcn/ui/table'
|
||||
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
import { TableCell, TableRow } from 'ui/src/components/shadcn/ui/table'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
|
||||
const ACCESS_TOKEN_SORT_VALUES = [
|
||||
'created_at:asc',
|
||||
'created_at:desc',
|
||||
'last_used_at:asc',
|
||||
'last_used_at:desc',
|
||||
'expires_at:asc',
|
||||
'expires_at:desc',
|
||||
] as const
|
||||
|
||||
type AccessTokenSort = (typeof ACCESS_TOKEN_SORT_VALUES)[number]
|
||||
type AccessTokenSortColumn = AccessTokenSort extends `${infer Column}:${string}` ? Column : unknown
|
||||
type AccessTokenSortOrder = AccessTokenSort extends `${string}:${infer Order}` ? Order : unknown
|
||||
|
||||
const RowLoading = () => (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Skeleton className="w-40 max-w-40 h-4 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="w-60 max-w-60 h-4 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="max-w-32 h-4 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="max-w-32 h-4 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="w-4 h-4 rounded-md" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
const tableHeaderClass = 'text-left font-mono uppercase text-xs text-foreground-lighter py-2'
|
||||
|
||||
interface TableContainerProps {
|
||||
children: React.ReactNode
|
||||
sort: AccessTokenSort
|
||||
onSortChange: (column: AccessTokenSortColumn) => void
|
||||
}
|
||||
|
||||
const TableContainer = ({ children, sort, onSortChange }: TableContainerProps) => (
|
||||
<Card className="w-full overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<Table className="p-5 table-auto">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-200">
|
||||
<TableHead className={tableHeaderClass}>Token</TableHead>
|
||||
<TableHead className={tableHeaderClass}>
|
||||
<TableHeadSort column="last_used_at" currentSort={sort} onSortChange={onSortChange}>
|
||||
Last used
|
||||
</TableHeadSort>
|
||||
</TableHead>
|
||||
<TableHead className={tableHeaderClass}>
|
||||
<TableHeadSort column="expires_at" currentSort={sort} onSortChange={onSortChange}>
|
||||
Expires
|
||||
</TableHeadSort>
|
||||
</TableHead>
|
||||
<TableHead className={cn(tableHeaderClass, '!text-right')} />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{children}</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
import {
|
||||
ACCESS_TOKEN_SORT_VALUES,
|
||||
AccessTokenSort,
|
||||
AccessTokenSortColumn,
|
||||
} from './AccessToken.types'
|
||||
import { handleSortChange, filterAndSortTokens } from './AccessToken.utils'
|
||||
import { TableContainer } from './AccessTokenTable/TableContainer'
|
||||
import { RowLoading } from './AccessTokenTable/RowLoading'
|
||||
import { TokenNameCell, LastUsedCell, ExpiresCell } from './AccessTokenTable/TokenCells'
|
||||
|
||||
export interface AccessTokenListProps {
|
||||
searchString?: string
|
||||
@@ -122,70 +51,20 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
|
||||
},
|
||||
})
|
||||
|
||||
const handleSortChange = (column: AccessTokenSortColumn) => {
|
||||
const [currentCol, currentOrder] = sort.split(':') as [
|
||||
AccessTokenSortColumn,
|
||||
AccessTokenSortOrder,
|
||||
]
|
||||
if (currentCol === column) {
|
||||
if (currentOrder === 'asc') {
|
||||
setSort(`${column}:desc` as AccessTokenSort)
|
||||
} else {
|
||||
setSort('created_at:desc')
|
||||
}
|
||||
} else {
|
||||
setSort(`${column}:asc` as AccessTokenSort)
|
||||
}
|
||||
const onSortChange = (column: AccessTokenSortColumn) => {
|
||||
handleSortChange(sort, column, setSort)
|
||||
}
|
||||
|
||||
const onDeleteToken = async (tokenId: number) => {
|
||||
deleteToken({ id: tokenId })
|
||||
}
|
||||
|
||||
const filteredTokens = useMemo(() => {
|
||||
const filtered = !searchString
|
||||
? tokens
|
||||
: tokens?.filter((token) => {
|
||||
return token.name.toLowerCase().includes(searchString.toLowerCase())
|
||||
})
|
||||
|
||||
if (!filtered) return filtered
|
||||
|
||||
const [sortCol, sortOrder] = sort.split(':') as [AccessTokenSortColumn, AccessTokenSortOrder]
|
||||
const orderMultiplier = sortOrder === 'asc' ? 1 : -1
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (sortCol === 'created_at') {
|
||||
return (
|
||||
(new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * orderMultiplier
|
||||
)
|
||||
}
|
||||
if (sortCol === 'last_used_at') {
|
||||
if (!a.last_used_at && !b.last_used_at) return 0
|
||||
if (!a.last_used_at) return 1
|
||||
if (!b.last_used_at) return -1
|
||||
return (
|
||||
(new Date(a.last_used_at).getTime() - new Date(b.last_used_at).getTime()) *
|
||||
orderMultiplier
|
||||
)
|
||||
}
|
||||
if (sortCol === 'expires_at') {
|
||||
if (!a.expires_at && !b.expires_at) return 0
|
||||
if (!a.expires_at) return 1
|
||||
if (!b.expires_at) return -1
|
||||
return (
|
||||
(new Date(a.expires_at).getTime() - new Date(b.expires_at).getTime()) * orderMultiplier
|
||||
)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}, [tokens, searchString, sort])
|
||||
const filteredTokens = useMemo(
|
||||
() => filterAndSortTokens(tokens, searchString, sort),
|
||||
[tokens, searchString, sort]
|
||||
)
|
||||
|
||||
const empty = filteredTokens?.length === 0 && !isLoading
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<TableContainer sort={sort} onSortChange={handleSortChange}>
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="p-0">
|
||||
<AlertError
|
||||
@@ -201,7 +80,7 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<TableContainer sort={sort} onSortChange={handleSortChange}>
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
<RowLoading />
|
||||
<RowLoading />
|
||||
</TableContainer>
|
||||
@@ -210,7 +89,7 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
|
||||
|
||||
if (empty) {
|
||||
return (
|
||||
<TableContainer sort={sort} onSortChange={handleSortChange}>
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-12">
|
||||
<p className="text-sm text-center text-foreground">No access tokens found</p>
|
||||
@@ -225,93 +104,51 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer sort={sort} onSortChange={handleSortChange}>
|
||||
{filteredTokens?.map((x) => {
|
||||
return (
|
||||
<TableRow key={x.token_alias}>
|
||||
<TableCell className="w-auto max-w-96">
|
||||
<p className="truncate" title={x.name}>
|
||||
{x.name}
|
||||
</p>
|
||||
<p
|
||||
className="font-mono text-foreground-lighter truncate text-xs mt-1 max-w-32 sm:max-w-48 lg:max-w-full"
|
||||
title={x.token_alias}
|
||||
>
|
||||
{x.token_alias}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light min-w-28">
|
||||
{x.last_used_at ? (
|
||||
<TimestampInfo
|
||||
utcTimestamp={x.last_used_at}
|
||||
label={dayjs(x.last_used_at).fromNow()}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-foreground-light text-sm">Never used</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="min-w-28 text-foreground-light">
|
||||
{x.expires_at ? (
|
||||
dayjs(x.expires_at).isBefore(dayjs()) ? (
|
||||
<TimestampInfo
|
||||
utcTimestamp={x.expires_at}
|
||||
label="Expired"
|
||||
className="text-sm"
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
{filteredTokens?.map((x) => (
|
||||
<TableRow key={x.token_alias}>
|
||||
<TokenNameCell name={x.name} tokenAlias={x.token_alias} />
|
||||
<LastUsedCell lastUsedAt={x.last_used_at} />
|
||||
<ExpiresCell expiresAt={x.expires_at} />
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
title="More options"
|
||||
className="w-7"
|
||||
icon={<MoreVertical />}
|
||||
/>
|
||||
) : (
|
||||
<TimestampInfo
|
||||
utcTimestamp={x.expires_at}
|
||||
label={dayjs(x.expires_at).format('DD MMM YYYY')}
|
||||
className="text-sm"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<p className="text-foreground-light text-sm">Never</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
title="More options"
|
||||
className="px-1"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
icon={<MoreVertical />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2"
|
||||
onClick={() => {
|
||||
setToken(x)
|
||||
setIsOpen(true)
|
||||
}}
|
||||
>
|
||||
<Trash size={12} />
|
||||
<p>Delete token</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2"
|
||||
onClick={() => {
|
||||
setToken(x)
|
||||
setIsOpen(true)
|
||||
}}
|
||||
>
|
||||
<Trash size={12} />
|
||||
<p>Delete token</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableContainer>
|
||||
|
||||
<ConfirmationModal
|
||||
visible={isOpen}
|
||||
variant={'destructive'}
|
||||
variant="destructive"
|
||||
title="Confirm to delete"
|
||||
confirmLabel="Delete"
|
||||
confirmLabelLoading="Deleting"
|
||||
onCancel={() => setIsOpen(false)}
|
||||
onConfirm={() => {
|
||||
if (token) onDeleteToken(token.id)
|
||||
if (token) deleteToken({ id: token.id })
|
||||
}}
|
||||
>
|
||||
<p className="py-4 text-sm text-foreground-light">
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { TokenPermissionsSection } from './TokenPermissionSection'
|
||||
import { useGroupedPermissions } from '../hooks/useGroupedPermissions'
|
||||
|
||||
interface AccessTokenNewBannerProps<T> {
|
||||
token: T
|
||||
onClose: () => void
|
||||
getTokenValue: (token: T) => string
|
||||
getTokenPermissions?: (token: T) => string[] | undefined
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const AccessTokenNewBanner = <T,>({
|
||||
token,
|
||||
onClose,
|
||||
getTokenValue,
|
||||
getTokenPermissions,
|
||||
title = 'Successfully generated a new token!',
|
||||
description = 'Copy this access token and store it in a secure place. You will not be able to see it again.',
|
||||
}: AccessTokenNewBannerProps<T>) => {
|
||||
const tokenPermissions = getTokenPermissions?.(token)
|
||||
const { groupedPermissions, totalCount } = useGroupedPermissions(tokenPermissions)
|
||||
|
||||
return (
|
||||
<Admonition
|
||||
type="tip"
|
||||
title={title}
|
||||
className="mb-6 relative"
|
||||
actions={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X />}
|
||||
className="w-7 h-7 absolute top-2.5 right-2.5"
|
||||
onClick={onClose}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground-light">{description}</p>
|
||||
<div className="w-full pb-2">
|
||||
<Input
|
||||
copy
|
||||
readOnly
|
||||
size="small"
|
||||
className="w-full input-mono"
|
||||
id="access-token-value"
|
||||
value={getTokenValue(token)}
|
||||
onChange={() => {}}
|
||||
onCopy={() => toast.success('Token copied to clipboard')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tokenPermissions && tokenPermissions.length > 0 && (
|
||||
<TokenPermissionsSection
|
||||
groupedPermissions={groupedPermissions}
|
||||
totalCount={totalCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
interface PermissionsListProps {
|
||||
groupedPermissions: Record<string, string[]>
|
||||
}
|
||||
|
||||
export const PermissionsList = ({ groupedPermissions }: PermissionsListProps) => {
|
||||
return (
|
||||
<div className="gap-2 flex flex-col">
|
||||
{Object.entries(groupedPermissions).map(([accessLevel, resources]) => (
|
||||
<div key={accessLevel} className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs text-foreground-lighter font-mono uppercase tracking-wide">
|
||||
{accessLevel}:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{resources.map((resource, index) => (
|
||||
<span
|
||||
key={`${accessLevel}-${resource}`}
|
||||
className="text-xs text-foreground capitalize"
|
||||
>
|
||||
{resource}
|
||||
{index < resources.length - 1 ? ',' : '.'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import {
|
||||
Collapsible_Shadcn_ as Collapsible,
|
||||
CollapsibleContent_Shadcn_ as CollapsibleContent,
|
||||
CollapsibleTrigger_Shadcn_ as CollapsibleTrigger,
|
||||
} from 'ui'
|
||||
import { PermissionsList } from './PermissionList'
|
||||
|
||||
const PERMISSIONS_COLLAPSE_THRESHOLD = 5
|
||||
|
||||
interface TokenPermissionsSectionProps {
|
||||
groupedPermissions: Record<string, string[]>
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export const TokenPermissionsSection = ({
|
||||
groupedPermissions,
|
||||
totalCount,
|
||||
}: TokenPermissionsSectionProps) => {
|
||||
const [permissionsOpen, setPermissionsOpen] = useState(false)
|
||||
const shouldCollapse = totalCount > PERMISSIONS_COLLAPSE_THRESHOLD
|
||||
|
||||
if (totalCount === 0) return null
|
||||
|
||||
return (
|
||||
<div className="pt-4 border-t border-default">
|
||||
{shouldCollapse ? (
|
||||
<Collapsible open={permissionsOpen} onOpenChange={setPermissionsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
className="w-full justify-start px-0.5 h-auto text-sm font-medium text-foreground-light hover:text-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{permissionsOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>Permissions assigned to this token ({totalCount})</span>
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-3 transition-all data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
|
||||
<PermissionsList groupedPermissions={groupedPermissions} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-sm font-medium mb-3">Permissions assigned to this token:</h3>
|
||||
<PermissionsList groupedPermissions={groupedPermissions} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Skeleton } from 'ui'
|
||||
import { TableCell, TableRow } from 'ui/src/components/shadcn/ui/table'
|
||||
|
||||
export const RowLoading = () => (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Skeleton className="w-40 max-w-40 h-4 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="w-60 max-w-60 h-4 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="max-w-32 h-4 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="max-w-32 h-4 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="w-4 h-4 rounded-md" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { Card, CardContent, cn } from 'ui'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableHeadSort,
|
||||
TableRow,
|
||||
} from 'ui/src/components/shadcn/ui/table'
|
||||
import { AccessTokenSort, AccessTokenSortColumn } from '../AccessToken.types'
|
||||
|
||||
const tableHeaderClass = 'text-left font-mono uppercase text-xs text-foreground-lighter py-2'
|
||||
|
||||
interface TableContainerProps {
|
||||
children: React.ReactNode
|
||||
sort: AccessTokenSort
|
||||
onSortChange: (column: AccessTokenSortColumn) => void
|
||||
}
|
||||
|
||||
export const TableContainer = ({ children, sort, onSortChange }: TableContainerProps) => (
|
||||
<Card className="w-full overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<Table className="p-5 table-auto">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-200">
|
||||
<TableHead className={tableHeaderClass}>Token</TableHead>
|
||||
<TableHead className={tableHeaderClass}>
|
||||
<TableHeadSort column="last_used_at" currentSort={sort} onSortChange={onSortChange}>
|
||||
Last used
|
||||
</TableHeadSort>
|
||||
</TableHead>
|
||||
<TableHead className={tableHeaderClass}>
|
||||
<TableHeadSort column="expires_at" currentSort={sort} onSortChange={onSortChange}>
|
||||
Expires
|
||||
</TableHeadSort>
|
||||
</TableHead>
|
||||
<TableHead className={cn(tableHeaderClass, '!text-right')} />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{children}</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { TableCell } from 'ui/src/components/shadcn/ui/table'
|
||||
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
|
||||
interface TokenNameCellProps {
|
||||
name: string
|
||||
tokenAlias: string
|
||||
}
|
||||
|
||||
export const TokenNameCell = ({ name, tokenAlias }: TokenNameCellProps) => (
|
||||
<TableCell className="w-auto max-w-96">
|
||||
<p className="truncate" title={name}>
|
||||
{name}
|
||||
</p>
|
||||
<p
|
||||
className="font-mono text-foreground-lighter truncate text-xs mt-1 max-w-32 sm:max-w-48 lg:max-w-full"
|
||||
title={tokenAlias}
|
||||
>
|
||||
{tokenAlias}
|
||||
</p>
|
||||
</TableCell>
|
||||
)
|
||||
|
||||
interface LastUsedCellProps {
|
||||
lastUsedAt: string | null | undefined
|
||||
}
|
||||
|
||||
export const LastUsedCell = ({ lastUsedAt }: LastUsedCellProps) => (
|
||||
<TableCell className="text-foreground-light min-w-28">
|
||||
{lastUsedAt ? (
|
||||
<TimestampInfo
|
||||
utcTimestamp={lastUsedAt}
|
||||
label={dayjs(lastUsedAt).fromNow()}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-foreground-light text-sm">Never used</p>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
|
||||
interface ExpiresCellProps {
|
||||
expiresAt: string | null | undefined
|
||||
}
|
||||
|
||||
export const ExpiresCell = ({ expiresAt }: ExpiresCellProps) => (
|
||||
<TableCell className="min-w-28 text-foreground-light">
|
||||
{expiresAt ? (
|
||||
dayjs(expiresAt).isBefore(dayjs()) ? (
|
||||
<TimestampInfo utcTimestamp={expiresAt} label="Expired" className="text-sm" />
|
||||
) : (
|
||||
<TimestampInfo
|
||||
utcTimestamp={expiresAt}
|
||||
label={dayjs(expiresAt).format('DD MMM YYYY')}
|
||||
className="text-sm"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<p className="text-foreground-light text-sm">Never</p>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export const NON_EXPIRING_TOKEN_VALUE = 'never'
|
||||
export const CUSTOM_EXPIRY_VALUE = 'custom'
|
||||
|
||||
export const ExpiresAtOptions: Record<string, { value: string; label: string }> = {
|
||||
hour: {
|
||||
value: dayjs().add(1, 'hour').toISOString(),
|
||||
label: '1 hour',
|
||||
},
|
||||
day: {
|
||||
value: dayjs().add(1, 'days').toISOString(),
|
||||
label: '1 day',
|
||||
},
|
||||
week: {
|
||||
value: dayjs().add(7, 'days').toISOString(),
|
||||
label: '7 days',
|
||||
},
|
||||
month: {
|
||||
value: dayjs().add(30, 'days').toISOString(),
|
||||
label: '30 days',
|
||||
},
|
||||
never: {
|
||||
value: NON_EXPIRING_TOKEN_VALUE,
|
||||
label: 'Never',
|
||||
},
|
||||
custom: {
|
||||
value: CUSTOM_EXPIRY_VALUE,
|
||||
label: 'Custom',
|
||||
},
|
||||
}
|
||||
+5
-4
@@ -8,13 +8,14 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import { NewAccessTokenDialog } from './NewAccessTokenDialog'
|
||||
import { NewTokenDialog } from './NewTokenDialog'
|
||||
import { type NewAccessToken } from 'data/access-tokens/access-tokens-create-mutation'
|
||||
|
||||
export interface NewAccessTokenButtonProps {
|
||||
onCreateToken: (token: any) => void
|
||||
onCreateToken: (token: NewAccessToken) => void
|
||||
}
|
||||
|
||||
export const NewAccessTokenButton = ({ onCreateToken }: NewAccessTokenButtonProps) => {
|
||||
export const NewTokenButton = ({ onCreateToken }: NewAccessTokenButtonProps) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [tokenScope, setTokenScope] = useState<'V0' | undefined>(undefined)
|
||||
|
||||
@@ -55,7 +56,7 @@ export const NewAccessTokenButton = ({ onCreateToken }: NewAccessTokenButtonProp
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<NewAccessTokenDialog
|
||||
<NewTokenDialog
|
||||
open={visible}
|
||||
onOpenChange={setVisible}
|
||||
tokenScope={tokenScope}
|
||||
+20
-14
@@ -7,7 +7,10 @@ import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { DatePicker } from 'components/ui/DatePicker'
|
||||
import { useAccessTokenCreateMutation } from 'data/access-tokens/access-tokens-create-mutation'
|
||||
import {
|
||||
useAccessTokenCreateMutation,
|
||||
type NewAccessToken,
|
||||
} from 'data/access-tokens/access-tokens-create-mutation'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -32,9 +35,10 @@ import { Admonition } from 'ui-patterns'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import {
|
||||
CUSTOM_EXPIRY_VALUE,
|
||||
ExpiresAtOptions,
|
||||
EXPIRES_AT_OPTIONS,
|
||||
NON_EXPIRING_TOKEN_VALUE,
|
||||
} from './AccessTokens.constants'
|
||||
} from '../AccessToken.constants'
|
||||
import { getExpirationDate } from '../AccessToken.utils'
|
||||
|
||||
const formId = 'new-access-token-form'
|
||||
|
||||
@@ -50,10 +54,10 @@ export interface NewAccessTokenDialogProps {
|
||||
open: boolean
|
||||
tokenScope: 'V0' | undefined
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreateToken: (token: any) => void
|
||||
onCreateToken: (token: NewAccessToken) => void
|
||||
}
|
||||
|
||||
export const NewAccessTokenDialog = ({
|
||||
export const NewTokenDialog = ({
|
||||
open,
|
||||
tokenScope,
|
||||
onOpenChange,
|
||||
@@ -64,18 +68,18 @@ export const NewAccessTokenDialog = ({
|
||||
|
||||
const form = useForm<z.infer<typeof TokenSchema>>({
|
||||
resolver: zodResolver(TokenSchema),
|
||||
defaultValues: { tokenName: '', expiresAt: ExpiresAtOptions['month'].value },
|
||||
defaultValues: { tokenName: '', expiresAt: EXPIRES_AT_OPTIONS['month'].value },
|
||||
mode: 'onChange',
|
||||
})
|
||||
const { mutate: createAccessToken, isPending } = useAccessTokenCreateMutation()
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof TokenSchema>> = async (values) => {
|
||||
// Use custom date if custom option is selected
|
||||
let expiresAt = values.expiresAt
|
||||
let expiresAt: string | undefined
|
||||
|
||||
if (isCustomExpiry && customExpiryDate) {
|
||||
// Use the date from the TokensDatePicker
|
||||
expiresAt = customExpiryDate.date
|
||||
} else {
|
||||
expiresAt = getExpirationDate(values.expiresAt || '')
|
||||
}
|
||||
|
||||
createAccessToken(
|
||||
@@ -201,11 +205,13 @@ export const NewAccessTokenDialog = ({
|
||||
<SelectValue_Shadcn_ placeholder="Expires at" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{Object.values(ExpiresAtOptions).map((option) => (
|
||||
<SelectItem_Shadcn_ key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
{Object.values(EXPIRES_AT_OPTIONS).map(
|
||||
(option: { value: string; label: string }) => (
|
||||
<SelectItem_Shadcn_ key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem_Shadcn_>
|
||||
)
|
||||
)}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
@@ -1,132 +0,0 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { screen, waitFor } from '@testing-library/dom'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { render } from 'tests/helpers'
|
||||
import { addAPIMock } from 'tests/lib/msw'
|
||||
import { NewAccessTokenButton } from './NewAccessTokenButton'
|
||||
|
||||
describe(`NewAccessTokenButton`, () => {
|
||||
beforeEach(() => {
|
||||
addAPIMock({
|
||||
method: `post`,
|
||||
path: `/platform/profile/access-tokens`,
|
||||
response: {
|
||||
name: faker.lorem.word(),
|
||||
scope: faker.helpers.arrayElement(['V0', undefined]),
|
||||
created_at: faker.date.past().toISOString(),
|
||||
expires_at: null,
|
||||
id: faker.number.int(),
|
||||
token_alias: faker.lorem.words(),
|
||||
token: faker.lorem.words(),
|
||||
last_used_at: faker.date.recent().toISOString(),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it(`generates regular tokens`, async () => {
|
||||
const onCreateToken = vi.fn()
|
||||
render(<NewAccessTokenButton onCreateToken={onCreateToken} />)
|
||||
|
||||
const dialogTrigger = screen.getByRole(`button`, { name: `Generate new token` })
|
||||
await userEvent.click(dialogTrigger)
|
||||
|
||||
// Fill in the token name
|
||||
const nameInput = screen.getByLabelText(`Name`)
|
||||
await userEvent.type(nameInput, `test`)
|
||||
|
||||
// Verify the form is open and the name field works
|
||||
expect(nameInput).toHaveValue(`test`)
|
||||
expect(screen.getByRole(`button`, { name: `Generate token` })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it(`generates experimental tokens`, async () => {
|
||||
const onCreateToken = vi.fn()
|
||||
render(<NewAccessTokenButton onCreateToken={onCreateToken} />)
|
||||
|
||||
const dropdownTrigger = screen.getByTitle(`Choose token scope`)
|
||||
await userEvent.click(dropdownTrigger)
|
||||
|
||||
const experimentalMenuItem = await screen.findByRole(`menuitem`, {
|
||||
name: `Generate token for experimental API`,
|
||||
})
|
||||
await userEvent.click(experimentalMenuItem)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole(`heading`, { name: `Generate token for experimental API` })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
`The experimental API provides additional endpoints which allows you to manage your organizations and projects.`
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Fill in the token name
|
||||
const nameInput = screen.getByLabelText(`Name`)
|
||||
await userEvent.type(nameInput, `test`)
|
||||
|
||||
// Verify the form is open and the name field works
|
||||
expect(nameInput).toHaveValue(`test`)
|
||||
expect(screen.getByRole(`button`, { name: `Generate token` })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it(`resets the form on close/cancel`, async () => {
|
||||
render(<NewAccessTokenButton onCreateToken={vi.fn()} />)
|
||||
|
||||
// pass 1: open dialog and fill form
|
||||
const dialogTrigger = screen.getByRole(`button`, { name: `Generate new token` })
|
||||
await userEvent.click(dialogTrigger)
|
||||
|
||||
let nameInput = screen.getByLabelText(`Name`)
|
||||
await userEvent.type(nameInput, `cancel button test`)
|
||||
expect(nameInput).toHaveValue(`cancel button test`)
|
||||
|
||||
// reset the form by pressing the cancel button
|
||||
const cancelButton = screen.getByRole(`button`, { name: `Cancel` })
|
||||
await userEvent.click(cancelButton)
|
||||
|
||||
// pass 2: check that the form is reset, then fill it again
|
||||
await userEvent.click(dialogTrigger)
|
||||
|
||||
nameInput = screen.getByLabelText(`Name`)
|
||||
expect(nameInput).not.toHaveValue(`cancel button test`)
|
||||
|
||||
await userEvent.type(nameInput, `close modal test`)
|
||||
expect(nameInput).toHaveValue(`close modal test`)
|
||||
|
||||
// reset the form by closing the dialog
|
||||
await userEvent.keyboard(`{Escape}`)
|
||||
|
||||
// pass 3: check that the form has been reset again
|
||||
await userEvent.click(dialogTrigger)
|
||||
|
||||
nameInput = screen.getByLabelText(`Name`)
|
||||
expect(nameInput).not.toHaveValue(`close modal test`)
|
||||
})
|
||||
|
||||
// it(`shows validation error when no permissions are configured`, async () => {
|
||||
// const onCreateToken = vi.fn()
|
||||
// render(<NewAccessTokenButton onCreateToken={onCreateToken} />)
|
||||
|
||||
// const dialogTrigger = screen.getByRole(`button`, { name: `Generate new token` })
|
||||
// await userEvent.click(dialogTrigger)
|
||||
|
||||
// // Fill in the token name
|
||||
// const nameInput = screen.getByLabelText(`Name`)
|
||||
// await userEvent.type(nameInput, `test`)
|
||||
|
||||
// // Try to submit without adding permissions
|
||||
// const generateButton = screen.getByRole(`button`, { name: `Generate token` })
|
||||
// await userEvent.click(generateButton)
|
||||
|
||||
// // The form should not submit and onCreateToken should not be called
|
||||
// // because validation prevents submission when no permissions are configured
|
||||
// expect(onCreateToken).not.toHaveBeenCalled()
|
||||
|
||||
// // The form should still be open and the button should still be enabled
|
||||
// expect(screen.getByRole(`button`, { name: `Generate token` })).toBeInTheDocument()
|
||||
// })
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { NewAccessToken } from 'data/access-tokens/access-tokens-create-mutation'
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
|
||||
interface NewTokenBannerProps {
|
||||
token: NewAccessToken
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const NewTokenBanner = ({ token, onClose }: NewTokenBannerProps) => {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
className="relative mb-6"
|
||||
title="Successfully generated a new token!"
|
||||
description={
|
||||
<div className="w-full space-y-2">
|
||||
<p className="text-sm">
|
||||
Do copy this access token and store it in a secure place - you will not be able to see
|
||||
it again.
|
||||
</p>
|
||||
<div className="max-w-xl">
|
||||
<Input
|
||||
copy
|
||||
readOnly
|
||||
size="small"
|
||||
className="max-w-xl input-mono"
|
||||
value={token.token}
|
||||
onChange={() => {}}
|
||||
onCopy={() => toast.success('Token copied to clipboard')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X />}
|
||||
className="w-7 h-7 absolute top-3 right-3"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Control, ControllerRenderProps } from 'react-hook-form'
|
||||
|
||||
import { DatePicker } from 'components/ui/DatePicker'
|
||||
import {
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Select_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
SelectValue_Shadcn_,
|
||||
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'
|
||||
|
||||
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_Shadcn_
|
||||
key="tokenName"
|
||||
name="tokenName"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="tokenName" label="Name">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
id="tokenName"
|
||||
{...field}
|
||||
placeholder="Provide a name for your token"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField_Shadcn_
|
||||
key="expiresAt"
|
||||
name="expiresAt"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="expiresAt" label="Expires in">
|
||||
<div className="flex gap-2">
|
||||
<FormControl_Shadcn_ className="flex-grow">
|
||||
<Select_Shadcn_
|
||||
value={field.value}
|
||||
onValueChange={(value) => handleExpiryChange(value, field)}
|
||||
>
|
||||
<SelectTrigger_Shadcn_>
|
||||
<SelectValue_Shadcn_ placeholder="Expires at" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{Object.values(EXPIRES_AT_OPTIONS).map(
|
||||
(option: { value: string; label: string }) => (
|
||||
<SelectItem_Shadcn_ key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem_Shadcn_>
|
||||
)
|
||||
)}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { Path, PathValue } from 'react-hook-form'
|
||||
import {
|
||||
Button,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Command_Shadcn_,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
Checkbox_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Plus, Key } from 'lucide-react'
|
||||
import { ACCESS_TOKEN_RESOURCES } from '../../../AccessToken.constants'
|
||||
import {
|
||||
PermissionResource,
|
||||
PermissionRow,
|
||||
PermissionsFormValues,
|
||||
PermissionResourceSelectorProps,
|
||||
} from './Permissions.types'
|
||||
import { togglePermissionResource } from './Permissions.utils'
|
||||
|
||||
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_Shadcn_ open={open} onOpenChange={onOpenChange} modal={true}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button type="default" size="tiny" icon={<Plus className="h-4 w-4" />}>
|
||||
Add permission
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="w-[400px] p-0" align={align}>
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_ placeholder="Search resources..." />
|
||||
<CommandList_Shadcn_>
|
||||
<CommandEmpty_Shadcn_>No resources found.</CommandEmpty_Shadcn_>
|
||||
|
||||
<CommandGroup_Shadcn_ 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_Shadcn_
|
||||
key={resource.resource}
|
||||
value={`${resource.resource} ${resource.title}`}
|
||||
onSelect={() => handleToggleResource(resource)}
|
||||
className="text-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<Checkbox_Shadcn_
|
||||
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_Shadcn_>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import { Path, PathValue } from 'react-hook-form'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Checkbox_Shadcn_,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { X, RotateCcw } from 'lucide-react'
|
||||
import { ACCESS_TOKEN_RESOURCES } from '../../../AccessToken.constants'
|
||||
import { PermissionRow, PermissionsFormValues, PermissionsProps } from './Permissions.types'
|
||||
import { sortActions } from './Permissions.utils'
|
||||
import { formatAccessText } from '../../../AccessToken.utils'
|
||||
import { PermissionResourceSelector } from './PermissionResourceSelector'
|
||||
|
||||
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
|
||||
type="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_Shadcn_>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type="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_Shadcn_>
|
||||
<PopoverContent_Shadcn_ 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_Shadcn_
|
||||
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_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)}
|
||||
<Button
|
||||
type="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
@@ -0,0 +1,33 @@
|
||||
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'
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { PermissionResource, PermissionRow } from './Permissions.types'
|
||||
|
||||
const getBestAction = (actions: string[]): string => {
|
||||
const availableActions = actions.filter((a) => a !== 'no access')
|
||||
|
||||
if (availableActions.length === 0) return 'no access'
|
||||
|
||||
const priority = ['read', 'write', 'create', 'delete', 'read-write']
|
||||
|
||||
for (const priorityAction of priority) {
|
||||
if (availableActions.includes(priorityAction)) {
|
||||
return priorityAction
|
||||
}
|
||||
}
|
||||
|
||||
return availableActions[0]
|
||||
}
|
||||
|
||||
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 }]
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { FormControl_Shadcn_, FormField_Shadcn_, cn } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { useOrgAndProjectData } from '../../../hooks/useOrgAndProjectData'
|
||||
import { ResourceAccessProps } from './ResourceAccess.types'
|
||||
import { ResourceMultiSelector } from './ResourceMultiSelector'
|
||||
import { ResourceOption } from './ResourceOption'
|
||||
|
||||
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_Shadcn_
|
||||
key="resourceAccess"
|
||||
name="resourceAccess"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="resourceAccess" label="Resource access">
|
||||
<FormControl_Shadcn_>
|
||||
<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_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
{resourceAccess === 'selected-orgs' && (
|
||||
<FormField_Shadcn_
|
||||
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_Shadcn_
|
||||
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>
|
||||
)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { Control, ControllerRenderProps, UseFormSetValue } from 'react-hook-form'
|
||||
import { TokenFormValues } from '../../../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
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from 'react'
|
||||
import { FormControl_Shadcn_ } 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_Shadcn_ 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_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { cn } from 'ui'
|
||||
import { Box } from 'lucide-react'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
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>
|
||||
)
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from 'ui'
|
||||
import { type NewScopedAccessToken } from 'data/scoped-access-tokens/scoped-access-token-create-mutation'
|
||||
import { NewScopedTokenSheet } from './NewScopedTokenSheet'
|
||||
|
||||
export interface NewScopedTokenButtonProps {
|
||||
onCreateToken: (token: NewScopedAccessToken) => void
|
||||
}
|
||||
|
||||
export const NewScopedTokenButton = ({ onCreateToken }: NewScopedTokenButtonProps) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setVisible(true)
|
||||
}}
|
||||
>
|
||||
Generate new token
|
||||
</Button>
|
||||
|
||||
<NewScopedTokenSheet
|
||||
visible={visible}
|
||||
onOpenChange={setVisible}
|
||||
tokenScope={undefined}
|
||||
onCreateToken={onCreateToken}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import dayjs from 'dayjs'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { type SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
useAccessTokenCreateMutation,
|
||||
type NewScopedAccessToken,
|
||||
type ScopedAccessTokenCreateVariables,
|
||||
} from 'data/scoped-access-tokens/scoped-access-token-create-mutation'
|
||||
import {
|
||||
Button,
|
||||
Form_Shadcn_,
|
||||
ScrollArea,
|
||||
Separator,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { BasicInfo } from './Form/BasicInfo'
|
||||
import { Permissions } from './Form/Permissions/Permissions'
|
||||
import { ResourceAccess } from './Form/ResourceAccess/ResourceAccess'
|
||||
import {
|
||||
CUSTOM_EXPIRY_VALUE,
|
||||
EXPIRES_AT_OPTIONS,
|
||||
type ScopedAccessTokenPermission,
|
||||
} from '../AccessToken.constants'
|
||||
import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData'
|
||||
import { mapPermissionToFGA, getExpirationDate } from '../AccessToken.utils'
|
||||
import { TokenSchema, type TokenFormValues } from '../AccessToken.schemas'
|
||||
|
||||
export interface NewScopedTokenSheetProps {
|
||||
visible: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
tokenScope: 'V0' | undefined
|
||||
onCreateToken: (token: NewScopedAccessToken) => void
|
||||
}
|
||||
|
||||
export const NewScopedTokenSheet = ({
|
||||
visible,
|
||||
onOpenChange,
|
||||
tokenScope,
|
||||
onCreateToken,
|
||||
}: NewScopedTokenSheetProps) => {
|
||||
const [resourceSearchOpen, setResourceSearchOpen] = useState(false)
|
||||
const { organizations, projects } = useOrgAndProjectData()
|
||||
|
||||
const form = useForm<TokenFormValues>({
|
||||
resolver: zodResolver(TokenSchema),
|
||||
defaultValues: {
|
||||
tokenName: '',
|
||||
expiresAt: EXPIRES_AT_OPTIONS['month'].value,
|
||||
customExpiryDate: undefined,
|
||||
resourceAccess: 'all-orgs',
|
||||
selectedOrganizations: [],
|
||||
selectedProjects: [],
|
||||
permissionRows: [],
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
const { mutate: createAccessToken, isPending } = useAccessTokenCreateMutation()
|
||||
|
||||
const resourceAccess = form.watch('resourceAccess')
|
||||
const expiresAt = form.watch('expiresAt')
|
||||
const permissionRows = form.watch('permissionRows') || []
|
||||
|
||||
const onSubmit: SubmitHandler<TokenFormValues> = async (values) => {
|
||||
if (!permissionRows || permissionRows.length === 0) {
|
||||
toast.error('Please configure at least one permission.')
|
||||
return
|
||||
}
|
||||
|
||||
const hasValidPermissions = permissionRows.every(
|
||||
(row) => row.resource && row.actions && row.actions.length > 0
|
||||
)
|
||||
if (!hasValidPermissions) {
|
||||
toast.error('Please ensure all permissions have both resource and action selected.')
|
||||
return
|
||||
}
|
||||
|
||||
if (values.resourceAccess === 'selected-orgs') {
|
||||
const selectedOrgs = values.selectedOrganizations || []
|
||||
|
||||
if (selectedOrgs.length === 0) {
|
||||
toast.error('Please select at least one organization.')
|
||||
return
|
||||
}
|
||||
|
||||
const availableOrgSlugs = organizations.map((org) => org.slug)
|
||||
const invalidOrgs = selectedOrgs.filter((slug) => !availableOrgSlugs.includes(slug))
|
||||
|
||||
if (invalidOrgs.length > 0) {
|
||||
toast.error(
|
||||
`You don't have access to the following organization(s): ${invalidOrgs.join(', ')}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (values.resourceAccess === 'selected-projects') {
|
||||
const selectedProjects = values.selectedProjects || []
|
||||
|
||||
if (selectedProjects.length === 0) {
|
||||
toast.error('Please select at least one project.')
|
||||
return
|
||||
}
|
||||
|
||||
const availableProjectRefs = projects.map((project) => project.ref)
|
||||
const invalidProjects = selectedProjects.filter((ref) => !availableProjectRefs.includes(ref))
|
||||
|
||||
if (invalidProjects.length > 0) {
|
||||
toast.error(
|
||||
`You don't have access to the following project(s): ${invalidProjects.join(', ')}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const finalExpiresAt =
|
||||
values.expiresAt === CUSTOM_EXPIRY_VALUE
|
||||
? values.customExpiryDate
|
||||
: getExpirationDate(values.expiresAt || '')
|
||||
|
||||
const permissions = permissionRows
|
||||
.flatMap((row) => {
|
||||
const { resource, actions } = row
|
||||
return actions.flatMap((action) => mapPermissionToFGA(resource, action))
|
||||
})
|
||||
.filter(Boolean) as ScopedAccessTokenPermission[]
|
||||
|
||||
if (!permissions || permissions.length === 0) {
|
||||
toast.error('Please configure at least one valid permission.')
|
||||
return
|
||||
}
|
||||
|
||||
const finalPayload: ScopedAccessTokenCreateVariables = {
|
||||
name: values.tokenName,
|
||||
permissions,
|
||||
}
|
||||
|
||||
if (finalExpiresAt) {
|
||||
finalPayload.expires_at = finalExpiresAt
|
||||
}
|
||||
|
||||
if (
|
||||
values.resourceAccess === 'selected-orgs' &&
|
||||
values.selectedOrganizations &&
|
||||
values.selectedOrganizations.length > 0
|
||||
) {
|
||||
finalPayload.organization_slugs = values.selectedOrganizations
|
||||
} else if (
|
||||
values.resourceAccess === 'selected-projects' &&
|
||||
values.selectedProjects &&
|
||||
values.selectedProjects.length > 0
|
||||
) {
|
||||
finalPayload.project_refs = values.selectedProjects
|
||||
}
|
||||
|
||||
if (!finalPayload.name || finalPayload.name.trim() === '') {
|
||||
toast.error('Please enter a token name.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!finalPayload.permissions || finalPayload.permissions.length === 0) {
|
||||
toast.error('Please configure at least one permission.')
|
||||
return
|
||||
}
|
||||
|
||||
createAccessToken(finalPayload, {
|
||||
onSuccess: (data) => {
|
||||
toast.success('Access token created successfully')
|
||||
onCreateToken(data)
|
||||
handleClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message && error.message.includes("don't have access")) {
|
||||
toast.error(
|
||||
`Access Error: ${error.message}. Please verify you have access to the selected resources.`
|
||||
)
|
||||
} else {
|
||||
toast.error(`Failed to create access token: ${error.message}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset({
|
||||
tokenName: '',
|
||||
expiresAt: EXPIRES_AT_OPTIONS['month'].value,
|
||||
customExpiryDate: undefined,
|
||||
resourceAccess: 'all-orgs',
|
||||
selectedOrganizations: [],
|
||||
selectedProjects: [],
|
||||
permissionRows: [],
|
||||
})
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleCustomDateChange = useCallback(
|
||||
(date: { date: string } | undefined) => {
|
||||
form.setValue('customExpiryDate', date?.date, { shouldValidate: true })
|
||||
},
|
||||
[form]
|
||||
)
|
||||
|
||||
const handleCustomExpiryChange = useCallback(
|
||||
(isCustom: boolean) => {
|
||||
if (isCustom && !form.getValues('customExpiryDate')) {
|
||||
form.setValue('customExpiryDate', dayjs().endOf('day').toISOString(), {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
if (!isCustom) {
|
||||
form.setValue('customExpiryDate', undefined, { shouldValidate: true })
|
||||
}
|
||||
},
|
||||
[form]
|
||||
)
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={visible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleClose()
|
||||
} else {
|
||||
onOpenChange(open)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
showClose={false}
|
||||
size="default"
|
||||
className="!min-w-[600px] flex flex-col h-full gap-0"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
A form to generate a new scoped access token.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 max-h-[calc(100vh-116px)]">
|
||||
<div className="flex flex-col overflow-visible">
|
||||
{tokenScope === 'V0' && (
|
||||
<div className="px-4 sm:px-5 py-4 pb-4">
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="The experimental API provides additional endpoints which allows you to manage your organizations and projects."
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
These include deleting organizations and projects which cannot be undone. As
|
||||
such, be very careful when using this API.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
href="https://api.supabase.com/api/v0"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Experimental API documentation
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form_Shadcn_ {...form}>
|
||||
<div className="flex flex-col gap-0 overflow-visible">
|
||||
<BasicInfo
|
||||
control={form.control}
|
||||
expirationDate={expiresAt || ''}
|
||||
onCustomDateChange={handleCustomDateChange}
|
||||
onCustomExpiryChange={handleCustomExpiryChange}
|
||||
/>
|
||||
<Separator />
|
||||
<ResourceAccess
|
||||
control={form.control}
|
||||
resourceAccess={resourceAccess}
|
||||
setValue={form.setValue}
|
||||
/>
|
||||
<Separator />
|
||||
<Permissions
|
||||
setValue={form.setValue}
|
||||
watch={form.watch}
|
||||
resourceSearchOpen={resourceSearchOpen}
|
||||
setResourceSearchOpen={setResourceSearchOpen}
|
||||
/>
|
||||
</div>
|
||||
</Form_Shadcn_>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="!justify-end w-full mt-auto py-4 border-t">
|
||||
<div className="flex gap-2">
|
||||
<Button type="default" disabled={isPending} onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={form.handleSubmit(onSubmit)} loading={isPending}>
|
||||
Generate token
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { useScopedAccessTokenDeleteMutation } from 'data/scoped-access-tokens/scoped-access-tokens-delete-mutation'
|
||||
import {
|
||||
ScopedAccessToken,
|
||||
useScopedAccessTokensQuery,
|
||||
} from 'data/scoped-access-tokens/scoped-access-token-query'
|
||||
import { MoreVertical, Trash, Key } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { TableCell, TableRow } from 'ui/src/components/shadcn/ui/table'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
|
||||
import {
|
||||
ACCESS_TOKEN_SORT_VALUES,
|
||||
AccessTokenSort,
|
||||
AccessTokenSortColumn,
|
||||
} from '../AccessToken.types'
|
||||
import { handleSortChange, filterAndSortTokens } from '../AccessToken.utils'
|
||||
import { TableContainer } from '../AccessTokenTable/TableContainer'
|
||||
import { RowLoading } from '../AccessTokenTable/RowLoading'
|
||||
import { TokenNameCell, LastUsedCell, ExpiresCell } from '../AccessTokenTable/TokenCells'
|
||||
import { ViewTokenSheet } from './ViewTokenSheet'
|
||||
|
||||
export interface ScopedTokenListProps {
|
||||
searchString?: string
|
||||
onDeleteSuccess: (id: string | number) => void
|
||||
}
|
||||
|
||||
export const ScopedTokenList = ({ searchString = '', onDeleteSuccess }: ScopedTokenListProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [token, setToken] = useState<ScopedAccessToken | undefined>(undefined)
|
||||
const [viewToken, setViewToken] = useState<ScopedAccessToken | undefined>(undefined)
|
||||
const [isViewSheetOpen, setIsViewSheetOpen] = useState(false)
|
||||
const [sort, setSort] = useQueryState(
|
||||
'sort',
|
||||
parseAsStringLiteral<AccessTokenSort>(ACCESS_TOKEN_SORT_VALUES).withDefault('created_at:desc')
|
||||
)
|
||||
|
||||
const { data: tokensData, error, isPending: isLoading, isError } = useScopedAccessTokensQuery()
|
||||
|
||||
const tokens = tokensData?.tokens
|
||||
|
||||
const { mutate: deleteToken } = useScopedAccessTokenDeleteMutation({
|
||||
onSuccess: (_, vars) => {
|
||||
onDeleteSuccess(vars.id)
|
||||
toast.success('Successfully deleted access token')
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete access token: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const onSortChange = (column: AccessTokenSortColumn) => {
|
||||
handleSortChange(sort, column, setSort)
|
||||
}
|
||||
|
||||
const filteredTokens = useMemo(
|
||||
() => filterAndSortTokens(tokens, searchString, sort),
|
||||
[tokens, searchString, sort]
|
||||
)
|
||||
|
||||
const empty = filteredTokens?.length === 0 && !isLoading
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="p-0">
|
||||
<AlertError
|
||||
error={error}
|
||||
subject="Failed to retrieve access tokens"
|
||||
className="rounded-none border-0"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
<RowLoading />
|
||||
<RowLoading />
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
return (
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-12">
|
||||
<p className="text-sm text-center text-foreground">No scoped access tokens found</p>
|
||||
<p className="text-sm text-center text-foreground-light">
|
||||
You do not have any scoped tokens created yet
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
{filteredTokens?.map((x) => (
|
||||
<TableRow key={x.id}>
|
||||
<TokenNameCell name={x.name} tokenAlias={x.token_alias} />
|
||||
<LastUsedCell lastUsedAt={x.last_used_at} />
|
||||
<ExpiresCell expiresAt={x.expires_at} />
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
title="More options"
|
||||
className="w-7"
|
||||
icon={<MoreVertical />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2"
|
||||
onClick={() => {
|
||||
setViewToken(x)
|
||||
setIsViewSheetOpen(true)
|
||||
}}
|
||||
>
|
||||
<Key size={12} />
|
||||
<p>View permissions</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2"
|
||||
onClick={() => {
|
||||
setToken(x)
|
||||
setIsOpen(true)
|
||||
}}
|
||||
>
|
||||
<Trash size={12} />
|
||||
<p>Delete token</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableContainer>
|
||||
|
||||
<ConfirmationModal
|
||||
visible={isOpen}
|
||||
variant="destructive"
|
||||
title="Confirm to delete"
|
||||
confirmLabel="Delete"
|
||||
confirmLabelLoading="Deleting"
|
||||
onCancel={() => setIsOpen(false)}
|
||||
onConfirm={() => {
|
||||
if (token) deleteToken({ id: token.id as string })
|
||||
}}
|
||||
>
|
||||
<p className="py-4 text-sm text-foreground-light">
|
||||
This action cannot be undone. Are you sure you want to delete "{token?.name}" token?
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
<ViewTokenSheet
|
||||
visible={isViewSheetOpen}
|
||||
tokenId={viewToken ? String(viewToken.id) : undefined}
|
||||
onClose={() => {
|
||||
setIsViewSheetOpen(false)
|
||||
setViewToken(undefined)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
ScrollArea,
|
||||
cn,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
import { useScopedAccessTokenQuery } from 'data/scoped-access-tokens/scoped-access-token-query'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { Card, CardContent } from 'ui'
|
||||
import { ACCESS_TOKEN_RESOURCES } from '../AccessToken.constants'
|
||||
import { useMemo } from 'react'
|
||||
import { formatAccessText, getRealAccess } from '../AccessToken.utils'
|
||||
import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData'
|
||||
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
|
||||
interface ViewTokenSheetProps {
|
||||
visible: boolean
|
||||
tokenId: string | undefined
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProps) {
|
||||
const { organizations, projects } = useOrgAndProjectData()
|
||||
|
||||
const {
|
||||
data: token,
|
||||
isLoading: isTokenLoading,
|
||||
error: tokenError,
|
||||
} = useScopedAccessTokenQuery(
|
||||
{ id: tokenId! },
|
||||
{
|
||||
enabled: visible && !!tokenId,
|
||||
retry: 1,
|
||||
retryDelay: 1000,
|
||||
}
|
||||
)
|
||||
|
||||
const groupedResourcesByAccess = useMemo(() => {
|
||||
const grouped: Record<string, string[]> = {}
|
||||
|
||||
if (!token?.permissions) {
|
||||
return grouped
|
||||
}
|
||||
|
||||
ACCESS_TOKEN_RESOURCES.forEach((resource) => {
|
||||
const access = getRealAccess(resource.resource, token.permissions)
|
||||
if (access !== 'no access') {
|
||||
const formattedAccess = formatAccessText(access)
|
||||
if (!grouped[formattedAccess]) {
|
||||
grouped[formattedAccess] = []
|
||||
}
|
||||
grouped[formattedAccess].push(resource.title)
|
||||
}
|
||||
})
|
||||
|
||||
return grouped
|
||||
}, [token?.permissions])
|
||||
|
||||
const getResourceAccessInfo = () => {
|
||||
const resources: Array<{ name: string; type: string; identifier: string }> = []
|
||||
|
||||
const organizationSlugs = token?.organization_slugs
|
||||
if (organizationSlugs && Array.isArray(organizationSlugs) && organizationSlugs.length > 0) {
|
||||
organizationSlugs.forEach((orgSlug: string) => {
|
||||
const org = organizations.find((o) => o.slug === orgSlug)
|
||||
resources.push({
|
||||
name: org?.name || orgSlug,
|
||||
type: 'Organization',
|
||||
identifier: orgSlug,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const projectRefs = token?.project_refs
|
||||
if (projectRefs && Array.isArray(projectRefs) && projectRefs.length > 0) {
|
||||
projectRefs.forEach((projectRef: string) => {
|
||||
const project = projects.find((p) => p.ref === projectRef)
|
||||
resources.push({
|
||||
name: project?.name || projectRef,
|
||||
type: 'Project',
|
||||
identifier: projectRef,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return resources
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={visible} onOpenChange={() => onClose()}>
|
||||
<SheetContent
|
||||
showClose={false}
|
||||
size="default"
|
||||
className="!min-w-[600px] flex flex-col h-full gap-0"
|
||||
>
|
||||
<SheetHeader
|
||||
className={cn('flex flex-row justify-between gap-x-4 items-center border-b')}
|
||||
>
|
||||
<p className="truncate" title={`Manage access for ${token?.name}`}>
|
||||
View access for {token?.name}
|
||||
</p>
|
||||
<DocsButton href="https://supabase.com/docs/reference/api/introduction" />
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 max-h-[calc(100vh-60px)]">
|
||||
<div className="space-y-8 px-5 sm:px-6 py-6">
|
||||
{isTokenLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-foreground-light">Loading token information...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokenError && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-foreground-light text-red-500">
|
||||
Error loading token information. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{token && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-foreground">Token Information</h3>
|
||||
<Card className="w-full overflow-hidden bg-surface-100">
|
||||
<CardContent className="p-0">
|
||||
<Table className="p-5 table-auto">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-200">
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2 w-[60%]">
|
||||
Info
|
||||
</TableHead>
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
|
||||
Date
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<p className="truncate text-foreground-light">Created</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{token?.created_at ? (
|
||||
<TimestampInfo
|
||||
utcTimestamp={token.created_at}
|
||||
label={dayjs(token.created_at).format('DD MMM YYYY')}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-foreground">Unknown</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<p className="truncate text-foreground-light">Last used</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{token?.last_used_at ? (
|
||||
<TimestampInfo
|
||||
utcTimestamp={token.last_used_at}
|
||||
label={dayjs(token.last_used_at).fromNow()}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-foreground">Never</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<p className="truncate text-foreground-light">Expires</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{token?.expires_at ? (
|
||||
<TimestampInfo
|
||||
utcTimestamp={token.expires_at}
|
||||
label={dayjs(token.expires_at).format('DD MMM YYYY')}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-foreground">Never</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-foreground">Resource Access</h3>
|
||||
<Card className="w-full overflow-hidden bg-surface-100">
|
||||
<CardContent className="p-0">
|
||||
<Table className="p-5 table-auto">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-200">
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2 w-[60%]">
|
||||
Resource
|
||||
</TableHead>
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
|
||||
Type
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{getResourceAccessInfo().length > 0 ? (
|
||||
getResourceAccessInfo().map((resource, index) => (
|
||||
<TableRow key={`${resource.type}-${resource.identifier}-${index}`}>
|
||||
<TableCell>
|
||||
<p className="truncate text-foreground">{resource.name}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-foreground-light">{resource.type}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<p className="text-foreground-light text-center py-4">
|
||||
{(token?.organization_slugs &&
|
||||
token.organization_slugs.length > 0) ||
|
||||
(token?.project_refs && token.project_refs.length > 0)
|
||||
? 'This token has access to specific organizations and projects.'
|
||||
: 'This token has access to all resources.'}
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-foreground">Permissions</h3>
|
||||
<Card className="w-full overflow-hidden bg-surface-100">
|
||||
<CardContent className="p-0">
|
||||
<Table className="p-5 table-auto">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-200">
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2 w-[60%]">
|
||||
Permission
|
||||
</TableHead>
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
|
||||
Access
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Object.keys(groupedResourcesByAccess).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<p className="text-foreground-light text-center py-4">
|
||||
No permissions configured for this token.
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
Object.entries(groupedResourcesByAccess).map(
|
||||
([accessLevel, resources]) => {
|
||||
return resources.map((resource) => (
|
||||
<TableRow key={`${accessLevel}-${resource}`}>
|
||||
<TableCell>
|
||||
<p className="truncate text-foreground capitalize">
|
||||
{resource}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-foreground-light">
|
||||
{formatAccessText(accessLevel)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
}
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ACCESS_TOKEN_RESOURCES } from '../AccessToken.constants'
|
||||
import { getRealAccess, formatAccessText } from '../AccessToken.utils'
|
||||
|
||||
export const useGroupedPermissions = (tokenPermissions: string[] | undefined) => {
|
||||
const groupedPermissions = useMemo(() => {
|
||||
const grouped: Record<string, string[]> = {}
|
||||
|
||||
if (!tokenPermissions || tokenPermissions.length === 0) {
|
||||
return grouped
|
||||
}
|
||||
|
||||
ACCESS_TOKEN_RESOURCES.forEach((resource) => {
|
||||
const access = getRealAccess(resource.resource, tokenPermissions)
|
||||
if (access !== 'no access') {
|
||||
const formattedAccess = formatAccessText(access)
|
||||
if (!grouped[formattedAccess]) {
|
||||
grouped[formattedAccess] = []
|
||||
}
|
||||
grouped[formattedAccess].push(resource.title)
|
||||
}
|
||||
})
|
||||
|
||||
return grouped
|
||||
}, [tokenPermissions])
|
||||
|
||||
const totalCount = Object.values(groupedPermissions).reduce(
|
||||
(sum, resources) => sum + resources.length,
|
||||
0
|
||||
)
|
||||
|
||||
return { groupedPermissions, totalCount }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useProjectsInfiniteQuery } from 'data/projects/projects-infinite-query'
|
||||
|
||||
interface UseOrgAndProjectDataOptions {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const useOrgAndProjectData = (options: UseOrgAndProjectDataOptions = {}) => {
|
||||
const { enabled = true } = options
|
||||
|
||||
const { data: organizations = [], isLoading: isLoadingOrgs } = useOrganizationsQuery({ enabled })
|
||||
|
||||
const { data: projectsData, isLoading: isLoadingProjects } = useProjectsInfiniteQuery({
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
const projects = useMemo(
|
||||
() => projectsData?.pages.flatMap((page) => page.projects) ?? [],
|
||||
[projectsData]
|
||||
)
|
||||
|
||||
return {
|
||||
organizations,
|
||||
projects,
|
||||
isLoadingOrgs,
|
||||
isLoadingProjects,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import { useFlag } from 'common'
|
||||
import { NavMenu, NavMenuItem } from 'ui'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderDescription,
|
||||
PageHeaderMeta,
|
||||
PageHeaderNavigationTabs,
|
||||
PageHeaderSummary,
|
||||
PageHeaderTitle,
|
||||
} from 'ui-patterns/PageHeader'
|
||||
|
||||
const AccessTokensLayout = ({ children }: PropsWithChildren) => {
|
||||
const router = useRouter()
|
||||
const scopedTokensEnabled = useFlag('scopedPAT')
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
label: 'Classic Tokens',
|
||||
href: `/account/tokens`,
|
||||
id: 'classic-tokens',
|
||||
},
|
||||
...(scopedTokensEnabled
|
||||
? [
|
||||
{
|
||||
label: 'Scoped Tokens',
|
||||
href: `/account/tokens/scoped`,
|
||||
id: 'scoped-tokens',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
const title = 'Access Tokens'
|
||||
const description = 'Create and manage access tokens for API authentication.'
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader size="small">
|
||||
<PageHeaderMeta>
|
||||
<PageHeaderSummary>
|
||||
<PageHeaderTitle>{title}</PageHeaderTitle>
|
||||
<PageHeaderDescription>{description}</PageHeaderDescription>
|
||||
</PageHeaderSummary>
|
||||
</PageHeaderMeta>
|
||||
{navigationItems.length > 0 && (
|
||||
<PageHeaderNavigationTabs>
|
||||
<NavMenu>
|
||||
{navigationItems.map((item) => {
|
||||
const isActive = router.asPath.split('?')[0] === item.href
|
||||
return (
|
||||
<NavMenuItem key={item.label} active={isActive}>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</NavMenuItem>
|
||||
)
|
||||
})}
|
||||
</NavMenu>
|
||||
</PageHeaderNavigationTabs>
|
||||
)}
|
||||
</PageHeader>
|
||||
<PageContainer size="small" className="pt-8 pb-16">
|
||||
{children}
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessTokensLayout
|
||||
@@ -71,7 +71,8 @@ const AccountLayout = ({ children, title }: PropsWithChildren<AccountLayoutProps
|
||||
key: 'access-tokens',
|
||||
label: 'Access Tokens',
|
||||
href: '/account/tokens',
|
||||
isActive: currentPath === '/account/tokens',
|
||||
isActive:
|
||||
currentPath === '/account/tokens' || currentPath === '/account/tokens/scoped',
|
||||
},
|
||||
...(showSecuritySettings
|
||||
? [
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import { Button, cn } from 'ui'
|
||||
import { ScaffoldContainerLegacy } from '../Scaffold'
|
||||
|
||||
export const AccountSettingsLayout = ({ children }: PropsWithChildren) => {
|
||||
const pathname = usePathname()
|
||||
const links = [
|
||||
{
|
||||
isActive: pathname === `/account/me`,
|
||||
label: 'Preferences',
|
||||
href: `/account/me`,
|
||||
key: `/account/me`,
|
||||
},
|
||||
{
|
||||
isActive: pathname === `/account/tokens`,
|
||||
label: 'Access Tokens',
|
||||
href: `/account/tokens`,
|
||||
key: `/account/tokens`,
|
||||
},
|
||||
|
||||
{
|
||||
isActive: pathname === `/account/security`,
|
||||
label: 'Security',
|
||||
href: `/account/security`,
|
||||
key: `/account/security`,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<ScaffoldContainerLegacy className="flex flex-row py-8 gap-20">
|
||||
<nav className="py-0">
|
||||
<ul className="flex flex-col gap-1">
|
||||
{links.map((link, i) => (
|
||||
<li key={`${link.key}-${i}`}>
|
||||
<Link href={link.href}>
|
||||
<Button
|
||||
type={link.isActive ? 'default' : 'text'}
|
||||
className={cn(
|
||||
'border-0',
|
||||
link.isActive
|
||||
? 'bg-selection text-foreground'
|
||||
: 'hover:bg-overlay-hover text-foreground-lighter'
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="flex-1 flex-grow">{children}</div>
|
||||
</ScaffoldContainerLegacy>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export const accessTokenKeys = {
|
||||
list: () => ['access-tokens'] as const,
|
||||
detail: (id: string) => ['access-tokens', id] as const,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const scopedAccessTokenKeys = {
|
||||
list: () => ['scoped-access-tokens'] as const,
|
||||
detail: (id: string) => ['scoped-access-tokens', id] as const,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { components } from 'api-types'
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
import type { ResponseError, UseCustomMutationOptions } from 'types'
|
||||
import { scopedAccessTokenKeys } from './keys'
|
||||
|
||||
export type ScopedAccessTokenCreateVariables = components['schemas']['CreateScopedAccessTokenBody']
|
||||
|
||||
export async function createScopedAccessToken({
|
||||
name,
|
||||
expires_at,
|
||||
organization_slugs,
|
||||
permissions,
|
||||
project_refs,
|
||||
}: ScopedAccessTokenCreateVariables) {
|
||||
const { data, error } = await post('/platform/profile/scoped-access-tokens', {
|
||||
body: { name, organization_slugs, expires_at, permissions, project_refs },
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export type NewScopedAccessToken = components['schemas']['CreateScopedAccessTokenResponse']
|
||||
|
||||
export const useAccessTokenCreateMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<NewScopedAccessToken, ResponseError, ScopedAccessTokenCreateVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<NewScopedAccessToken, ResponseError, ScopedAccessTokenCreateVariables>({
|
||||
mutationFn: (vars) => createScopedAccessToken(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries({ queryKey: scopedAccessTokenKeys.list() })
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to create access token: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { components } from 'api-types'
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import type { ResponseError, UseCustomQueryOptions } from 'types'
|
||||
import { scopedAccessTokenKeys } from './keys'
|
||||
|
||||
export async function getScopedAccessTokens(signal?: AbortSignal) {
|
||||
const { data, error } = await get('/platform/profile/scoped-access-tokens', { signal })
|
||||
|
||||
if (error) handleError(error)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getScopedAccessToken({ id }: { id: string }, signal?: AbortSignal) {
|
||||
if (!id) throw new Error('Token ID is required')
|
||||
|
||||
const { data, error } = await get('/platform/profile/scoped-access-tokens/{id}', {
|
||||
params: { path: { id } },
|
||||
signal,
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export type ScopedAccessTokensData = Awaited<ReturnType<typeof getScopedAccessTokens>>
|
||||
export type ScopedAccessTokensError = ResponseError
|
||||
|
||||
export type ScopedAccessToken =
|
||||
components['schemas']['GetScopedAccessTokensResponse']['tokens'][number]
|
||||
|
||||
export type ScopedAccessTokenData = Awaited<ReturnType<typeof getScopedAccessToken>>
|
||||
|
||||
export const useScopedAccessTokensQuery = <TData = ScopedAccessTokensData>({
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<ScopedAccessTokensData, ScopedAccessTokensError, TData> = {}) =>
|
||||
useQuery<ScopedAccessTokensData, ScopedAccessTokensError, TData>({
|
||||
queryKey: scopedAccessTokenKeys.list(),
|
||||
queryFn: ({ signal }) => getScopedAccessTokens(signal),
|
||||
enabled,
|
||||
...options,
|
||||
})
|
||||
|
||||
export const useScopedAccessTokenQuery = <TData = ScopedAccessTokenData>(
|
||||
{ id }: { id: string },
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<ScopedAccessTokenData, ScopedAccessTokensError, TData> = {}
|
||||
) =>
|
||||
useQuery<ScopedAccessTokenData, ScopedAccessTokensError, TData>({
|
||||
queryKey: scopedAccessTokenKeys.detail(id),
|
||||
queryFn: ({ signal }) => getScopedAccessToken({ id }, signal),
|
||||
enabled: enabled && typeof id !== 'undefined',
|
||||
...options,
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { del, handleError } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { scopedAccessTokenKeys } from './keys'
|
||||
|
||||
export type ScopedAccessTokenDeleteVariables = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export async function deleteScopedAccessToken({ id }: ScopedAccessTokenDeleteVariables) {
|
||||
const { data, error } = await del('/platform/profile/scoped-access-tokens/{id}', {
|
||||
params: { path: { id } },
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
type ScopedAccessTokenDeleteData = Awaited<ReturnType<typeof deleteScopedAccessToken>>
|
||||
|
||||
export const useScopedAccessTokenDeleteMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<ScopedAccessTokenDeleteData, ResponseError, ScopedAccessTokenDeleteVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<ScopedAccessTokenDeleteData, ResponseError, ScopedAccessTokenDeleteVariables>({
|
||||
mutationFn: (vars) => deleteScopedAccessToken(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries({ queryKey: scopedAccessTokenKeys.list() })
|
||||
await queryClient.invalidateQueries({ queryKey: scopedAccessTokenKeys.detail(variables.id) })
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to delete access token: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -63,7 +63,7 @@
|
||||
"@supabase/mcp-utils": "^0.3.2",
|
||||
"@supabase/pg-meta": "workspace:*",
|
||||
"@supabase/realtime-js": "catalog:",
|
||||
"@supabase/shared-types": "0.1.83",
|
||||
"@supabase/shared-types": "0.1.84",
|
||||
"@supabase/sql-to-rest": "^0.1.6",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ExternalLink, Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { AccessTokenList } from 'components/interfaces/Account/AccessTokens/AccessTokenList'
|
||||
import { NewAccessTokenButton } from 'components/interfaces/Account/AccessTokens/NewAccessTokenButton'
|
||||
import { NewTokenBanner } from 'components/interfaces/Account/AccessTokens/NewTokenBanner'
|
||||
import { NewTokenButton } from 'components/interfaces/Account/AccessTokens/Classic/NewTokenButton'
|
||||
import { AccessTokenNewBanner } from '@/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner'
|
||||
import AccessTokensLayout from 'components/layouts/AccessTokens/AccessTokensLayout'
|
||||
import AccountLayout from 'components/layouts/AccountLayout/AccountLayout'
|
||||
import AppLayout from 'components/layouts/AppLayout/AppLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
@@ -13,68 +13,54 @@ import { DOCS_URL } from 'lib/constants'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { Button } from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderDescription,
|
||||
PageHeaderMeta,
|
||||
PageHeaderSummary,
|
||||
PageHeaderTitle,
|
||||
} from 'ui-patterns/PageHeader'
|
||||
|
||||
const UserAccessTokens: NextPageWithLayout = () => {
|
||||
const [newToken, setNewToken] = useState<NewAccessToken | undefined>()
|
||||
const [searchString, setSearchString] = useState('')
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader size="small">
|
||||
<PageHeaderMeta>
|
||||
<PageHeaderSummary>
|
||||
<PageHeaderTitle>Access Tokens</PageHeaderTitle>
|
||||
<PageHeaderDescription>
|
||||
Create and manage personal access tokens for API authentication.
|
||||
</PageHeaderDescription>
|
||||
</PageHeaderSummary>
|
||||
</PageHeaderMeta>
|
||||
</PageHeader>
|
||||
<PageContainer size="small" className="mt-8">
|
||||
<div className="space-y-4">
|
||||
{newToken && <NewTokenBanner token={newToken} onClose={() => setNewToken(undefined)} />}
|
||||
<div className="flex items-center justify-between gap-x-2 mb-3">
|
||||
<Input
|
||||
size="tiny"
|
||||
autoComplete="off"
|
||||
icon={<Search size={12} />}
|
||||
value={searchString}
|
||||
onChange={(e: any) => setSearchString(e.target.value)}
|
||||
name="search"
|
||||
id="search"
|
||||
placeholder="Filter tokens"
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<a href={`${DOCS_URL}/reference/api/introduction`} target="_blank" rel="noreferrer">
|
||||
API Docs
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<a href={`${DOCS_URL}/reference/cli/start`} target="_blank" rel="noreferrer">
|
||||
CLI docs
|
||||
</a>
|
||||
</Button>
|
||||
<NewAccessTokenButton onCreateToken={setNewToken} />
|
||||
</div>
|
||||
</div>
|
||||
<AccessTokenList
|
||||
searchString={searchString}
|
||||
onDeleteSuccess={(id) => {
|
||||
if (id === newToken?.id) setNewToken(undefined)
|
||||
}}
|
||||
<AccessTokensLayout>
|
||||
<div className="space-y-4">
|
||||
{newToken && (
|
||||
<AccessTokenNewBanner
|
||||
token={newToken}
|
||||
onClose={() => setNewToken(undefined)}
|
||||
getTokenValue={(token) => token.token}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-x-2 mb-3">
|
||||
<Input
|
||||
size="tiny"
|
||||
autoComplete="off"
|
||||
icon={<Search size={12} />}
|
||||
value={searchString}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchString(e.target.value)}
|
||||
name="search"
|
||||
id="search"
|
||||
placeholder="Filter tokens"
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<a href={`${DOCS_URL}/reference/api/introduction`} target="_blank" rel="noreferrer">
|
||||
API Docs
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<a href={`${DOCS_URL}/reference/cli/start`} target="_blank" rel="noreferrer">
|
||||
CLI docs
|
||||
</a>
|
||||
</Button>
|
||||
<NewTokenButton onCreateToken={setNewToken} />
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</>
|
||||
<AccessTokenList
|
||||
searchString={searchString}
|
||||
onDeleteSuccess={(id) => {
|
||||
if (id === newToken?.id) setNewToken(undefined)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AccessTokensLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ExternalLink, Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import AccessTokensLayout from 'components/layouts/AccessTokens/AccessTokensLayout'
|
||||
import { NewScopedTokenButton } from 'components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenButton'
|
||||
import { AccessTokenNewBanner } from '@/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner'
|
||||
|
||||
import { ScopedTokenList } from 'components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList'
|
||||
import AccountLayout from 'components/layouts/AccountLayout/AccountLayout'
|
||||
import AppLayout from 'components/layouts/AppLayout/AppLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import OrganizationLayout from 'components/layouts/OrganizationLayout'
|
||||
import { NewScopedAccessToken } from 'data/scoped-access-tokens/scoped-access-token-create-mutation'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { Button } from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
|
||||
const ScopedTokens: NextPageWithLayout = () => {
|
||||
const [searchString, setSearchString] = useState('')
|
||||
const [newToken, setNewToken] = useState<NewScopedAccessToken | undefined>()
|
||||
|
||||
return (
|
||||
<AccessTokensLayout>
|
||||
<div className="space-y-4">
|
||||
{newToken && (
|
||||
<AccessTokenNewBanner
|
||||
token={newToken}
|
||||
onClose={() => setNewToken(undefined)}
|
||||
getTokenValue={(token) => token.token}
|
||||
getTokenPermissions={(token) => token.permissions}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-x-2 mb-3">
|
||||
<Input
|
||||
size="tiny"
|
||||
autoComplete="off"
|
||||
icon={<Search size={12} />}
|
||||
value={searchString}
|
||||
onChange={(e: any) => setSearchString(e.target.value)}
|
||||
name="search"
|
||||
id="search"
|
||||
placeholder="Filter by name"
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<a
|
||||
href="https://supabase.com/docs/reference/api/introduction"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
API Docs
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<a
|
||||
href="https://supabase.com/docs/reference/cli/start"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
CLI docs
|
||||
</a>
|
||||
</Button>
|
||||
<NewScopedTokenButton onCreateToken={setNewToken} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScopedTokenList
|
||||
searchString={searchString}
|
||||
onDeleteSuccess={(id) => {
|
||||
if (id === newToken?.id) setNewToken(undefined)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AccessTokensLayout>
|
||||
)
|
||||
}
|
||||
|
||||
ScopedTokens.getLayout = (page) => (
|
||||
<AppLayout>
|
||||
<DefaultLayout headerTitle="Account">
|
||||
<OrganizationLayout>
|
||||
<AccountLayout title="Access Tokens">{page}</AccountLayout>
|
||||
</OrganizationLayout>
|
||||
</DefaultLayout>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
export default ScopedTokens
|
||||
Generated
+5
-5
@@ -1173,8 +1173,8 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 2.94.1
|
||||
'@supabase/shared-types':
|
||||
specifier: 0.1.83
|
||||
version: 0.1.83
|
||||
specifier: 0.1.84
|
||||
version: 0.1.84
|
||||
'@supabase/sql-to-rest':
|
||||
specifier: ^0.1.6
|
||||
version: 0.1.6(encoding@0.1.13)(supports-color@8.1.1)
|
||||
@@ -9285,8 +9285,8 @@ packages:
|
||||
resolution: {integrity: sha512-Wq8olpCAGmN4y2DH2kUdlcakdzNHRCde72BFS8zK5ub46bBeSUoE9DqrfeNFWKaF2gCE/cmK8aTUTorZD9jdtQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@supabase/shared-types@0.1.83':
|
||||
resolution: {integrity: sha512-hKXExQKLU9FvUCpDsMKtP/EN+qma5SL+pxd44D06OmwV1UnOUqBwgUvO0nUyF2vV32jKRNaWsf2o+IDJ3pATJA==}
|
||||
'@supabase/shared-types@0.1.84':
|
||||
resolution: {integrity: sha512-0bcV8Il0r5zybdY6gIWEmbJ/5VGGw60p9SOZ8tUe3x7iKxW/2H+CmPezyqQHuwYURxF4Gf18zJfENlaDyn+g8w==}
|
||||
|
||||
'@supabase/sql-to-rest@0.1.6':
|
||||
resolution: {integrity: sha512-06KgjeINtc6405XQvfnchBE1azEsU8G2NElfadmvVHKmHa5l2bFzjbtFbpaYgpgTzccHlcDmBaCgedVf2Gyl8Q==}
|
||||
@@ -28445,7 +28445,7 @@ snapshots:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@supabase/shared-types@0.1.83': {}
|
||||
'@supabase/shared-types@0.1.84': {}
|
||||
|
||||
'@supabase/sql-to-rest@0.1.6(encoding@0.1.13)(supports-color@8.1.1)':
|
||||
dependencies:
|
||||
|
||||
Reference in new issue
Block a user