@orsetra/shared-ui 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/layout/index.ts +31 -0
- package/components/layout/layout-container.tsx +85 -0
- package/components/layout/root-layout-wrapper.tsx +33 -0
- package/components/layout/sidebar/data.tsx +88 -0
- package/components/layout/sidebar/main-sidebar.tsx +177 -0
- package/components/layout/sidebar/sidebar.tsx +750 -0
- package/components/layout/skeleton.tsx +15 -0
- package/components/ui/accordion.tsx +58 -0
- package/components/ui/alert-dialog.tsx +133 -0
- package/components/ui/alert.tsx +59 -0
- package/components/ui/aspect-ratio.tsx +7 -0
- package/components/ui/assets-header.tsx +50 -0
- package/components/ui/avatar.tsx +50 -0
- package/components/ui/badge.tsx +54 -0
- package/components/ui/breadcrumb.tsx +115 -0
- package/components/ui/button.tsx +83 -0
- package/components/ui/calendar.tsx +66 -0
- package/components/ui/card.tsx +79 -0
- package/components/ui/carousel.tsx +262 -0
- package/components/ui/certificate-editor.tsx +445 -0
- package/components/ui/chart.tsx +365 -0
- package/components/ui/checkbox.tsx +30 -0
- package/components/ui/collapsible.tsx +11 -0
- package/components/ui/command.tsx +153 -0
- package/components/ui/context-menu.tsx +200 -0
- package/components/ui/dialog.tsx +122 -0
- package/components/ui/drawer.tsx +118 -0
- package/components/ui/dropdown-menu.tsx +200 -0
- package/components/ui/environment-settings.tsx +173 -0
- package/components/ui/environment-variables-config.tsx +175 -0
- package/components/ui/file-import.tsx +177 -0
- package/components/ui/form.tsx +178 -0
- package/components/ui/hover-card.tsx +29 -0
- package/components/ui/index.ts +54 -0
- package/components/ui/input-otp.tsx +71 -0
- package/components/ui/input.tsx +23 -0
- package/components/ui/label.tsx +26 -0
- package/components/ui/logo.tsx +17 -0
- package/components/ui/menubar.tsx +236 -0
- package/components/ui/navigation-menu.tsx +128 -0
- package/components/ui/page-header.tsx +35 -0
- package/components/ui/pagination.tsx +112 -0
- package/components/ui/popover.tsx +31 -0
- package/components/ui/process-status.tsx +98 -0
- package/components/ui/progress.tsx +31 -0
- package/components/ui/radio-group.tsx +44 -0
- package/components/ui/resizable.tsx +45 -0
- package/components/ui/resource-settings.tsx +227 -0
- package/components/ui/scroll-area.tsx +48 -0
- package/components/ui/search-input.tsx +26 -0
- package/components/ui/secret-explorer.tsx +274 -0
- package/components/ui/secret-properties-editor.tsx +642 -0
- package/components/ui/select.tsx +162 -0
- package/components/ui/selected-asset.tsx +120 -0
- package/components/ui/separator.tsx +31 -0
- package/components/ui/sheet.tsx +140 -0
- package/components/ui/skeleton.tsx +15 -0
- package/components/ui/slider.tsx +28 -0
- package/components/ui/sonner.tsx +31 -0
- package/components/ui/switch.tsx +29 -0
- package/components/ui/table.tsx +117 -0
- package/components/ui/tabs.tsx +55 -0
- package/components/ui/textarea.tsx +22 -0
- package/components/ui/toast.tsx +131 -0
- package/components/ui/toaster.tsx +35 -0
- package/components/ui/toggle-group.tsx +61 -0
- package/components/ui/toggle.tsx +45 -0
- package/components/ui/tooltip.tsx +30 -0
- package/components/ui/user-menu.tsx +86 -0
- package/hooks/index.ts +5 -0
- package/hooks/use-auth.ts +10 -0
- package/hooks/use-mobile.ts +19 -0
- package/hooks/use-toast.ts +8 -0
- package/hooks/use-websocket.tsx +76 -0
- package/index.ts +10 -1
- package/package.json +35 -8
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react"
|
|
4
|
+
import { Button } from "./button"
|
|
5
|
+
import { Input } from "./input"
|
|
6
|
+
import { Label } from "./label"
|
|
7
|
+
import { Textarea } from "./textarea"
|
|
8
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"
|
|
9
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select"
|
|
10
|
+
import { Key, Eye, EyeOff, Copy, FileText, RefreshCw, Info } from "lucide-react"
|
|
11
|
+
import { useToast } from "../../hooks/use-toast"
|
|
12
|
+
import { getCertificateData, createCertificate, updateCertificate, getCertificate } from "@/app/secrets/service"
|
|
13
|
+
import type { CreateSecretRequest, UpdateSecretRequest } from "@/app/secrets/models"
|
|
14
|
+
import { FileImport } from "./file-import"
|
|
15
|
+
|
|
16
|
+
interface CertificateEditorProps {
|
|
17
|
+
secretPath?: string
|
|
18
|
+
initialContent?: string
|
|
19
|
+
description?: string
|
|
20
|
+
onContentChange?: (content: string) => void
|
|
21
|
+
onDescriptionChange?: (description: string) => void
|
|
22
|
+
onUpdate?: () => void
|
|
23
|
+
showUpdateButton?: boolean
|
|
24
|
+
className?: string
|
|
25
|
+
readOnly?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function CertificateEditor({
|
|
29
|
+
secretPath,
|
|
30
|
+
initialContent = "",
|
|
31
|
+
description = "",
|
|
32
|
+
onContentChange,
|
|
33
|
+
onDescriptionChange,
|
|
34
|
+
onUpdate,
|
|
35
|
+
showUpdateButton = true,
|
|
36
|
+
className = "",
|
|
37
|
+
readOnly = false
|
|
38
|
+
}: CertificateEditorProps) {
|
|
39
|
+
const { toast } = useToast()
|
|
40
|
+
const [versions, setVersions] = useState<any>({})
|
|
41
|
+
const [metadata, setMetadata] = useState<any>({})
|
|
42
|
+
const [loading, setLoading] = useState(false)
|
|
43
|
+
const [showContent, setShowContent] = useState(false)
|
|
44
|
+
const [activeTab, setActiveTab] = useState<"content" | "metadata">("content")
|
|
45
|
+
const [secretExists, setSecretExists] = useState(false)
|
|
46
|
+
const [expirationDate, setExpirationDate] = useState<Date | null>(null)
|
|
47
|
+
const [selectedVersion, setSelectedVersion] = useState<string>("")
|
|
48
|
+
const [hasChanges, setHasChanges] = useState(false)
|
|
49
|
+
const [originalContent, setOriginalContent] = useState<string>("")
|
|
50
|
+
const [originalDescription, setOriginalDescription] = useState("")
|
|
51
|
+
const [createForm, setCreateForm] = useState<{description: string, content: string, fileType: string}>({
|
|
52
|
+
description: description,
|
|
53
|
+
content: initialContent,
|
|
54
|
+
fileType: ""
|
|
55
|
+
})
|
|
56
|
+
// Load secret data when secretPath changes
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (secretPath) {
|
|
59
|
+
loadSecretData()
|
|
60
|
+
loadVersions()
|
|
61
|
+
}
|
|
62
|
+
}, [secretPath])
|
|
63
|
+
|
|
64
|
+
// Sync with external content changes
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (initialContent) {
|
|
67
|
+
setCreateForm(prev => ({ ...prev, content: initialContent }))
|
|
68
|
+
}
|
|
69
|
+
}, [initialContent])
|
|
70
|
+
|
|
71
|
+
// Check for changes when content or description changes
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
checkForChanges()
|
|
74
|
+
}, [createForm.content, createForm.description, originalContent, originalDescription])
|
|
75
|
+
|
|
76
|
+
const loadVersions = async () => {
|
|
77
|
+
if (!secretPath) return
|
|
78
|
+
const secret = await getCertificate(secretPath)
|
|
79
|
+
setVersions(secret?.metadata.versions || {})
|
|
80
|
+
// Set selected version if not already set and we have versions
|
|
81
|
+
if (!selectedVersion && secret?.metadata?.versions) {
|
|
82
|
+
const versions = Object.keys(secret.metadata.versions)
|
|
83
|
+
if (versions.length > 0) {
|
|
84
|
+
const latestVersion = Math.max(...versions.map(Number)).toString()
|
|
85
|
+
setSelectedVersion(latestVersion)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const loadSecretData = async (version?: string) => {
|
|
91
|
+
if (!secretPath) return
|
|
92
|
+
|
|
93
|
+
setLoading(true)
|
|
94
|
+
try {
|
|
95
|
+
// For Vault KV v2, we can specify version in the path or as a parameter
|
|
96
|
+
const versionedPath = version ? `${secretPath}?version=${version}` : secretPath
|
|
97
|
+
const secretData = await getCertificateData(versionedPath)
|
|
98
|
+
if (secretData) {
|
|
99
|
+
const contentStr = secretData.data?.content || ""
|
|
100
|
+
const descriptionStr = secretData.metadata?.description || ""
|
|
101
|
+
const certificateTypeStr = secretData.metadata?.certificate_type || "pem"
|
|
102
|
+
setCreateForm(prev => ({ ...prev, content: contentStr, description: descriptionStr, fileType: certificateTypeStr }))
|
|
103
|
+
setMetadata(secretData.metadata || {})
|
|
104
|
+
setSecretExists(true)
|
|
105
|
+
|
|
106
|
+
// Store original values for change detection
|
|
107
|
+
setOriginalContent(contentStr)
|
|
108
|
+
setOriginalDescription(descriptionStr)
|
|
109
|
+
setHasChanges(false)
|
|
110
|
+
} else {
|
|
111
|
+
setCreateForm(prev => ({ ...prev, content: "", description: "", fileType: "" }))
|
|
112
|
+
setMetadata({})
|
|
113
|
+
setSecretExists(false)
|
|
114
|
+
setOriginalContent("")
|
|
115
|
+
setOriginalDescription("")
|
|
116
|
+
setHasChanges(false)
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error("Error loading certificate:", error)
|
|
120
|
+
toast({
|
|
121
|
+
variant: "destructive",
|
|
122
|
+
title: "Error loading certificate",
|
|
123
|
+
description: "Failed to load certificate data."
|
|
124
|
+
})
|
|
125
|
+
} finally {
|
|
126
|
+
setLoading(false)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const checkForChanges = () => {
|
|
131
|
+
const contentChanged = createForm.content !== originalContent
|
|
132
|
+
const descriptionChanged = createForm.description !== originalDescription
|
|
133
|
+
setHasChanges(contentChanged || descriptionChanged)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const refreshCertificate = async () => {
|
|
137
|
+
if (secretPath) {
|
|
138
|
+
await loadSecretData(selectedVersion)
|
|
139
|
+
await loadVersions()
|
|
140
|
+
toast({
|
|
141
|
+
variant: "success",
|
|
142
|
+
title: "Certificate refreshed",
|
|
143
|
+
description: "Certificate data has been reloaded."
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const handleVersionChange = async (version: string) => {
|
|
149
|
+
setSelectedVersion(version)
|
|
150
|
+
await loadSecretData(version)
|
|
151
|
+
toast({
|
|
152
|
+
variant: "success",
|
|
153
|
+
title: "Version loaded",
|
|
154
|
+
description: `Loaded version ${version} of the certificate.`
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const getAvailableVersions = () => {
|
|
159
|
+
if (!versions) return []
|
|
160
|
+
return Object.entries(versions)
|
|
161
|
+
.filter(([_, versionData]: [string, any]) => !versionData.destroyed && !versionData.deletion_time)
|
|
162
|
+
.map(([versionNum, versionData]: [string, any]) => ({
|
|
163
|
+
value: versionNum,
|
|
164
|
+
label: `${secretPath}:${versionNum}`,
|
|
165
|
+
createdTime: versionData.created_time
|
|
166
|
+
}))
|
|
167
|
+
.sort((a, b) => parseInt(b.value) - parseInt(a.value)) // Sort by version number descending
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const toggleContentVisibility = () => {
|
|
171
|
+
setShowContent(!showContent)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const copyToClipboard = (value: string) => {
|
|
175
|
+
navigator.clipboard.writeText(value)
|
|
176
|
+
toast({
|
|
177
|
+
variant: "success",
|
|
178
|
+
title: "Copied to clipboard",
|
|
179
|
+
description: "Certificate content has been copied."
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const handleUpdate = async () => {
|
|
184
|
+
if (!secretPath) {
|
|
185
|
+
onUpdate?.()
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const request: CreateSecretRequest | UpdateSecretRequest = {
|
|
191
|
+
data: createForm,
|
|
192
|
+
metadata: {
|
|
193
|
+
description: createForm.description || undefined
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (secretExists) {
|
|
198
|
+
await updateCertificate(secretPath, request as UpdateSecretRequest)
|
|
199
|
+
toast({
|
|
200
|
+
variant: "success",
|
|
201
|
+
title: "Certificate updated",
|
|
202
|
+
description: `Certificate "${secretPath}" has been updated successfully.`
|
|
203
|
+
})
|
|
204
|
+
} else {
|
|
205
|
+
await createCertificate(secretPath, request as CreateSecretRequest)
|
|
206
|
+
setSecretExists(true)
|
|
207
|
+
toast({
|
|
208
|
+
variant: "success",
|
|
209
|
+
title: "Certificate created",
|
|
210
|
+
description: `Certificate "${secretPath}" has been created successfully.`
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Reset change tracking after successful update
|
|
215
|
+
setOriginalContent(createForm.content)
|
|
216
|
+
setOriginalDescription(createForm.description)
|
|
217
|
+
setHasChanges(false)
|
|
218
|
+
|
|
219
|
+
onUpdate?.()
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error("Error updating certificate:", error)
|
|
222
|
+
toast({
|
|
223
|
+
variant: "destructive",
|
|
224
|
+
title: "Error updating certificate",
|
|
225
|
+
description: "Failed to save certificate data."
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (loading) {
|
|
231
|
+
return (
|
|
232
|
+
<div className={`space-y-6 ${className}`}>
|
|
233
|
+
<div className="animate-pulse">
|
|
234
|
+
<div className="h-4 bg-ibm-gray-20 mb-4"></div>
|
|
235
|
+
<div className="h-32 bg-ibm-gray-20"></div>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<div className={`space-y-6 ${className}`}>
|
|
243
|
+
{/* Certificate Section */}
|
|
244
|
+
<div>
|
|
245
|
+
<div className="flex items-center justify-between mb-4">
|
|
246
|
+
<Label className="text-sm font-medium text-ibm-gray-100">Secret file</Label>
|
|
247
|
+
{secretPath && (
|
|
248
|
+
<div className="flex items-center gap-3">
|
|
249
|
+
<Button
|
|
250
|
+
variant="ghost"
|
|
251
|
+
size="sm"
|
|
252
|
+
onClick={refreshCertificate}
|
|
253
|
+
style={{borderRadius: 0}}
|
|
254
|
+
leftIcon={<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />}
|
|
255
|
+
disabled={loading}
|
|
256
|
+
>
|
|
257
|
+
Refresh
|
|
258
|
+
</Button>
|
|
259
|
+
|
|
260
|
+
{/* Version Selector */}
|
|
261
|
+
{getAvailableVersions().length > 0 && (
|
|
262
|
+
<Select value={selectedVersion} onValueChange={handleVersionChange}>
|
|
263
|
+
<SelectTrigger className="w-64 h-12" style={{borderRadius: 0}}>
|
|
264
|
+
<SelectValue placeholder="Select a version" />
|
|
265
|
+
</SelectTrigger>
|
|
266
|
+
<SelectContent>
|
|
267
|
+
{getAvailableVersions().map((version) => (
|
|
268
|
+
<SelectItem key={version.value} value={version.value}>
|
|
269
|
+
<div className="flex flex-col">
|
|
270
|
+
<span className="font-medium">{version.label}</span>
|
|
271
|
+
<span className="text-xs text-ibm-gray-70">
|
|
272
|
+
{new Date(version.createdTime).toLocaleString()}
|
|
273
|
+
</span>
|
|
274
|
+
</div>
|
|
275
|
+
</SelectItem>
|
|
276
|
+
))}
|
|
277
|
+
</SelectContent>
|
|
278
|
+
</Select>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "content" | "metadata")} className="w-full">
|
|
285
|
+
<div className="border-b border-ibm-gray-20">
|
|
286
|
+
<TabsList className="h-auto bg-transparent border-0 p-0 justify-start gap-0">
|
|
287
|
+
<TabsTrigger
|
|
288
|
+
value="content"
|
|
289
|
+
className="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors border-b-2 border-transparent data-[state=active]:border-ibm-blue-60 data-[state=active]:text-ibm-blue-60 text-ibm-gray-70 hover:text-ibm-blue-60 bg-transparent rounded-none"
|
|
290
|
+
>
|
|
291
|
+
<FileText className="w-4 h-4" />
|
|
292
|
+
Content
|
|
293
|
+
</TabsTrigger>
|
|
294
|
+
<TabsTrigger
|
|
295
|
+
value="metadata"
|
|
296
|
+
className="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors border-b-2 border-transparent data-[state=active]:border-ibm-blue-60 data-[state=active]:text-ibm-blue-60 text-ibm-gray-70 hover:text-ibm-blue-60 bg-transparent rounded-none"
|
|
297
|
+
>
|
|
298
|
+
<Info className="w-4 h-4" />
|
|
299
|
+
Metadata
|
|
300
|
+
</TabsTrigger>
|
|
301
|
+
</TabsList>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<TabsContent value="content" className="mt-4">
|
|
305
|
+
<div className="space-y-4">
|
|
306
|
+
<div>
|
|
307
|
+
<div className="flex items-center justify-between mb-2">
|
|
308
|
+
<div className="flex items-center gap-2">
|
|
309
|
+
<Label className="text-sm font-medium text-ibm-gray-100">Expiration date:</Label>
|
|
310
|
+
<Input
|
|
311
|
+
type= "date"
|
|
312
|
+
value={expirationDate ? expirationDate.toISOString().split('T')[0] : ""}
|
|
313
|
+
onChange={(e) => setExpirationDate(e.target.value ? new Date(e.target.value) : null)}
|
|
314
|
+
className="w-30 h-6 border-none text-sm"
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
<div className="flex items-center gap-2">
|
|
318
|
+
<Button
|
|
319
|
+
variant="ghost"
|
|
320
|
+
size="xs"
|
|
321
|
+
onClick={toggleContentVisibility}
|
|
322
|
+
leftIcon={showContent ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
|
|
323
|
+
className="h-6 px-2"
|
|
324
|
+
>
|
|
325
|
+
{showContent ? "Hide" : "Show"}
|
|
326
|
+
</Button>
|
|
327
|
+
<Button
|
|
328
|
+
variant="ghost"
|
|
329
|
+
size="xs"
|
|
330
|
+
onClick={() => copyToClipboard(createForm.content)}
|
|
331
|
+
leftIcon={<Copy className="w-3 h-3" />}
|
|
332
|
+
className="h-6 px-2"
|
|
333
|
+
>
|
|
334
|
+
Copy
|
|
335
|
+
</Button>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
<Textarea
|
|
339
|
+
value={createForm.content}
|
|
340
|
+
onChange={(e) => {
|
|
341
|
+
|
|
342
|
+
setCreateForm(prev => ({ ...prev, content: e.target.value }))
|
|
343
|
+
onContentChange?.(e.target.value)
|
|
344
|
+
}}
|
|
345
|
+
placeholder="-----BEGIN CERTIFICATE----- Certificate content here... -----END CERTIFICATE-----"
|
|
346
|
+
className="mt-2 min-h-[200px] max-h-64 font-mono text-sm resize-y"
|
|
347
|
+
readOnly={readOnly}
|
|
348
|
+
style={{
|
|
349
|
+
fontFamily: 'monospace',
|
|
350
|
+
fontSize: '12px',
|
|
351
|
+
lineHeight: '1.4'
|
|
352
|
+
}}
|
|
353
|
+
/>
|
|
354
|
+
<div className="mt-2 flex items-center gap-2">
|
|
355
|
+
<FileImport
|
|
356
|
+
onFileContent={(content, fileName, fileType) => {
|
|
357
|
+
setCreateForm(prev => ({ ...prev, content: content, fileType: fileType}))
|
|
358
|
+
}}
|
|
359
|
+
acceptedTypes={["text/plain", "application/x-pem-file", "application/x-x509-ca-cert"]}
|
|
360
|
+
buttonText="Import content"
|
|
361
|
+
buttonVariant="ghost"
|
|
362
|
+
buttonSize="sm"
|
|
363
|
+
/>
|
|
364
|
+
<p className="text-xs text-ibm-gray-70">
|
|
365
|
+
Paste your secret file content in text format. This can be a certificate, private key, or certificate chain.
|
|
366
|
+
</p>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
</TabsContent>
|
|
371
|
+
|
|
372
|
+
<TabsContent value="metadata" className="mt-4">
|
|
373
|
+
<div className="space-y-4">
|
|
374
|
+
<div>
|
|
375
|
+
<Label className="text-sm font-medium text-ibm-gray-100">Certificate Metadata</Label>
|
|
376
|
+
<div className="mt-2 p-4 bg-ibm-gray-10 border border-ibm-gray-20 rounded">
|
|
377
|
+
{Object.keys(metadata).length > 0 ? (
|
|
378
|
+
<div className="space-y-3">
|
|
379
|
+
{Object.entries(metadata).map(([key, value]) => (
|
|
380
|
+
<div key={key} className="flex items-start gap-3">
|
|
381
|
+
<span className="text-sm font-medium text-ibm-gray-100 min-w-[120px] capitalize">
|
|
382
|
+
{key.replace(/_/g, ' ')}:
|
|
383
|
+
</span>
|
|
384
|
+
<span className="text-sm text-ibm-gray-70 break-all">
|
|
385
|
+
{typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value || 'N/A')}
|
|
386
|
+
</span>
|
|
387
|
+
</div>
|
|
388
|
+
))}
|
|
389
|
+
</div>
|
|
390
|
+
) : (
|
|
391
|
+
<div className="text-center py-6">
|
|
392
|
+
<Info className="w-8 h-8 text-ibm-gray-30 mx-auto mb-2" />
|
|
393
|
+
<p className="text-sm text-ibm-gray-70">
|
|
394
|
+
No metadata available for this certificate
|
|
395
|
+
</p>
|
|
396
|
+
</div>
|
|
397
|
+
)}
|
|
398
|
+
</div>
|
|
399
|
+
<p className="text-xs text-ibm-gray-70 mt-1">
|
|
400
|
+
This information is automatically generated by the certificate management system.
|
|
401
|
+
</p>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
{/* Description Field */}
|
|
405
|
+
<div>
|
|
406
|
+
{readOnly ? (
|
|
407
|
+
<div className="p-3 bg-ibm-gray-10 border border-ibm-gray-20 rounded">
|
|
408
|
+
<p className="text-sm text-ibm-gray-70">
|
|
409
|
+
{createForm.description || "No description provided"}
|
|
410
|
+
</p>
|
|
411
|
+
</div>
|
|
412
|
+
) : (
|
|
413
|
+
<Input
|
|
414
|
+
value={createForm.description}
|
|
415
|
+
onChange={(e) => {
|
|
416
|
+
setCreateForm(prev => ({ ...prev, description: e.target.value }))
|
|
417
|
+
onDescriptionChange?.(e.target.value)
|
|
418
|
+
}}
|
|
419
|
+
placeholder="Enter certificate description..."
|
|
420
|
+
className="text-sm border-ibm-gray-20"
|
|
421
|
+
/>
|
|
422
|
+
)}
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
</TabsContent>
|
|
426
|
+
</Tabs>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
{/* Update Button */}
|
|
430
|
+
{showUpdateButton && !readOnly && (
|
|
431
|
+
<div className="flex justify-end pt-4 border-b-0">
|
|
432
|
+
<Button
|
|
433
|
+
variant="primary"
|
|
434
|
+
style={{borderRadius: 0}}
|
|
435
|
+
size="sm"
|
|
436
|
+
onClick={handleUpdate}
|
|
437
|
+
disabled={!hasChanges || !createForm.content.trim()}
|
|
438
|
+
>
|
|
439
|
+
Update Certificate
|
|
440
|
+
</Button>
|
|
441
|
+
</div>
|
|
442
|
+
)}
|
|
443
|
+
</div>
|
|
444
|
+
)
|
|
445
|
+
}
|