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:
kemal.earth authored and GitHub committed 2026-02-09 11:32:55 +00:00
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">
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
@@ -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>
)
@@ -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',
},
}
@@ -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}
@@ -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>
)
}
@@ -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_>
)
}
@@ -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>
)
}
@@ -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'
}
@@ -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 }]
}
@@ -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>
)
}
@@ -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
}
@@ -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>
)
}
@@ -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>
)
@@ -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}
/>
</>
)
}
@@ -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
View File
@@ -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,
})
}
+1 -1
View File
@@ -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",
+43 -57
View File
@@ -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
+5 -5
View File
@@ -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: