From 8ffdcfc145d6731804d623ba028deeba68778e4d Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:32:55 +0000 Subject: [PATCH] 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. ## 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. --- .../AccessTokens/AccessToken.constants.ts | 75 ++++ .../AccessTokens/AccessToken.schemas.ts | 23 ++ .../Account/AccessTokens/AccessToken.types.ts | 25 ++ .../Account/AccessTokens/AccessToken.utils.ts | 148 ++++++++ .../Account/AccessTokens/AccessTokenList.tsx | 271 +++------------ .../AccessTokenNewBanner.tsx | 67 ++++ .../AccessTokenNewBanner/PermissionList.tsx | 28 ++ .../TokenPermissionSection.tsx | 55 +++ .../AccessTokenTable/RowLoading.tsx | 22 ++ .../AccessTokenTable/TableContainer.tsx | 44 +++ .../AccessTokenTable/TokenCells.tsx | 62 ++++ .../AccessTokens/AccessTokens.constants.ts | 31 -- .../NewTokenButton.tsx} | 9 +- .../NewTokenDialog.tsx} | 34 +- .../NewAccessTokenButton.test.tsx | 132 -------- .../Account/AccessTokens/NewTokenBanner.tsx | 48 --- .../AccessTokens/Scoped/Form/BasicInfo.tsx | 144 ++++++++ .../PermissionResourceSelector.tsx | 89 +++++ .../Scoped/Form/Permissions/Permissions.tsx | 172 ++++++++++ .../Form/Permissions/Permissions.types.ts | 33 ++ .../Form/Permissions/Permissions.utils.ts | 48 +++ .../Form/ResourceAccess/ResourceAccess.tsx | 115 +++++++ .../ResourceAccess/ResourceAccess.types.ts | 30 ++ .../ResourceAccess/ResourceMultiSelector.tsx | 68 ++++ .../Form/ResourceAccess/ResourceOption.tsx | 42 +++ .../Scoped/NewScopedTokenButton.tsx | 33 ++ .../Scoped/NewScopedTokenSheet.tsx | 320 ++++++++++++++++++ .../AccessTokens/Scoped/ScopedTokenList.tsx | 187 ++++++++++ .../AccessTokens/Scoped/ViewTokenSheet.tsx | 304 +++++++++++++++++ .../hooks/useGroupedPermissions.ts | 33 ++ .../hooks/useOrgAndProjectData.ts | 29 ++ .../AccessTokens/AccessTokensLayout.tsx | 72 ++++ .../layouts/AccountLayout/AccountLayout.tsx | 3 +- .../AccountLayout/AccountSettingsLayout.tsx | 58 ---- apps/studio/data/access-tokens/keys.ts | 1 + apps/studio/data/scoped-access-tokens/keys.ts | 4 + .../scoped-access-token-create-mutation.ts | 55 +++ .../scoped-access-token-query.ts | 60 ++++ .../scoped-access-tokens-delete-mutation.ts | 50 +++ apps/studio/package.json | 2 +- apps/studio/pages/account/tokens.tsx | 100 +++--- apps/studio/pages/account/tokens/scoped.tsx | 88 +++++ pnpm-lock.yaml | 10 +- 43 files changed, 2656 insertions(+), 568 deletions(-) create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessToken.schemas.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessToken.types.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessToken.utils.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/PermissionList.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/TokenPermissionSection.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/RowLoading.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TableContainer.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TokenCells.tsx delete mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessTokens.constants.ts rename apps/studio/components/interfaces/Account/AccessTokens/{NewAccessTokenButton.tsx => Classic/NewTokenButton.tsx} (84%) rename apps/studio/components/interfaces/Account/AccessTokens/{NewAccessTokenDialog.tsx => Classic/NewTokenDialog.tsx} (89%) delete mode 100644 apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.test.tsx delete mode 100644 apps/studio/components/interfaces/Account/AccessTokens/NewTokenBanner.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/BasicInfo.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/PermissionResourceSelector.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.utils.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.types.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceMultiSelector.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceOption.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenButton.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/hooks/useGroupedPermissions.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/hooks/useOrgAndProjectData.ts create mode 100644 apps/studio/components/layouts/AccessTokens/AccessTokensLayout.tsx delete mode 100644 apps/studio/components/layouts/AccountLayout/AccountSettingsLayout.tsx create mode 100644 apps/studio/data/scoped-access-tokens/keys.ts create mode 100644 apps/studio/data/scoped-access-tokens/scoped-access-token-create-mutation.ts create mode 100644 apps/studio/data/scoped-access-tokens/scoped-access-token-query.ts create mode 100644 apps/studio/data/scoped-access-tokens/scoped-access-tokens-delete-mutation.ts create mode 100644 apps/studio/pages/account/tokens/scoped.tsx diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts new file mode 100644 index 00000000000..75e1accca3f --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts @@ -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() + + 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()) +})() diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.schemas.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.schemas.ts new file mode 100644 index 00000000000..da0604f0633 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.schemas.ts @@ -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 diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.types.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.types.ts new file mode 100644 index 00000000000..403fe2c96eb --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.types.ts @@ -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 +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.utils.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.utils.ts new file mode 100644 index 00000000000..20b573290ca --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.utils.ts @@ -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 = ( + 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 => { + const [scope, resource] = resourceKey.split(':') + const result: Record = { '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 + } +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx index 1a0e8f30dbf..d6433c3ff86 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx @@ -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 = () => ( - - - - - - - - - - - - - - - - - -) - -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) => ( - - - - - - Token - - - Last used - - - - - Expires - - - - - - {children} -
-
-
-) +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 ( - + + @@ -210,7 +89,7 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo if (empty) { return ( - +

No access tokens found

@@ -225,93 +104,51 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo return ( <> - - {filteredTokens?.map((x) => { - return ( - - -

- {x.name} -

-

- {x.token_alias} -

-
- - {x.last_used_at ? ( - - ) : ( -

Never used

- )} -
- - {x.expires_at ? ( - dayjs(x.expires_at).isBefore(dayjs()) ? ( - + {filteredTokens?.map((x) => ( + + + + + +
+ + +
-
-
- ) - })} + + + { + setToken(x) + setIsOpen(true) + }} + > + +

Delete token

+
+
+ + +
+
+ ))}
setIsOpen(false)} onConfirm={() => { - if (token) onDeleteToken(token.id) + if (token) deleteToken({ id: token.id }) }} >

diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx new file mode 100644 index 00000000000..bb055d856e2 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx @@ -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 { + token: T + onClose: () => void + getTokenValue: (token: T) => string + getTokenPermissions?: (token: T) => string[] | undefined + title?: string + description?: string +} + +export const AccessTokenNewBanner = ({ + 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) => { + const tokenPermissions = getTokenPermissions?.(token) + const { groupedPermissions, totalCount } = useGroupedPermissions(tokenPermissions) + + return ( + } + className="w-7 h-7 absolute top-2.5 right-2.5" + onClick={onClose} + /> + } + > +

+

{description}

+
+ {}} + onCopy={() => toast.success('Token copied to clipboard')} + /> +
+ + {tokenPermissions && tokenPermissions.length > 0 && ( + + )} +
+ + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/PermissionList.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/PermissionList.tsx new file mode 100644 index 00000000000..6353f874dcb --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/PermissionList.tsx @@ -0,0 +1,28 @@ +interface PermissionsListProps { + groupedPermissions: Record +} + +export const PermissionsList = ({ groupedPermissions }: PermissionsListProps) => { + return ( +
+ {Object.entries(groupedPermissions).map(([accessLevel, resources]) => ( +
+ + {accessLevel}: + +
+ {resources.map((resource, index) => ( + + {resource} + {index < resources.length - 1 ? ',' : '.'} + + ))} +
+
+ ))} +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/TokenPermissionSection.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/TokenPermissionSection.tsx new file mode 100644 index 00000000000..c5b99c5d0c7 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/TokenPermissionSection.tsx @@ -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 + 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 ( +
+ {shouldCollapse ? ( + + + + + + + + + ) : ( + <> +

Permissions assigned to this token:

+ + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/RowLoading.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/RowLoading.tsx new file mode 100644 index 00000000000..53f799da494 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/RowLoading.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from 'ui' +import { TableCell, TableRow } from 'ui/src/components/shadcn/ui/table' + +export const RowLoading = () => ( + + + + + + + + + + + + + + + + + +) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TableContainer.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TableContainer.tsx new file mode 100644 index 00000000000..b36702b25f9 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TableContainer.tsx @@ -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) => ( + + + + + + Token + + + Last used + + + + + Expires + + + + + + {children} +
+
+
+) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TokenCells.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TokenCells.tsx new file mode 100644 index 00000000000..b1cf4f95c1a --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TokenCells.tsx @@ -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) => ( + +

+ {name} +

+

+ {tokenAlias} +

+
+) + +interface LastUsedCellProps { + lastUsedAt: string | null | undefined +} + +export const LastUsedCell = ({ lastUsedAt }: LastUsedCellProps) => ( + + {lastUsedAt ? ( + + ) : ( +

Never used

+ )} +
+) + +interface ExpiresCellProps { + expiresAt: string | null | undefined +} + +export const ExpiresCell = ({ expiresAt }: ExpiresCellProps) => ( + + {expiresAt ? ( + dayjs(expiresAt).isBefore(dayjs()) ? ( + + ) : ( + + ) + ) : ( +

Never

+ )} +
+) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokens.constants.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokens.constants.ts deleted file mode 100644 index 745c2f89ca7..00000000000 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokens.constants.ts +++ /dev/null @@ -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 = { - 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', - }, -} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenButton.tsx similarity index 84% rename from apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.tsx rename to apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenButton.tsx index a17eaa5cce8..c89aa724d12 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenButton.tsx @@ -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 - 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>({ 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> = 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 = ({ - {Object.values(ExpiresAtOptions).map((option) => ( - - {option.label} - - ))} + {Object.values(EXPIRES_AT_OPTIONS).map( + (option: { value: string; label: string }) => ( + + {option.label} + + ) + )} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.test.tsx deleted file mode 100644 index 41a45511e54..00000000000 --- a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.test.tsx +++ /dev/null @@ -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() - - 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() - - 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() - - // 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() - - // 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() - // }) -}) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/NewTokenBanner.tsx b/apps/studio/components/interfaces/Account/AccessTokens/NewTokenBanner.tsx deleted file mode 100644 index ccdb05bcd83..00000000000 --- a/apps/studio/components/interfaces/Account/AccessTokens/NewTokenBanner.tsx +++ /dev/null @@ -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 ( - -

- Do copy this access token and store it in a secure place - you will not be able to see - it again. -

-
- {}} - onCopy={() => toast.success('Token copied to clipboard')} - /> -
- - } - > - + + + + + + No resources found. + + +
+ {ACCESS_TOKEN_RESOURCES.map((resource) => { + const isChecked = permissionRows.some( + (row: PermissionRow) => row.resource === resource.resource + ) + return ( + handleToggleResource(resource)} + className="text-foreground" + > +
+ handleToggleResource(resource)} + onClick={(e) => e.stopPropagation()} + /> + +
+ + {resource.title} + +
+
+
+ ) + })} +
+
+
+
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.tsx new file mode 100644 index 00000000000..3a06202b1a9 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.tsx @@ -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 = ({ + setValue, + watch, + resourceSearchOpen, + setResourceSearchOpen, +}: PermissionsProps) => { + const permissionRows = (watch('permissionRows' as Path) || []) as PermissionRow[] + + return ( +
+
+
+ Configure permissions +
+ {permissionRows.length > 0 && ( + { + setValue( + 'permissionRows' as Path, + [] as PathValue> + ) + }} + icon={} + tooltip={{ + content: { + side: 'top', + align: 'center', + alignOffset: -10, + text: 'Reset all permissions', + }, + }} + /> + )} + +
+
+ + {permissionRows.length === 0 ? ( +
+

No permissions configured yet.

+
+ ) : ( +
+ {permissionRows.map((row, index) => { + const selectedResource = ACCESS_TOKEN_RESOURCES.find( + (r) => r.resource === row.resource + ) + return ( +
+
+
+
+
+ + {selectedResource?.title} + +
+
+
+
+ {selectedResource && ( + + + + + +
+ {sortActions(selectedResource.actions).map((action) => ( + + ))} +
+
+
+ )} +
+
+ {index < permissionRows.length - 1 &&
} +
+ ) + })} +
+ )} +
+ +
+ + + Once you've set these permissions, you cannot edit them. + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts new file mode 100644 index 00000000000..ffb26fe487a --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts @@ -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 + watch: UseFormWatch + resourceSearchOpen: boolean + setResourceSearchOpen: (open: boolean) => void +} + +export interface PermissionResourceSelectorProps { + open: boolean + onOpenChange: (open: boolean) => void + permissionRows: PermissionRow[] + setValue: UseFormSetValue + align?: 'center' | 'end' | 'start' +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.utils.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.utils.ts new file mode 100644 index 00000000000..059d21d26d4 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.utils.ts @@ -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 }] +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.tsx new file mode 100644 index 00000000000..d7bc2fea0b8 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.tsx @@ -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 ( +
+ ( + + +
+
+ Resource access level + {RESOURCE_OPTIONS.map((option) => ( + { + handleResourceAccessChange(option.value, field.value) + field.onChange(option.value) + }} + /> + ))} +
+ +

+ {RESOURCE_OPTIONS.find((opt) => opt.value === field.value)?.description} +

+
+
+
+ )} + /> + + {resourceAccess === 'selected-orgs' && ( + ( + ({ id: org.slug, name: org.name }))} + isLoading={isLoadingOrgs} + fieldName="selectedOrganizations" + label="Select organizations" + loadingMessage="Loading organizations..." + emptyMessage="No organizations available" + /> + )} + /> + )} + + {resourceAccess === 'selected-projects' && ( + ( + ({ id: project.ref, name: project.name }))} + isLoading={isLoadingProjects} + fieldName="selectedProjects" + label="Select projects" + loadingMessage="Loading projects..." + emptyMessage="No projects available" + /> + )} + /> + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.types.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.types.ts new file mode 100644 index 00000000000..df1409affc8 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.types.ts @@ -0,0 +1,30 @@ +import { Control, ControllerRenderProps, UseFormSetValue } from 'react-hook-form' +import { TokenFormValues } from '../../../AccessToken.schemas' + +export interface ResourceAccessProps { + control: Control + resourceAccess: string + setValue: UseFormSetValue +} + +export interface ResourceItem { + id: string + name: string +} + +export interface ResourceMultiSelectorProps { + field: ControllerRenderProps + items: ResourceItem[] + isLoading: boolean + fieldName: string + label: string + loadingMessage: string + emptyMessage: string +} + +export interface ResourceOptionProps { + value: string + label: string + isSelected: boolean + onChange: () => void +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceMultiSelector.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceMultiSelector.tsx new file mode 100644 index 00000000000..b32619fd883 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceMultiSelector.tsx @@ -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 ( + + + + + + {isLoading ? ( +
{loadingMessage}
+ ) : items.length === 0 ? ( +
{emptyMessage}
+ ) : ( + + {items.map((item) => ( + + {item.name} + + ))} + + )} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceOption.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceOption.tsx new file mode 100644 index 00000000000..0aed24cf0b2 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceOption.tsx @@ -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 +}) => ( + +) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenButton.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenButton.tsx new file mode 100644 index 00000000000..c95257038f9 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenButton.tsx @@ -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 ( + <> + + + + + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx new file mode 100644 index 00000000000..2eaf5380d36 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx @@ -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({ + 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 = 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 ( + { + if (!open) { + handleClose() + } else { + onOpenChange(open) + } + }} + > + + + + {tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'} + + + A form to generate a new scoped access token. + + + +
+ {tokenScope === 'V0' && ( +
+ +

+ These include deleting organizations and projects which cannot be undone. As + such, be very careful when using this API. +

+
+ +
+ + } + /> +
+ )} + + +
+ + + + + +
+
+
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx new file mode 100644 index 00000000000..85307d63561 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx @@ -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(undefined) + const [viewToken, setViewToken] = useState(undefined) + const [isViewSheetOpen, setIsViewSheetOpen] = useState(false) + const [sort, setSort] = useQueryState( + 'sort', + parseAsStringLiteral(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 ( + + + + + + + + ) + } + + if (isLoading) { + return ( + + + + + ) + } + + if (empty) { + return ( + + + +

No scoped access tokens found

+

+ You do not have any scoped tokens created yet +

+
+
+
+ ) + } + + return ( + <> + + {filteredTokens?.map((x) => ( + + + + + +
+ + +
+
+
+ ))} +
+ + setIsOpen(false)} + onConfirm={() => { + if (token) deleteToken({ id: token.id as string }) + }} + > +

+ This action cannot be undone. Are you sure you want to delete "{token?.name}" token? +

+
+ + { + setIsViewSheetOpen(false) + setViewToken(undefined) + }} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx new file mode 100644 index 00000000000..f1810cf962c --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx @@ -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 = {} + + 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 ( + <> + onClose()}> + + +

+ View access for {token?.name} +

+ +
+ +
+ {isTokenLoading && ( +
+

Loading token information...

+
+ )} + + {tokenError && ( +
+

+ Error loading token information. Please try again. +

+
+ )} + + {token && ( + <> +
+

Token Information

+ + + + + + + Info + + + Date + + + + + + +

Created

+
+ + {token?.created_at ? ( + + ) : ( + Unknown + )} + +
+ + +

Last used

+
+ + {token?.last_used_at ? ( + + ) : ( + Never + )} + +
+ + +

Expires

+
+ + {token?.expires_at ? ( + + ) : ( + Never + )} + +
+
+
+
+
+
+ +
+

Resource Access

+ + + + + + + Resource + + + Type + + + + + {getResourceAccessInfo().length > 0 ? ( + getResourceAccessInfo().map((resource, index) => ( + + +

{resource.name}

+
+ + {resource.type} + +
+ )) + ) : ( + + +

+ {(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.'} +

+
+
+ )} +
+
+
+
+
+ +
+

Permissions

+ + + + + + + Permission + + + Access + + + + + {Object.keys(groupedResourcesByAccess).length === 0 ? ( + + +

+ No permissions configured for this token. +

+
+
+ ) : ( + Object.entries(groupedResourcesByAccess).map( + ([accessLevel, resources]) => { + return resources.map((resource) => ( + + +

+ {resource} +

+
+ + + {formatAccessText(accessLevel)} + + +
+ )) + } + ) + )} +
+
+
+
+
+ + )} +
+
+
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useGroupedPermissions.ts b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useGroupedPermissions.ts new file mode 100644 index 00000000000..d69f3e23e1d --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useGroupedPermissions.ts @@ -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 = {} + + 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 } +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useOrgAndProjectData.ts b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useOrgAndProjectData.ts new file mode 100644 index 00000000000..2ae0c8bc0b4 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useOrgAndProjectData.ts @@ -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, + } +} diff --git a/apps/studio/components/layouts/AccessTokens/AccessTokensLayout.tsx b/apps/studio/components/layouts/AccessTokens/AccessTokensLayout.tsx new file mode 100644 index 00000000000..43840b62216 --- /dev/null +++ b/apps/studio/components/layouts/AccessTokens/AccessTokensLayout.tsx @@ -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 ( + <> + + + + {title} + {description} + + + {navigationItems.length > 0 && ( + + + {navigationItems.map((item) => { + const isActive = router.asPath.split('?')[0] === item.href + return ( + + {item.label} + + ) + })} + + + )} + + + {children} + + + ) +} + +export default AccessTokensLayout diff --git a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx index 33b83ecf4fa..84640554271 100644 --- a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx +++ b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx @@ -71,7 +71,8 @@ const AccountLayout = ({ children, title }: 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 ( - - -
{children}
-
- ) -} diff --git a/apps/studio/data/access-tokens/keys.ts b/apps/studio/data/access-tokens/keys.ts index 8d5e3a8f3da..e000d4a01bc 100644 --- a/apps/studio/data/access-tokens/keys.ts +++ b/apps/studio/data/access-tokens/keys.ts @@ -1,3 +1,4 @@ export const accessTokenKeys = { list: () => ['access-tokens'] as const, + detail: (id: string) => ['access-tokens', id] as const, } diff --git a/apps/studio/data/scoped-access-tokens/keys.ts b/apps/studio/data/scoped-access-tokens/keys.ts new file mode 100644 index 00000000000..f3dc146c2bf --- /dev/null +++ b/apps/studio/data/scoped-access-tokens/keys.ts @@ -0,0 +1,4 @@ +export const scopedAccessTokenKeys = { + list: () => ['scoped-access-tokens'] as const, + detail: (id: string) => ['scoped-access-tokens', id] as const, +} diff --git a/apps/studio/data/scoped-access-tokens/scoped-access-token-create-mutation.ts b/apps/studio/data/scoped-access-tokens/scoped-access-token-create-mutation.ts new file mode 100644 index 00000000000..3c02d2a794a --- /dev/null +++ b/apps/studio/data/scoped-access-tokens/scoped-access-token-create-mutation.ts @@ -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, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + 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, + }) +} diff --git a/apps/studio/data/scoped-access-tokens/scoped-access-token-query.ts b/apps/studio/data/scoped-access-tokens/scoped-access-token-query.ts new file mode 100644 index 00000000000..849b3812e93 --- /dev/null +++ b/apps/studio/data/scoped-access-tokens/scoped-access-token-query.ts @@ -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> +export type ScopedAccessTokensError = ResponseError + +export type ScopedAccessToken = + components['schemas']['GetScopedAccessTokensResponse']['tokens'][number] + +export type ScopedAccessTokenData = Awaited> + +export const useScopedAccessTokensQuery = ({ + enabled = true, + ...options +}: UseCustomQueryOptions = {}) => + useQuery({ + queryKey: scopedAccessTokenKeys.list(), + queryFn: ({ signal }) => getScopedAccessTokens(signal), + enabled, + ...options, + }) + +export const useScopedAccessTokenQuery = ( + { id }: { id: string }, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: scopedAccessTokenKeys.detail(id), + queryFn: ({ signal }) => getScopedAccessToken({ id }, signal), + enabled: enabled && typeof id !== 'undefined', + ...options, + }) diff --git a/apps/studio/data/scoped-access-tokens/scoped-access-tokens-delete-mutation.ts b/apps/studio/data/scoped-access-tokens/scoped-access-tokens-delete-mutation.ts new file mode 100644 index 00000000000..701991ad40c --- /dev/null +++ b/apps/studio/data/scoped-access-tokens/scoped-access-tokens-delete-mutation.ts @@ -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> + +export const useScopedAccessTokenDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + 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, + }) +} diff --git a/apps/studio/package.json b/apps/studio/package.json index 49615234eec..beec72b34b0 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -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", diff --git a/apps/studio/pages/account/tokens.tsx b/apps/studio/pages/account/tokens.tsx index 33ed3334fab..f875a7b55a4 100644 --- a/apps/studio/pages/account/tokens.tsx +++ b/apps/studio/pages/account/tokens.tsx @@ -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() const [searchString, setSearchString] = useState('') return ( - <> - - - - Access Tokens - - Create and manage personal access tokens for API authentication. - - - - - -
- {newToken && setNewToken(undefined)} />} -
- } - value={searchString} - onChange={(e: any) => setSearchString(e.target.value)} - name="search" - id="search" - placeholder="Filter tokens" - /> -
- - - -
-
- { - if (id === newToken?.id) setNewToken(undefined) - }} + +
+ {newToken && ( + setNewToken(undefined)} + getTokenValue={(token) => token.token} /> + )} +
+ } + value={searchString} + onChange={(e: React.ChangeEvent) => setSearchString(e.target.value)} + name="search" + id="search" + placeholder="Filter tokens" + /> +
+ + + +
- - + { + if (id === newToken?.id) setNewToken(undefined) + }} + /> +
+
) } diff --git a/apps/studio/pages/account/tokens/scoped.tsx b/apps/studio/pages/account/tokens/scoped.tsx new file mode 100644 index 00000000000..a2a259fa9a1 --- /dev/null +++ b/apps/studio/pages/account/tokens/scoped.tsx @@ -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() + + return ( + +
+ {newToken && ( + setNewToken(undefined)} + getTokenValue={(token) => token.token} + getTokenPermissions={(token) => token.permissions} + /> + )} +
+ } + value={searchString} + onChange={(e: any) => setSearchString(e.target.value)} + name="search" + id="search" + placeholder="Filter by name" + /> +
+ + + +
+
+ + { + if (id === newToken?.id) setNewToken(undefined) + }} + /> +
+
+ ) +} + +ScopedTokens.getLayout = (page) => ( + + + + {page} + + + +) + +export default ScopedTokens diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32a96ed16e3..50881543cc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: