Skip to content

devex: improve API key permissions display with summarized badge #1975

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 5 additions & 14 deletions apps/dashboard/src/components/ApiKeyTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ import {
DialogTitle,
DialogTrigger,
} from './ui/dialog'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'
import { Pagination } from './Pagination'
import { Loader2 } from 'lucide-react'
import { DEFAULT_PAGE_SIZE } from '@/constants/Pagination'
import { getRelativeTimeString } from '@/lib/utils'
import { TableEmptyState } from './TableEmptyState'
import { PermissionBadges } from './PermissionBadges'

interface DataTableProps {
data: ApiKeyList[]
Expand Down Expand Up @@ -129,20 +129,11 @@ const getColumns = ({
return <div className="max-w-md px-3">Permissions</div>
},
cell: ({ row }) => {
const permissions = row.original.permissions.join(', ')
const permissions = row.original.permissions
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="truncate max-w-md px-3 cursor-text">{permissions || '-'}</div>
</TooltipTrigger>
{permissions && (
<TooltipContent>
<p className="max-w-[300px]">{permissions}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<div className="max-w-md px-3">
<PermissionBadges permissions={permissions} showDetails />
</div>
)
},
},
Expand Down
125 changes: 125 additions & 0 deletions apps/dashboard/src/components/PermissionBadges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* Copyright 2025 Daytona Platforms Inc.
* SPDX-License-Identifier: AGPL-3.0
*/

import React from 'react'
import { Badge } from './ui/badge'
import { Button } from './ui/button'
import { Eye } from 'lucide-react'
import {
summarizePermissions,
getCategoryLevelLabel,
getCategoryLevelVariant,
type PermissionSummary,
} from '@/utils/permissionSummary'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'

interface PermissionBadgesProps {
permissions: string[]
showDetails?: boolean
}

export function PermissionBadges({ permissions, showDetails = false }: PermissionBadgesProps) {
const summary = summarizePermissions(permissions)

if (permissions.length === 0) {
return <span className="text-muted-foreground text-sm">No permissions</span>
}

// For simple cases (full access or read-only), show a single badge
if (summary.type === 'full' || summary.type === 'readonly') {
return (
<div className="flex items-center gap-2">
<Badge variant={summary.variant}>{summary.label}</Badge>
{showDetails && <PermissionDetailsDialog permissions={permissions} summary={summary} />}
</div>
)
}

// For custom permissions, show category badges
return (
<div className="flex items-center gap-1 flex-wrap">
{summary.categories.slice(0, 3).map((category) => (
<TooltipProvider key={category.name}>
<Tooltip>
<TooltipTrigger>
<Badge variant={getCategoryLevelVariant(category.level)} className="text-xs">
{category.name} {getCategoryLevelLabel(category.level)}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="text-sm">
<div className="font-medium">{category.name}</div>
<div className="text-xs text-muted-foreground">{category.permissions.join(', ')}</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}

{summary.categories.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{summary.categories.length - 3} more
</Badge>
)}

{showDetails && <PermissionDetailsDialog permissions={permissions} summary={summary} />}
</div>
)
}

interface PermissionDetailsDialogProps {
permissions: string[]
summary: PermissionSummary
}

function PermissionDetailsDialog({ permissions, summary }: PermissionDetailsDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6" onMouseDown={(e) => e.preventDefault()}>
<Eye className="h-3 w-3" />
<span className="sr-only">View permission details</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-md [&_button:focus]:!outline-none [&_button:focus]:!ring-0">
<DialogHeader>
<DialogTitle>Permission Details</DialogTitle>
<DialogDescription>Detailed breakdown of permissions for this API key.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<h4 className="font-medium mb-2">Summary</h4>
<Badge variant={summary.variant}>{summary.label}</Badge>
</div>

{summary.categories.length > 0 && (
<div>
<h4 className="font-medium mb-2">Categories</h4>
<div className="space-y-2">
{summary.categories.map((category) => (
<div key={category.name} className="border rounded-md p-3">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">{category.name}</span>
<Badge variant={getCategoryLevelVariant(category.level)} className="text-xs">
{getCategoryLevelLabel(category.level)}
</Badge>
</div>
<div className="text-xs text-muted-foreground">{category.permissions.join(', ')}</div>
</div>
))}
</div>
</div>
)}

<div>
<h4 className="font-medium mb-2">Raw Permissions</h4>
<div className="bg-muted rounded-md p-3 text-xs font-mono">{permissions.join(', ')}</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
165 changes: 165 additions & 0 deletions apps/dashboard/src/utils/permissionSummary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
* Copyright 2025 Daytona Platforms Inc.
* SPDX-License-Identifier: AGPL-3.0
*/

import { ApiKeyListPermissionsEnum } from '@daytonaio/api-client'

export interface PermissionSummary {
type: 'full' | 'readonly' | 'custom'
label: string
variant: 'default' | 'secondary' | 'outline'
categories: PermissionCategory[]
}

export interface PermissionCategory {
name: string
permissions: string[]
level: 'read' | 'write' | 'delete' | 'full'
}

// All available permissions for comparison
const ALL_PERMISSIONS = Object.values(ApiKeyListPermissionsEnum) as string[]

// Permission categories and their mappings
const PERMISSION_CATEGORIES = {
sandboxes: {
name: 'Sandboxes',
permissions: [ApiKeyListPermissionsEnum.WRITE_SANDBOXES, ApiKeyListPermissionsEnum.DELETE_SANDBOXES] as string[],
},
images: {
name: 'Images',
permissions: [ApiKeyListPermissionsEnum.WRITE_IMAGES, ApiKeyListPermissionsEnum.DELETE_IMAGES] as string[],
},
registries: {
name: 'Registries',
permissions: [ApiKeyListPermissionsEnum.WRITE_REGISTRIES, ApiKeyListPermissionsEnum.DELETE_REGISTRIES] as string[],
},
volumes: {
name: 'Volumes',
permissions: [
ApiKeyListPermissionsEnum.READ_VOLUMES,
ApiKeyListPermissionsEnum.WRITE_VOLUMES,
ApiKeyListPermissionsEnum.DELETE_VOLUMES,
] as string[],
},
}

/**
* Determine the permission level for a category based on granted permissions
*/
function getCategoryLevel(
categoryPermissions: string[],
grantedPermissions: string[],
): 'read' | 'write' | 'delete' | 'full' | null {
const granted = grantedPermissions.filter((p) => categoryPermissions.includes(p))

if (granted.length === 0) return null
if (granted.length === categoryPermissions.length) return 'full'

// Check specific patterns
const hasRead = granted.some((p) => p.includes('read:'))
const hasWrite = granted.some((p) => p.includes('write:'))
const hasDelete = granted.some((p) => p.includes('delete:'))

// For resources with read permissions (like volumes)
if (hasRead && !hasWrite && !hasDelete) return 'read'

// For delete permissions (highest level)
if (hasDelete && hasWrite) return 'full'
if (hasDelete) return 'delete'

// For write permissions
if (hasWrite) return 'write'

// Fallback
return hasRead ? 'read' : 'write'
}

/**
* Summarize API key permissions into a more readable format
*/
export function summarizePermissions(permissions: string[]): PermissionSummary {
// Check if user has all permissions (full access)
const hasAllPermissions = ALL_PERMISSIONS.every((permission) => permissions.includes(permission))

if (hasAllPermissions) {
return {
type: 'full',
label: 'Full Access',
variant: 'default',
categories: [],
}
}

// Check if user has only read permissions (only applies to volumes currently)
const hasOnlyReadPermissions =
permissions.length === 1 && permissions.includes(ApiKeyListPermissionsEnum.READ_VOLUMES)

if (hasOnlyReadPermissions) {
return {
type: 'readonly',
label: 'Read-only',
variant: 'secondary',
categories: [],
}
}

// Categorize permissions
const categories: PermissionCategory[] = []

Object.entries(PERMISSION_CATEGORIES).forEach(([key, category]) => {
const level = getCategoryLevel(category.permissions, permissions)
if (level) {
categories.push({
name: category.name,
permissions: permissions.filter((p) => category.permissions.includes(p)),
level,
})
}
})

return {
type: 'custom',
label: 'Custom',
variant: 'outline',
categories,
}
}

/**
* Get a display-friendly label for a permission category level
*/
export function getCategoryLevelLabel(level: 'read' | 'write' | 'delete' | 'full'): string {
switch (level) {
case 'read':
return 'Read'
case 'write':
return 'Write'
case 'delete':
return 'Delete'
case 'full':
return 'Full'
default:
return level
}
}

/**
* Get a color variant for a permission category level
*/
export function getCategoryLevelVariant(
level: 'read' | 'write' | 'delete' | 'full',
): 'default' | 'secondary' | 'outline' {
switch (level) {
case 'read':
return 'secondary'
case 'write':
return 'outline'
case 'delete':
case 'full':
return 'default'
default:
return 'outline'
}
}
Loading