Scoped PAT: View token permissions (#48003)

Third stacked PR for scoped PAT extracted from the prototype #47783 to
ease reviewing.
Previous stacked PR is #47979
Next stacked PR is #47999

This PR add a way to view the tokens permissions

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Added a “View permissions” option for scoped access tokens.
  * Scoped token permissions now open in a dedicated viewing panel.
  * Scoped token support is enabled by default.

* **Bug Fixes**
* Improved token deletion confirmation to clearly identify the selected
token.
  * Deletion dialogs now close correctly after successful removal.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Gildas Garcia authored and GitHub committed 2026-07-29 11:23:33 +02:00
1 parent bccc18af8e
commit eb4aafa37a
2 files changed
+31 -25

No files matched your search

@@ -1,4 +1,4 @@
import { MoreVertical, Trash } from 'lucide-react'
import { Key, MoreVertical, Trash } from 'lucide-react'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
@@ -22,6 +22,7 @@ import { RowLoading } from './AccessTokenTable/RowLoading'
import { TableContainer } from './AccessTokenTable/TableContainer'
import { ExpiresCell, LastUsedCell, TokenNameCell } from './AccessTokenTable/TokenCells'
import { useMergedAccessTokens, type MergedAccessToken } from './hooks/useMergedAccessTokens'
import { ViewTokenSheet } from './Scoped/ViewTokenSheet'
import { AlertError } from '@/components/ui/AlertError'
import { useAccessTokenDeleteMutation } from '@/data/access-tokens/access-tokens-delete-mutation'
import { useScopedAccessTokenDeleteMutation } from '@/data/scoped-access-tokens/scoped-access-tokens-delete-mutation'
@@ -39,16 +40,14 @@ export const AccessTokenList = ({
onDeleteSuccess,
}: AccessTokenListProps) => {
const track = useTrack()
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
const [token, setToken] = useState<MergedAccessToken | undefined>(undefined)
const [tokenToShow, setTokenToShow] = useState<MergedAccessToken | undefined>(undefined)
const [tokenToDelete, setTokenToDelete] = useState<MergedAccessToken | undefined>(undefined)
const [sort, setSort] = useQueryState(
'sort',
parseAsStringLiteral<AccessTokenSort>(ACCESS_TOKEN_SORT_VALUES).withDefault('created_at:desc')
)
const { tokens, error, isLoading, isError } = useMergedAccessTokens({
scopedTokensEnabled,
})
const { tokens, error, isLoading, isError } = useMergedAccessTokens({ scopedTokensEnabled })
const { mutate: deleteClassicToken, isPending: isPendingClassicToken } =
useAccessTokenDeleteMutation({
@@ -56,7 +55,7 @@ export const AccessTokenList = ({
track('access_token_removed', { tokenType: 'classic' })
onDeleteSuccess(vars.id)
toast.success('Successfully deleted access token')
setIsDeleteOpen(false)
setTokenToDelete(undefined)
},
onError: (error) => {
toast.error(`Failed to delete access token: ${error.message}`)
@@ -69,7 +68,7 @@ export const AccessTokenList = ({
track('access_token_removed', { tokenType: 'scoped' })
onDeleteSuccess(vars.id)
toast.success('Successfully deleted access token')
setIsDeleteOpen(false)
setTokenToDelete(undefined)
},
onError: (error) => {
toast.error(`Failed to delete access token: ${error.message}`)
@@ -88,9 +87,9 @@ export const AccessTokenList = ({
const empty = filteredTokens?.length === 0 && !isLoading
const handleConfirmDelete = () => {
if (!token) return
if (token.kind === 'classic') deleteClassicToken({ id: token.id })
else deleteScopedToken({ id: token.id })
if (!tokenToDelete) return
if (tokenToDelete.kind === 'classic') deleteClassicToken({ id: tokenToDelete.id as number })
else deleteScopedToken({ id: tokenToDelete.id as string })
}
if (isError) {
@@ -158,13 +157,13 @@ export const AccessTokenList = ({
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuItem
className="gap-x-2"
onClick={() => {
setToken(x)
setIsDeleteOpen(true)
}}
>
{x.kind === 'scoped' && (
<DropdownMenuItem className="gap-x-2" onClick={() => setTokenToShow(x)}>
<Key size={12} />
<p>View permissions</p>
</DropdownMenuItem>
)}
<DropdownMenuItem className="gap-x-2" onClick={() => setTokenToDelete(x)}>
<Trash size={12} />
<p>Delete token</p>
</DropdownMenuItem>
@@ -177,19 +176,26 @@ export const AccessTokenList = ({
</TableContainer>
<ConfirmationModal
visible={isDeleteOpen}
visible={tokenToDelete != null}
variant="destructive"
title="Confirm to delete"
confirmLabel="Delete"
confirmLabelLoading="Deleting"
onCancel={() => setIsDeleteOpen(false)}
onCancel={() => setTokenToDelete(undefined)}
onConfirm={handleConfirmDelete}
loading={isPendingClassicToken || isPendingScopedToken}
>
<p className="py-4 text-sm text-foreground-light">
This action cannot be undone. Are you sure you want to delete "{token?.name}" token?
This action cannot be undone. Are you sure you want to delete "{tokenToDelete?.name}"
token?
</p>
</ConfirmationModal>
<ViewTokenSheet
visible={tokenToShow != null}
onClose={() => setTokenToShow(undefined)}
tokenId={tokenToShow ? String(tokenToShow.id) : undefined}
/>
</>
)
}
@@ -30,10 +30,10 @@ interface UseMergedAccessTokensOptions {
* the consuming list (via filterAndSortTokens), so this only merges + tags.
*/
export const useMergedAccessTokens = ({
scopedTokensEnabled: scopedEnabled,
scopedTokensEnabled,
}: UseMergedAccessTokensOptions = {}) => {
const classic = useAccessTokensQuery()
const scoped = useScopedAccessTokensQuery({ enabled: scopedEnabled })
const scoped = useScopedAccessTokensQuery({ enabled: scopedTokensEnabled })
return useMemo(() => {
const classicTokens: MergedAccessToken[] = (classic.data ?? []).map((token) => ({
@@ -47,7 +47,7 @@ export const useMergedAccessTokens = ({
return {
tokens: [...classicTokens, ...scopedTokens],
// Classic drives the primary states; a scoped fetch that is still loading shouldn't block the list.
isLoading: classic.isPending || (scopedEnabled && scoped.isPending),
isLoading: classic.isPending || (scopedTokensEnabled && scoped.isPending),
isError: classic.isError,
error: classic.error,
isSuccess: classic.isSuccess,
@@ -60,6 +60,6 @@ export const useMergedAccessTokens = ({
classic.isSuccess,
scoped.data,
scoped.isPending,
scopedEnabled,
scopedTokensEnabled,
])
}