@orsetra/shared-ui 1.5.10 → 1.5.12
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/sidebar/main-sidebar.tsx +56 -31
- package/components/layout/sidebar/sidebar.tsx +5 -4
- package/components/ui/file-chip.tsx +62 -0
- package/components/ui/index.ts +2 -0
- package/components/ui/resources-input.tsx +170 -0
- package/components/ui/secret-context.tsx +6 -1
- package/components/ui/secret-input.tsx +14 -9
- package/components/ui/secrets-input.tsx +50 -20
- package/package.json +1 -1
- package/components/ui/secret-explorer.tsx +0 -274
|
@@ -56,11 +56,12 @@ export function MainSidebar({
|
|
|
56
56
|
onSecondarySidebarOpen()
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
if (item.href) {
|
|
60
|
+
const path = item.href.startsWith('http://') || item.href.startsWith('https://')
|
|
61
|
+
? new URL(item.href).pathname
|
|
62
|
+
: item.href
|
|
63
|
+
window.location.href = path
|
|
64
|
+
}
|
|
64
65
|
|
|
65
66
|
if (!isMinimized) {
|
|
66
67
|
onToggle()
|
|
@@ -126,34 +127,58 @@ export function MainSidebar({
|
|
|
126
127
|
const isActive = currentMenu === item.id
|
|
127
128
|
const isExpanded = expandedMenu === item.id
|
|
128
129
|
|
|
130
|
+
const itemHref = !hasSubMenu && item.href
|
|
131
|
+
? (item.href.startsWith('http://') || item.href.startsWith('https://')
|
|
132
|
+
? new URL(item.href).pathname
|
|
133
|
+
: item.href)
|
|
134
|
+
: undefined
|
|
135
|
+
|
|
136
|
+
const itemClassName = cn(
|
|
137
|
+
"w-full flex items-center text-left transition-all duration-150 font-medium",
|
|
138
|
+
isMinimized ? "justify-center p-3" : "gap-3 px-3 py-2",
|
|
139
|
+
isActive
|
|
140
|
+
? "bg-interactive/10 text-interactive border-l-4 border-interactive"
|
|
141
|
+
: "text-text-primary hover:bg-ui-background border-l-4 border-transparent"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const itemContent = (
|
|
145
|
+
<>
|
|
146
|
+
<Icon className={cn(
|
|
147
|
+
"h-5 w-5 flex-shrink-0",
|
|
148
|
+
isActive ? "text-interactive" : "text-text-secondary"
|
|
149
|
+
)} />
|
|
150
|
+
{!isMinimized && (
|
|
151
|
+
<>
|
|
152
|
+
<span className="text-base flex-1">{item.label}</span>
|
|
153
|
+
{hasSubMenu && (
|
|
154
|
+
isExpanded
|
|
155
|
+
? <ChevronDown className="h-4 w-4 text-text-secondary flex-shrink-0" />
|
|
156
|
+
: <ChevronRight className="h-4 w-4 text-text-secondary flex-shrink-0" />
|
|
157
|
+
)}
|
|
158
|
+
</>
|
|
159
|
+
)}
|
|
160
|
+
</>
|
|
161
|
+
)
|
|
162
|
+
|
|
129
163
|
return (
|
|
130
164
|
<div key={item.id}>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
isMinimized ?
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
<span className="text-base flex-1">{item.label}</span>
|
|
149
|
-
{hasSubMenu && (
|
|
150
|
-
isExpanded
|
|
151
|
-
? <ChevronDown className="h-4 w-4 text-text-secondary flex-shrink-0" />
|
|
152
|
-
: <ChevronRight className="h-4 w-4 text-text-secondary flex-shrink-0" />
|
|
153
|
-
)}
|
|
154
|
-
</>
|
|
155
|
-
)}
|
|
156
|
-
</button>
|
|
165
|
+
{itemHref ? (
|
|
166
|
+
<a
|
|
167
|
+
href={itemHref}
|
|
168
|
+
className={itemClassName}
|
|
169
|
+
title={isMinimized ? item.label : undefined}
|
|
170
|
+
>
|
|
171
|
+
{itemContent}
|
|
172
|
+
</a>
|
|
173
|
+
) : (
|
|
174
|
+
<button
|
|
175
|
+
onClick={() => handleMenuClick(item)}
|
|
176
|
+
className={itemClassName}
|
|
177
|
+
title={isMinimized ? item.label : undefined}
|
|
178
|
+
>
|
|
179
|
+
{itemContent}
|
|
180
|
+
</button>
|
|
181
|
+
)}
|
|
157
182
|
|
|
158
183
|
{/* Inline accordion sub-items */}
|
|
159
184
|
{!isMinimized && isExpanded && hasSubMenu && (
|
|
@@ -197,9 +197,9 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
|
|
|
197
197
|
|
|
198
198
|
const filteredMainItems = React.useMemo(() => {
|
|
199
199
|
if (!isCollapsed || mainMenuItems.length === 0) return []
|
|
200
|
-
// Exclude items that match the current menu context OR appear in the secondary nav
|
|
200
|
+
// Exclude items that match the current menu context OR appear in the secondary nav OR have no href
|
|
201
201
|
const navIds = new Set(currentNavigation.map((n) => n.id))
|
|
202
|
-
return mainMenuItems.filter((item) => item.id !== currentMenu && !navIds.has(item.id))
|
|
202
|
+
return mainMenuItems.filter((item) => item.id !== currentMenu && !navIds.has(item.id) && !!item.href)
|
|
203
203
|
}, [isCollapsed, mainMenuItems, currentNavigation, currentMenu])
|
|
204
204
|
|
|
205
205
|
return (
|
|
@@ -300,8 +300,9 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
|
|
|
300
300
|
<>
|
|
301
301
|
<div className="border-t border-ui-border mx-1 my-2" />
|
|
302
302
|
{filteredMainItems.map((item) => {
|
|
303
|
-
const
|
|
304
|
-
|
|
303
|
+
const href = item.href!.startsWith('http://') || item.href!.startsWith('https://')
|
|
304
|
+
? new URL(item.href!).pathname
|
|
305
|
+
: item.href!
|
|
305
306
|
const linkEl = (
|
|
306
307
|
<Link
|
|
307
308
|
key={item.id}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { FileCode, X } from "lucide-react"
|
|
4
|
+
|
|
5
|
+
export interface FileChipProps {
|
|
6
|
+
file: { name: string; id?: string }
|
|
7
|
+
isActive: boolean
|
|
8
|
+
isEditing: boolean
|
|
9
|
+
disabled?: boolean
|
|
10
|
+
onSelect: () => void
|
|
11
|
+
onDelete: (e: React.MouseEvent) => void
|
|
12
|
+
onNameChange: (name: string) => void
|
|
13
|
+
onEditStart: () => void
|
|
14
|
+
onEditEnd: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function FileChip({ file, isActive, isEditing, disabled, onSelect, onDelete, onNameChange, onEditStart, onEditEnd }: FileChipProps) {
|
|
18
|
+
const showInput = isEditing || file.name === ""
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
onClick={onSelect}
|
|
23
|
+
className={`flex items-center gap-1 px-2 py-0.5 text-xs cursor-pointer border transition-colors shrink-0 ${
|
|
24
|
+
isActive
|
|
25
|
+
? "border-ibm-blue-60 bg-white text-ibm-blue-60"
|
|
26
|
+
: "border-ibm-gray-20 bg-white text-ibm-gray-70 hover:border-ibm-gray-40"
|
|
27
|
+
}`}
|
|
28
|
+
>
|
|
29
|
+
<FileCode className="w-3 h-3 shrink-0 flex-none" />
|
|
30
|
+
|
|
31
|
+
{showInput ? (
|
|
32
|
+
<input
|
|
33
|
+
autoFocus
|
|
34
|
+
type="text"
|
|
35
|
+
value={file.name}
|
|
36
|
+
onChange={(e) => onNameChange(e.target.value)}
|
|
37
|
+
onBlur={onEditEnd}
|
|
38
|
+
onClick={(e) => e.stopPropagation()}
|
|
39
|
+
placeholder="filename.yaml"
|
|
40
|
+
className="w-24 text-xs outline-none border-b border-ibm-blue-40 bg-transparent placeholder:text-ibm-gray-40 text-ibm-gray-100"
|
|
41
|
+
/>
|
|
42
|
+
) : (
|
|
43
|
+
<span
|
|
44
|
+
className="truncate max-w-[120px]"
|
|
45
|
+
onDoubleClick={(e) => { e.stopPropagation(); if (isActive) onEditStart() }}
|
|
46
|
+
>
|
|
47
|
+
{file.name}
|
|
48
|
+
</span>
|
|
49
|
+
)}
|
|
50
|
+
|
|
51
|
+
{!disabled && (
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
onClick={(e) => { e.stopPropagation(); onDelete(e) }}
|
|
55
|
+
className="ml-0.5 hover:text-red-500 transition-colors flex-none"
|
|
56
|
+
>
|
|
57
|
+
<X className="w-3 h-3" />
|
|
58
|
+
</button>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
package/components/ui/index.ts
CHANGED
|
@@ -64,6 +64,7 @@ export { ToggleGroup, ToggleGroupItem } from './toggle-group'
|
|
|
64
64
|
export { Toggle } from './toggle'
|
|
65
65
|
export { StringsInput } from './strings-input'
|
|
66
66
|
export { KVInput } from './kv-input'
|
|
67
|
+
export { ResourcesInput } from './resources-input'
|
|
67
68
|
export { NumbersInput } from './numbers-input'
|
|
68
69
|
export { KVDynamicInput } from './kv-dynamic-input'
|
|
69
70
|
export { SelectInput } from './select-input'
|
|
@@ -73,4 +74,5 @@ export { SecretsInput } from './secrets-input'
|
|
|
73
74
|
export { StructsInput } from './structs-input'
|
|
74
75
|
export { CodeEditor } from './code-editor'
|
|
75
76
|
export { K8sObjectsCode } from './k8s-objects-code'
|
|
77
|
+
export { FileChip } from './file-chip'
|
|
76
78
|
export { EnvironmentPickerDialog, type EnvironmentPickerDialogProps } from './environment-picker-dialog'
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from "react"
|
|
4
|
+
import { Button } from "./button"
|
|
5
|
+
import { Plus, X, FileCode } from "lucide-react"
|
|
6
|
+
import { SecretInput } from "./secret-input"
|
|
7
|
+
|
|
8
|
+
interface ResourcesInputProps {
|
|
9
|
+
id?: string
|
|
10
|
+
value?: string[]
|
|
11
|
+
onChange?: (value: string[]) => void
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
[key: string]: any
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SECRET_PREFIX = "secret:"
|
|
17
|
+
const BASE_PATH = "/etc"
|
|
18
|
+
|
|
19
|
+
function parseItem(item: string): { name: string; path: string } {
|
|
20
|
+
const raw = item.startsWith(SECRET_PREFIX) ? item.slice(SECRET_PREFIX.length) : item
|
|
21
|
+
const at = raw.indexOf("@")
|
|
22
|
+
if (at === -1) return { name: raw, path: BASE_PATH }
|
|
23
|
+
return { name: raw.slice(0, at), path: raw.slice(at + 1) }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildItem(name: string, path: string): string {
|
|
27
|
+
return `${SECRET_PREFIX}${name}@${path}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pathSuffix(path: string): string {
|
|
31
|
+
const after = path.startsWith(BASE_PATH) ? path.slice(BASE_PATH.length) : path
|
|
32
|
+
return after.startsWith("/") ? after.slice(1) : after
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ResourcesInput({ id, value, onChange, disabled = false, ...rest }: ResourcesInputProps) {
|
|
36
|
+
const normalize = (v: any): string[] =>
|
|
37
|
+
Array.isArray(v) ? v.filter(Boolean).map((item) => item.startsWith(SECRET_PREFIX) ? item : buildItem(parseItem(item).name, parseItem(item).path)) : []
|
|
38
|
+
const [items, setItems] = useState<string[]>(normalize(value))
|
|
39
|
+
const [draft, setDraft] = useState("")
|
|
40
|
+
const [editingPathIndex, setEditingPathIndex] = useState<number | null>(null)
|
|
41
|
+
const [editingPathSuffix, setEditingPathSuffix] = useState("")
|
|
42
|
+
const mounted = useRef(false)
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const filtered = normalize(value)
|
|
46
|
+
if (!mounted.current) {
|
|
47
|
+
mounted.current = true
|
|
48
|
+
const raw = Array.isArray(value) ? value.filter(Boolean) : []
|
|
49
|
+
if (JSON.stringify(filtered) !== JSON.stringify(raw)) {
|
|
50
|
+
onChange?.(filtered)
|
|
51
|
+
}
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (JSON.stringify(filtered) !== JSON.stringify(items)) {
|
|
55
|
+
setItems(filtered)
|
|
56
|
+
}
|
|
57
|
+
}, [value])
|
|
58
|
+
|
|
59
|
+
const handleAdd = () => {
|
|
60
|
+
if (!draft || items.includes(draft)) return
|
|
61
|
+
const next = [...items, draft]
|
|
62
|
+
setItems(next)
|
|
63
|
+
onChange?.(next)
|
|
64
|
+
setDraft("")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const handleRemove = (index: number) => {
|
|
68
|
+
const next = items.filter((_, i) => i !== index)
|
|
69
|
+
setItems(next)
|
|
70
|
+
onChange?.(next)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const startPathEditing = (index: number, path: string) => {
|
|
74
|
+
if (disabled) return
|
|
75
|
+
setEditingPathIndex(index)
|
|
76
|
+
setEditingPathSuffix(pathSuffix(path))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const confirmPathEdit = (index: number) => {
|
|
80
|
+
const { name } = parseItem(items[index])
|
|
81
|
+
const fullPath = `${BASE_PATH}/${editingPathSuffix}`
|
|
82
|
+
const next = items.map((item, i) => i === index ? buildItem(name, fullPath) : item)
|
|
83
|
+
setItems(next)
|
|
84
|
+
onChange?.(next)
|
|
85
|
+
setEditingPathIndex(null)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="space-y-2">
|
|
90
|
+
{items.map((item, index) => {
|
|
91
|
+
const { name, path } = parseItem(item)
|
|
92
|
+
const suffix = pathSuffix(path)
|
|
93
|
+
const isEditingPath = editingPathIndex === index
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
key={index}
|
|
98
|
+
className={`flex items-center h-10 bg-white transition-colors ${
|
|
99
|
+
isEditingPath ? "" : "border border-ibm-gray-20 hover:border-ibm-gray-40"
|
|
100
|
+
}`}
|
|
101
|
+
>
|
|
102
|
+
{/* Name */}
|
|
103
|
+
<div className="flex items-center gap-1.5 px-3 shrink-0 border-r border-ibm-gray-20">
|
|
104
|
+
<FileCode className="w-3 h-3 text-ibm-gray-50 shrink-0" />
|
|
105
|
+
<span className="text-xs font-mono text-ibm-gray-100">{name}</span>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{/* Path */}
|
|
109
|
+
{isEditingPath ? (
|
|
110
|
+
<div className="flex flex-1 items-center px-3 min-w-0">
|
|
111
|
+
<span className="text-sm font-mono text-ibm-gray-40 shrink-0 select-none">{BASE_PATH}/</span>
|
|
112
|
+
<input
|
|
113
|
+
autoFocus
|
|
114
|
+
value={editingPathSuffix}
|
|
115
|
+
onChange={(e) => setEditingPathSuffix(e.target.value)}
|
|
116
|
+
onBlur={() => confirmPathEdit(index)}
|
|
117
|
+
onKeyDown={(e) => {
|
|
118
|
+
if (e.key === "Enter") confirmPathEdit(index)
|
|
119
|
+
if (e.key === "Escape") setEditingPathIndex(null)
|
|
120
|
+
}}
|
|
121
|
+
className="flex-1 text-sm font-mono text-ibm-gray-100 bg-transparent focus:outline-none min-w-0"
|
|
122
|
+
placeholder="/myapp"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
) : (
|
|
126
|
+
<span
|
|
127
|
+
onDoubleClick={() => startPathEditing(index, path)}
|
|
128
|
+
title={disabled ? undefined : "Double-click to edit path"}
|
|
129
|
+
className={`flex flex-1 items-center px-3 min-w-0 text-sm font-mono ${
|
|
130
|
+
disabled ? "cursor-default" : "cursor-text"
|
|
131
|
+
}`}
|
|
132
|
+
>
|
|
133
|
+
<span className="text-ibm-gray-40 shrink-0">{BASE_PATH}/</span>
|
|
134
|
+
<span className={`truncate ${suffix ? "text-ibm-gray-70" : "text-ibm-gray-40 italic"}`}>
|
|
135
|
+
{suffix || "…"}
|
|
136
|
+
</span>
|
|
137
|
+
</span>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{/* Delete */}
|
|
141
|
+
{!disabled && (
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
onClick={() => handleRemove(index)}
|
|
145
|
+
className="px-2 h-full flex items-center text-ibm-gray-40 hover:text-red-500 transition-colors shrink-0 border-l border-ibm-gray-20"
|
|
146
|
+
>
|
|
147
|
+
<X className="w-3.5 h-3.5" />
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
})}
|
|
153
|
+
|
|
154
|
+
<div className="flex gap-2">
|
|
155
|
+
<div className="flex-1">
|
|
156
|
+
<SecretInput value={draft} onChange={setDraft} disabled={disabled} type={id} />
|
|
157
|
+
</div>
|
|
158
|
+
<Button
|
|
159
|
+
type="button"
|
|
160
|
+
variant="secondary"
|
|
161
|
+
onClick={handleAdd}
|
|
162
|
+
disabled={disabled || !draft}
|
|
163
|
+
className="rounded-none h-10 w-10 p-0 shrink-0"
|
|
164
|
+
>
|
|
165
|
+
<Plus className="h-3.5 w-3.5" />
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
@@ -6,23 +6,28 @@ import type { ReactNode } from "react"
|
|
|
6
6
|
export interface SecretEntry {
|
|
7
7
|
name: string
|
|
8
8
|
description?: string
|
|
9
|
+
labels?: string[]
|
|
10
|
+
template?: string
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
interface SecretContextValue {
|
|
12
14
|
secretsMap: Map<string, SecretEntry[]>
|
|
15
|
+
loading: boolean
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
const SecretContext = createContext<SecretContextValue | null>(null)
|
|
16
19
|
|
|
17
20
|
export function SecretContextProvider({
|
|
18
21
|
children,
|
|
22
|
+
loading = false,
|
|
19
23
|
secretsMap,
|
|
20
24
|
}: {
|
|
21
25
|
children: ReactNode
|
|
26
|
+
loading?: boolean
|
|
22
27
|
secretsMap: Map<string, SecretEntry[]>
|
|
23
28
|
}) {
|
|
24
29
|
return (
|
|
25
|
-
<SecretContext.Provider value={{ secretsMap }}>
|
|
30
|
+
<SecretContext.Provider value={{ secretsMap, loading }}>
|
|
26
31
|
{children}
|
|
27
32
|
</SecretContext.Provider>
|
|
28
33
|
)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { SelectInput } from "./select-input"
|
|
4
4
|
import { useSecretContext } from "./secret-context"
|
|
5
|
+
import { Loader2 } from "lucide-react"
|
|
5
6
|
|
|
6
7
|
interface SecretInputProps {
|
|
7
8
|
id?: string
|
|
@@ -31,16 +32,20 @@ export function SecretInput({
|
|
|
31
32
|
</div>
|
|
32
33
|
)
|
|
33
34
|
}
|
|
35
|
+
console.log(type)
|
|
34
36
|
const secrets = ctx.secretsMap.get(type || 'configs') || []
|
|
35
37
|
return (
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
<div className="flex items-center gap-2">
|
|
39
|
+
<SelectInput
|
|
40
|
+
id={id}
|
|
41
|
+
value={value}
|
|
42
|
+
onChange={onChange}
|
|
43
|
+
disabled={disabled || ctx.loading}
|
|
44
|
+
className={className}
|
|
45
|
+
placeholder={ctx.loading ? "Loading…" : placeholder}
|
|
46
|
+
options={secrets.map((s) => ({ label: s.description || s.name, value: s.name }))}
|
|
47
|
+
/>
|
|
48
|
+
{ctx.loading && <Loader2 className="h-4 w-4 animate-spin text-ibm-blue-60 flex-shrink-0" />}
|
|
49
|
+
</div>
|
|
45
50
|
)
|
|
46
51
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect } from "react"
|
|
3
|
+
import { useState, useEffect, useRef } from "react"
|
|
4
4
|
import { Button } from "./button"
|
|
5
|
-
import { X, Plus } from "lucide-react"
|
|
5
|
+
import { X, Plus, FileCode } from "lucide-react"
|
|
6
6
|
import { SecretInput } from "./secret-input"
|
|
7
7
|
|
|
8
8
|
interface SecretsInputProps {
|
|
@@ -13,20 +13,43 @@ interface SecretsInputProps {
|
|
|
13
13
|
[key: string]: any
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const SECRET_PREFIX = "secret:"
|
|
17
|
+
|
|
18
|
+
function parseName(item: string): string {
|
|
19
|
+
return item.startsWith(SECRET_PREFIX) ? item.slice(SECRET_PREFIX.length) : item
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildItem(name: string): string {
|
|
23
|
+
return `${SECRET_PREFIX}${name}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function SecretsInput({ id, value, onChange, disabled = false, ...rest }: SecretsInputProps) {
|
|
27
|
+
const normalize = (v: any): string[] =>
|
|
28
|
+
Array.isArray(v) ? v.filter(Boolean).map((item) => item.startsWith(SECRET_PREFIX) ? item : buildItem(item)) : []
|
|
29
|
+
const [items, setItems] = useState<string[]>(normalize(value))
|
|
18
30
|
const [draft, setDraft] = useState("")
|
|
31
|
+
const mounted = useRef(false)
|
|
19
32
|
|
|
20
33
|
useEffect(() => {
|
|
21
|
-
const filtered = value
|
|
34
|
+
const filtered = normalize(value)
|
|
35
|
+
if (!mounted.current) {
|
|
36
|
+
mounted.current = true
|
|
37
|
+
const raw = Array.isArray(value) ? value.filter(Boolean) : []
|
|
38
|
+
if (JSON.stringify(filtered) !== JSON.stringify(raw)) {
|
|
39
|
+
onChange?.(filtered)
|
|
40
|
+
}
|
|
41
|
+
return
|
|
42
|
+
}
|
|
22
43
|
if (JSON.stringify(filtered) !== JSON.stringify(items)) {
|
|
23
44
|
setItems(filtered)
|
|
24
45
|
}
|
|
25
46
|
}, [value])
|
|
26
47
|
|
|
27
48
|
const handleAdd = () => {
|
|
28
|
-
if (!draft
|
|
29
|
-
const
|
|
49
|
+
if (!draft) return
|
|
50
|
+
const built = buildItem(draft)
|
|
51
|
+
if (items.includes(built)) return
|
|
52
|
+
const next = [...items, built]
|
|
30
53
|
setItems(next)
|
|
31
54
|
onChange?.(next)
|
|
32
55
|
setDraft("")
|
|
@@ -41,19 +64,26 @@ export function SecretsInput({ id, value = [], onChange, disabled = false, ...re
|
|
|
41
64
|
return (
|
|
42
65
|
<div className="space-y-2">
|
|
43
66
|
{items.map((item, index) => (
|
|
44
|
-
<div
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
<div
|
|
68
|
+
key={index}
|
|
69
|
+
className="flex items-center h-10 border border-ibm-gray-20 bg-white hover:border-ibm-gray-40 transition-colors"
|
|
70
|
+
>
|
|
71
|
+
<div className="flex items-center gap-1.5 px-3 flex-1 min-w-0">
|
|
72
|
+
<FileCode className="w-3 h-3 text-ibm-gray-50 shrink-0" />
|
|
73
|
+
<span className="text-xs font-mono text-ibm-gray-100 truncate">
|
|
74
|
+
{parseName(item)}
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{!disabled && (
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={() => handleRemove(index)}
|
|
82
|
+
className="px-2 h-full flex items-center text-ibm-gray-40 hover:text-red-500 transition-colors shrink-0 border-l border-ibm-gray-20"
|
|
83
|
+
>
|
|
84
|
+
<X className="w-3.5 h-3.5" />
|
|
85
|
+
</button>
|
|
86
|
+
)}
|
|
57
87
|
</div>
|
|
58
88
|
))}
|
|
59
89
|
|
package/package.json
CHANGED
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect } from "react"
|
|
4
|
-
import { Button } from "./button"
|
|
5
|
-
import { Card, CardContent, CardHeader, CardTitle } from "./card"
|
|
6
|
-
import { Key, Plus, Trash2, Folder, File, ChevronRight } from "lucide-react"
|
|
7
|
-
import { useToast } from "../../hooks/use-toast"
|
|
8
|
-
|
|
9
|
-
import type { Secrets } from "@/app/secrets/models"
|
|
10
|
-
|
|
11
|
-
interface SecretExplorerProps {
|
|
12
|
-
currentPath: string
|
|
13
|
-
onPathChange: (path: string) => void
|
|
14
|
-
getSecrets: (path: string) => Promise<Secrets>
|
|
15
|
-
deleteSecret: (path: string) => Promise<boolean>
|
|
16
|
-
selectedSecret: string | null
|
|
17
|
-
onSecretSelect: (secret: string | null) => void
|
|
18
|
-
onCreateClick: () => void
|
|
19
|
-
onRefresh?: () => void
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function SecretExplorer({
|
|
23
|
-
currentPath,
|
|
24
|
-
onPathChange,
|
|
25
|
-
getSecrets,
|
|
26
|
-
deleteSecret,
|
|
27
|
-
selectedSecret,
|
|
28
|
-
onSecretSelect,
|
|
29
|
-
onCreateClick,
|
|
30
|
-
onRefresh
|
|
31
|
-
}: SecretExplorerProps) {
|
|
32
|
-
const { toast } = useToast()
|
|
33
|
-
const [secretPaths, setSecretPaths] = useState<string[]>([])
|
|
34
|
-
const [loading, setLoading] = useState(true)
|
|
35
|
-
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
|
36
|
-
const [selectedSecretForAction, setSelectedSecretForAction] = useState<string | null>(null)
|
|
37
|
-
|
|
38
|
-
useEffect(() => {
|
|
39
|
-
loadSecrets()
|
|
40
|
-
}, [currentPath])
|
|
41
|
-
|
|
42
|
-
const loadSecrets = async () => {
|
|
43
|
-
try {
|
|
44
|
-
const secretsResponse: Secrets = await getSecrets(currentPath || "/")
|
|
45
|
-
const paths = secretsResponse.data?.keys || []
|
|
46
|
-
setSecretPaths(paths)
|
|
47
|
-
} catch (error) {
|
|
48
|
-
// Navigate up if current path is invalid
|
|
49
|
-
if (currentPath) {
|
|
50
|
-
const pathParts = currentPath.split('/').filter(Boolean)
|
|
51
|
-
pathParts.pop()
|
|
52
|
-
const newPath = pathParts.length > 0 ? pathParts.join('/') + '/' : ""
|
|
53
|
-
onPathChange(newPath)
|
|
54
|
-
}
|
|
55
|
-
toast({
|
|
56
|
-
variant: "destructive",
|
|
57
|
-
title: "Error loading secrets",
|
|
58
|
-
description: "Failed to fetch configuration secrets."
|
|
59
|
-
})
|
|
60
|
-
} finally {
|
|
61
|
-
setLoading(false)
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const handleItemClick = async (path: string) => {
|
|
66
|
-
if (path.endsWith('/')) {
|
|
67
|
-
// It's a folder, navigate into it
|
|
68
|
-
const newPath = currentPath ? `${currentPath}${path}` : path
|
|
69
|
-
onPathChange(newPath)
|
|
70
|
-
onSecretSelect(null)
|
|
71
|
-
} else {
|
|
72
|
-
// It's a secret, show details
|
|
73
|
-
onSecretSelect(path)
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const getBreadcrumbs = () => {
|
|
78
|
-
if (!currentPath) return [{ name: "Root", path: "" }]
|
|
79
|
-
|
|
80
|
-
const parts = currentPath.split('/').filter(Boolean)
|
|
81
|
-
const breadcrumbs = [{ name: "Root", path: "" }]
|
|
82
|
-
|
|
83
|
-
let accumulatedPath = ""
|
|
84
|
-
parts.forEach((part, index) => {
|
|
85
|
-
accumulatedPath += part + "/"
|
|
86
|
-
breadcrumbs.push({
|
|
87
|
-
name: part,
|
|
88
|
-
path: accumulatedPath
|
|
89
|
-
})
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
return breadcrumbs
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const handleDeleteSecret = async () => {
|
|
96
|
-
if (!selectedSecretForAction) return
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
const fullPath = currentPath ? `${currentPath}${selectedSecretForAction}` : selectedSecretForAction
|
|
100
|
-
await deleteSecret(fullPath)
|
|
101
|
-
|
|
102
|
-
toast({
|
|
103
|
-
variant: "success",
|
|
104
|
-
title: "Secret deleted successfully",
|
|
105
|
-
description: `Configuration "${selectedSecretForAction}" has been deleted.`
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
setShowDeleteDialog(false)
|
|
109
|
-
setSelectedSecretForAction(null)
|
|
110
|
-
loadSecrets()
|
|
111
|
-
|
|
112
|
-
// Clear details if this was the selected secret
|
|
113
|
-
if (selectedSecret === selectedSecretForAction) {
|
|
114
|
-
onSecretSelect(null)
|
|
115
|
-
}
|
|
116
|
-
} catch (error) {
|
|
117
|
-
console.error("Error deleting secret:", error)
|
|
118
|
-
toast({
|
|
119
|
-
variant: "destructive",
|
|
120
|
-
title: "Error deleting secret",
|
|
121
|
-
description: "Failed to delete configuration secret."
|
|
122
|
-
})
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (loading) {
|
|
127
|
-
return (
|
|
128
|
-
<Card className="border border-ibm-gray-20">
|
|
129
|
-
<CardHeader className="pb-3">
|
|
130
|
-
<div className="animate-pulse">
|
|
131
|
-
<div className="h-6 bg-ibm-gray-20 mb-2"></div>
|
|
132
|
-
<div className="h-4 bg-ibm-gray-20"></div>
|
|
133
|
-
</div>
|
|
134
|
-
</CardHeader>
|
|
135
|
-
<CardContent className="p-0">
|
|
136
|
-
<div className="animate-pulse">
|
|
137
|
-
<div className="h-32 bg-ibm-gray-20"></div>
|
|
138
|
-
</div>
|
|
139
|
-
</CardContent>
|
|
140
|
-
</Card>
|
|
141
|
-
)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return (
|
|
145
|
-
<>
|
|
146
|
-
<Card className="border border-ibm-gray-20">
|
|
147
|
-
<CardHeader className="pb-3">
|
|
148
|
-
<div className="flex items-center justify-between">
|
|
149
|
-
<CardTitle className="text-lg font-medium">Secrets Explorer</CardTitle>
|
|
150
|
-
<Button variant="ghost" size="sm" onClick={onCreateClick}>
|
|
151
|
-
<Plus className="w-4 h-4" />
|
|
152
|
-
</Button>
|
|
153
|
-
</div>
|
|
154
|
-
|
|
155
|
-
{/* Breadcrumbs */}
|
|
156
|
-
<div className="flex items-center gap-1 text-xs text-ibm-gray-70 mt-2">
|
|
157
|
-
{getBreadcrumbs().map((crumb, index) => (
|
|
158
|
-
<div key={crumb.path} className="flex items-center gap-1">
|
|
159
|
-
{index > 0 && <ChevronRight className="w-3 h-3" />}
|
|
160
|
-
<button
|
|
161
|
-
onClick={() => {
|
|
162
|
-
onPathChange(crumb.path)
|
|
163
|
-
onSecretSelect(null)
|
|
164
|
-
}}
|
|
165
|
-
className={`hover:text-ibm-blue-60 ${
|
|
166
|
-
crumb.path === currentPath ? "text-ibm-blue-60 font-medium" : ""
|
|
167
|
-
}`}
|
|
168
|
-
>
|
|
169
|
-
{crumb.name}
|
|
170
|
-
</button>
|
|
171
|
-
</div>
|
|
172
|
-
))}
|
|
173
|
-
</div>
|
|
174
|
-
</CardHeader>
|
|
175
|
-
|
|
176
|
-
<CardContent className="p-0">
|
|
177
|
-
{/* File List */}
|
|
178
|
-
<div className="border-t border-ibm-gray-20">
|
|
179
|
-
{secretPaths.length === 0 ? (
|
|
180
|
-
<div className="p-8 text-center">
|
|
181
|
-
<Key className="w-8 h-8 text-ibm-gray-30 mx-auto mb-2" />
|
|
182
|
-
<p className="text-sm text-ibm-gray-70">
|
|
183
|
-
Empty folder
|
|
184
|
-
</p>
|
|
185
|
-
</div>
|
|
186
|
-
) : (
|
|
187
|
-
<div className="divide-y divide-ibm-gray-20">
|
|
188
|
-
{secretPaths.map((path) => {
|
|
189
|
-
const isFolder = path.endsWith('/')
|
|
190
|
-
const displayName = isFolder ? path.slice(0, -1) : path
|
|
191
|
-
const isSelected = selectedSecret === path
|
|
192
|
-
|
|
193
|
-
return (
|
|
194
|
-
<div
|
|
195
|
-
key={path}
|
|
196
|
-
className={`group flex items-center gap-3 px-4 py-3 hover:bg-ibm-gray-10 cursor-pointer transition-colors ${
|
|
197
|
-
isSelected ? "bg-ibm-blue-10 border-r-2 border-ibm-blue-60" : ""
|
|
198
|
-
}`}
|
|
199
|
-
onClick={() => handleItemClick(path)}
|
|
200
|
-
>
|
|
201
|
-
<div className="flex-shrink-0">
|
|
202
|
-
{isFolder ? (
|
|
203
|
-
<Folder className="w-4 h-4 text-ibm-yellow-60" />
|
|
204
|
-
) : (
|
|
205
|
-
<File className="w-4 h-4 text-ibm-blue-60" />
|
|
206
|
-
)}
|
|
207
|
-
</div>
|
|
208
|
-
<span className="text-sm text-ibm-gray-100 flex-1 truncate">
|
|
209
|
-
{displayName}
|
|
210
|
-
</span>
|
|
211
|
-
|
|
212
|
-
{!isFolder && (
|
|
213
|
-
<Button
|
|
214
|
-
variant="ghost"
|
|
215
|
-
size="xs"
|
|
216
|
-
leftIcon={<Trash2 className="w-3 h-3" />}
|
|
217
|
-
onClick={(e) => {
|
|
218
|
-
e.stopPropagation()
|
|
219
|
-
setSelectedSecretForAction(path)
|
|
220
|
-
setShowDeleteDialog(true)
|
|
221
|
-
}}
|
|
222
|
-
className="opacity-0 group-hover:opacity-100 transition-opacity text-ibm-red-60 hover:text-ibm-red-70 hover:bg-ibm-red-10"
|
|
223
|
-
>
|
|
224
|
-
</Button>
|
|
225
|
-
)}
|
|
226
|
-
</div>
|
|
227
|
-
)
|
|
228
|
-
})}
|
|
229
|
-
</div>
|
|
230
|
-
)}
|
|
231
|
-
</div>
|
|
232
|
-
</CardContent>
|
|
233
|
-
</Card>
|
|
234
|
-
|
|
235
|
-
{/* Delete Confirmation Dialog */}
|
|
236
|
-
<div
|
|
237
|
-
className={`fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 ${
|
|
238
|
-
showDeleteDialog ? 'block' : 'hidden'
|
|
239
|
-
}`}
|
|
240
|
-
onClick={() => setShowDeleteDialog(false)}
|
|
241
|
-
>
|
|
242
|
-
<div
|
|
243
|
-
className="bg-white p-6 rounded-lg max-w-md w-full mx-4"
|
|
244
|
-
onClick={(e) => e.stopPropagation()}
|
|
245
|
-
>
|
|
246
|
-
<h3 className="text-lg font-medium text-ibm-gray-100 mb-2">Delete Secret</h3>
|
|
247
|
-
<p className="text-sm text-ibm-gray-70 mb-6">
|
|
248
|
-
Are you sure you want to delete the secret "{selectedSecretForAction}"?
|
|
249
|
-
This action cannot be undone and will permanently remove all secret data.
|
|
250
|
-
</p>
|
|
251
|
-
<div className="flex justify-end gap-3">
|
|
252
|
-
<Button
|
|
253
|
-
variant="secondary"
|
|
254
|
-
size="sm"
|
|
255
|
-
style={{ borderRadius: 0 }}
|
|
256
|
-
onClick={() => setShowDeleteDialog(false)}
|
|
257
|
-
>
|
|
258
|
-
Cancel
|
|
259
|
-
</Button>
|
|
260
|
-
<Button
|
|
261
|
-
variant="danger"
|
|
262
|
-
style={{ borderRadius: 0 }}
|
|
263
|
-
leftIcon={<Trash2 className="w-3 h-3" />}
|
|
264
|
-
size="sm"
|
|
265
|
-
onClick={handleDeleteSecret}
|
|
266
|
-
>
|
|
267
|
-
Delete Secret
|
|
268
|
-
</Button>
|
|
269
|
-
</div>
|
|
270
|
-
</div>
|
|
271
|
-
</div>
|
|
272
|
-
</>
|
|
273
|
-
)
|
|
274
|
-
}
|