Skip to content

Commit 62107cc

Browse files
committed
devex: improve API key permissions display with summarized badge
Signed-off-by: OxDTH <[email protected]>
1 parent c4a835e commit 62107cc

File tree

3 files changed

+295
-14
lines changed

3 files changed

+295
-14
lines changed

apps/dashboard/src/components/ApiKeyTable.tsx

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@ import {
2626
DialogTitle,
2727
DialogTrigger,
2828
} from './ui/dialog'
29-
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'
3029
import { Pagination } from './Pagination'
3130
import { Loader2 } from 'lucide-react'
3231
import { DEFAULT_PAGE_SIZE } from '@/constants/Pagination'
3332
import { getRelativeTimeString } from '@/lib/utils'
3433
import { TableEmptyState } from './TableEmptyState'
34+
import { PermissionBadges } from './PermissionBadges'
3535

3636
interface DataTableProps {
3737
data: ApiKeyList[]
@@ -129,20 +129,11 @@ const getColumns = ({
129129
return <div className="max-w-md px-3">Permissions</div>
130130
},
131131
cell: ({ row }) => {
132-
const permissions = row.original.permissions.join(', ')
132+
const permissions = row.original.permissions
133133
return (
134-
<TooltipProvider>
135-
<Tooltip>
136-
<TooltipTrigger>
137-
<div className="truncate max-w-md px-3 cursor-text">{permissions || '-'}</div>
138-
</TooltipTrigger>
139-
{permissions && (
140-
<TooltipContent>
141-
<p className="max-w-[300px]">{permissions}</p>
142-
</TooltipContent>
143-
)}
144-
</Tooltip>
145-
</TooltipProvider>
134+
<div className="max-w-md px-3">
135+
<PermissionBadges permissions={permissions} showDetails />
136+
</div>
146137
)
147138
},
148139
},
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright 2025 Daytona Platforms Inc.
3+
* SPDX-License-Identifier: AGPL-3.0
4+
*/
5+
6+
import React from 'react'
7+
import { Badge } from './ui/badge'
8+
import { Button } from './ui/button'
9+
import { Eye } from 'lucide-react'
10+
import {
11+
summarizePermissions,
12+
getCategoryLevelLabel,
13+
getCategoryLevelVariant,
14+
type PermissionSummary,
15+
} from '@/utils/permissionSummary'
16+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog'
17+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'
18+
19+
interface PermissionBadgesProps {
20+
permissions: string[]
21+
showDetails?: boolean
22+
}
23+
24+
export function PermissionBadges({ permissions, showDetails = false }: PermissionBadgesProps) {
25+
const summary = summarizePermissions(permissions)
26+
27+
if (permissions.length === 0) {
28+
return <span className="text-muted-foreground text-sm">No permissions</span>
29+
}
30+
31+
// For simple cases (full access or read-only), show a single badge
32+
if (summary.type === 'full' || summary.type === 'readonly') {
33+
return (
34+
<div className="flex items-center gap-2">
35+
<Badge variant={summary.variant}>{summary.label}</Badge>
36+
{showDetails && <PermissionDetailsDialog permissions={permissions} summary={summary} />}
37+
</div>
38+
)
39+
}
40+
41+
// For custom permissions, show category badges
42+
return (
43+
<div className="flex items-center gap-1 flex-wrap">
44+
{summary.categories.slice(0, 3).map((category) => (
45+
<TooltipProvider key={category.name}>
46+
<Tooltip>
47+
<TooltipTrigger>
48+
<Badge variant={getCategoryLevelVariant(category.level)} className="text-xs">
49+
{category.name} {getCategoryLevelLabel(category.level)}
50+
</Badge>
51+
</TooltipTrigger>
52+
<TooltipContent>
53+
<div className="text-sm">
54+
<div className="font-medium">{category.name}</div>
55+
<div className="text-xs text-muted-foreground">{category.permissions.join(', ')}</div>
56+
</div>
57+
</TooltipContent>
58+
</Tooltip>
59+
</TooltipProvider>
60+
))}
61+
62+
{summary.categories.length > 3 && (
63+
<Badge variant="secondary" className="text-xs">
64+
+{summary.categories.length - 3} more
65+
</Badge>
66+
)}
67+
68+
{showDetails && <PermissionDetailsDialog permissions={permissions} summary={summary} />}
69+
</div>
70+
)
71+
}
72+
73+
interface PermissionDetailsDialogProps {
74+
permissions: string[]
75+
summary: PermissionSummary
76+
}
77+
78+
function PermissionDetailsDialog({ permissions, summary }: PermissionDetailsDialogProps) {
79+
return (
80+
<Dialog>
81+
<DialogTrigger asChild>
82+
<Button variant="ghost" size="icon" className="h-6 w-6" onMouseDown={(e) => e.preventDefault()}>
83+
<Eye className="h-3 w-3" />
84+
<span className="sr-only">View permission details</span>
85+
</Button>
86+
</DialogTrigger>
87+
<DialogContent className="max-w-md [&_button:focus]:!outline-none [&_button:focus]:!ring-0">
88+
<DialogHeader>
89+
<DialogTitle>Permission Details</DialogTitle>
90+
<DialogDescription>Detailed breakdown of permissions for this API key.</DialogDescription>
91+
</DialogHeader>
92+
<div className="space-y-4">
93+
<div>
94+
<h4 className="font-medium mb-2">Summary</h4>
95+
<Badge variant={summary.variant}>{summary.label}</Badge>
96+
</div>
97+
98+
{summary.categories.length > 0 && (
99+
<div>
100+
<h4 className="font-medium mb-2">Categories</h4>
101+
<div className="space-y-2">
102+
{summary.categories.map((category) => (
103+
<div key={category.name} className="border rounded-md p-3">
104+
<div className="flex items-center gap-2 mb-1">
105+
<span className="font-medium text-sm">{category.name}</span>
106+
<Badge variant={getCategoryLevelVariant(category.level)} className="text-xs">
107+
{getCategoryLevelLabel(category.level)}
108+
</Badge>
109+
</div>
110+
<div className="text-xs text-muted-foreground">{category.permissions.join(', ')}</div>
111+
</div>
112+
))}
113+
</div>
114+
</div>
115+
)}
116+
117+
<div>
118+
<h4 className="font-medium mb-2">Raw Permissions</h4>
119+
<div className="bg-muted rounded-md p-3 text-xs font-mono">{permissions.join(', ')}</div>
120+
</div>
121+
</div>
122+
</DialogContent>
123+
</Dialog>
124+
)
125+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/*
2+
* Copyright 2025 Daytona Platforms Inc.
3+
* SPDX-License-Identifier: AGPL-3.0
4+
*/
5+
6+
import { ApiKeyListPermissionsEnum } from '@daytonaio/api-client'
7+
8+
export interface PermissionSummary {
9+
type: 'full' | 'readonly' | 'custom'
10+
label: string
11+
variant: 'default' | 'secondary' | 'outline'
12+
categories: PermissionCategory[]
13+
}
14+
15+
export interface PermissionCategory {
16+
name: string
17+
permissions: string[]
18+
level: 'read' | 'write' | 'delete' | 'full'
19+
}
20+
21+
// All available permissions for comparison
22+
const ALL_PERMISSIONS = Object.values(ApiKeyListPermissionsEnum) as string[]
23+
24+
// Permission categories and their mappings
25+
const PERMISSION_CATEGORIES = {
26+
sandboxes: {
27+
name: 'Sandboxes',
28+
permissions: [ApiKeyListPermissionsEnum.WRITE_SANDBOXES, ApiKeyListPermissionsEnum.DELETE_SANDBOXES] as string[],
29+
},
30+
images: {
31+
name: 'Images',
32+
permissions: [ApiKeyListPermissionsEnum.WRITE_IMAGES, ApiKeyListPermissionsEnum.DELETE_IMAGES] as string[],
33+
},
34+
registries: {
35+
name: 'Registries',
36+
permissions: [ApiKeyListPermissionsEnum.WRITE_REGISTRIES, ApiKeyListPermissionsEnum.DELETE_REGISTRIES] as string[],
37+
},
38+
volumes: {
39+
name: 'Volumes',
40+
permissions: [
41+
ApiKeyListPermissionsEnum.READ_VOLUMES,
42+
ApiKeyListPermissionsEnum.WRITE_VOLUMES,
43+
ApiKeyListPermissionsEnum.DELETE_VOLUMES,
44+
] as string[],
45+
},
46+
}
47+
48+
/**
49+
* Determine the permission level for a category based on granted permissions
50+
*/
51+
function getCategoryLevel(
52+
categoryPermissions: string[],
53+
grantedPermissions: string[],
54+
): 'read' | 'write' | 'delete' | 'full' | null {
55+
const granted = grantedPermissions.filter((p) => categoryPermissions.includes(p))
56+
57+
if (granted.length === 0) return null
58+
if (granted.length === categoryPermissions.length) return 'full'
59+
60+
// Check specific patterns
61+
const hasRead = granted.some((p) => p.includes('read:'))
62+
const hasWrite = granted.some((p) => p.includes('write:'))
63+
const hasDelete = granted.some((p) => p.includes('delete:'))
64+
65+
// For resources with read permissions (like volumes)
66+
if (hasRead && !hasWrite && !hasDelete) return 'read'
67+
68+
// For delete permissions (highest level)
69+
if (hasDelete && hasWrite) return 'full'
70+
if (hasDelete) return 'delete'
71+
72+
// For write permissions
73+
if (hasWrite) return 'write'
74+
75+
// Fallback
76+
return hasRead ? 'read' : 'write'
77+
}
78+
79+
/**
80+
* Summarize API key permissions into a more readable format
81+
*/
82+
export function summarizePermissions(permissions: string[]): PermissionSummary {
83+
// Check if user has all permissions (full access)
84+
const hasAllPermissions = ALL_PERMISSIONS.every((permission) => permissions.includes(permission))
85+
86+
if (hasAllPermissions) {
87+
return {
88+
type: 'full',
89+
label: 'Full Access',
90+
variant: 'default',
91+
categories: [],
92+
}
93+
}
94+
95+
// Check if user has only read permissions (only applies to volumes currently)
96+
const hasOnlyReadPermissions =
97+
permissions.length === 1 && permissions.includes(ApiKeyListPermissionsEnum.READ_VOLUMES)
98+
99+
if (hasOnlyReadPermissions) {
100+
return {
101+
type: 'readonly',
102+
label: 'Read-only',
103+
variant: 'secondary',
104+
categories: [],
105+
}
106+
}
107+
108+
// Categorize permissions
109+
const categories: PermissionCategory[] = []
110+
111+
Object.entries(PERMISSION_CATEGORIES).forEach(([key, category]) => {
112+
const level = getCategoryLevel(category.permissions, permissions)
113+
if (level) {
114+
categories.push({
115+
name: category.name,
116+
permissions: permissions.filter((p) => category.permissions.includes(p)),
117+
level,
118+
})
119+
}
120+
})
121+
122+
return {
123+
type: 'custom',
124+
label: 'Custom',
125+
variant: 'outline',
126+
categories,
127+
}
128+
}
129+
130+
/**
131+
* Get a display-friendly label for a permission category level
132+
*/
133+
export function getCategoryLevelLabel(level: 'read' | 'write' | 'delete' | 'full'): string {
134+
switch (level) {
135+
case 'read':
136+
return 'Read'
137+
case 'write':
138+
return 'Write'
139+
case 'delete':
140+
return 'Delete'
141+
case 'full':
142+
return 'Full'
143+
default:
144+
return level
145+
}
146+
}
147+
148+
/**
149+
* Get a color variant for a permission category level
150+
*/
151+
export function getCategoryLevelVariant(
152+
level: 'read' | 'write' | 'delete' | 'full',
153+
): 'default' | 'secondary' | 'outline' {
154+
switch (level) {
155+
case 'read':
156+
return 'secondary'
157+
case 'write':
158+
return 'outline'
159+
case 'delete':
160+
case 'full':
161+
return 'default'
162+
default:
163+
return 'outline'
164+
}
165+
}

0 commit comments

Comments
 (0)