@checkstack/gitops-frontend 0.2.0
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/CHANGELOG.md +37 -0
- package/package.json +29 -0
- package/src/components/GitOpsLockBanner.tsx +35 -0
- package/src/components/GitOpsMenuItem.tsx +28 -0
- package/src/components/KindRegistryMenuItem.tsx +28 -0
- package/src/components/ProvenanceStatus.tsx +246 -0
- package/src/components/ProviderEditor.tsx +214 -0
- package/src/components/ProviderList.tsx +294 -0
- package/src/components/SecretEditor.tsx +110 -0
- package/src/components/SecretList.tsx +204 -0
- package/src/components/SecretRotateDialog.tsx +79 -0
- package/src/hooks/useProvenanceLock.ts +32 -0
- package/src/index.tsx +46 -0
- package/src/pages/GitOpsPage.tsx +56 -0
- package/src/pages/KindRegistryPage.tsx +516 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
usePluginClient,
|
|
4
|
+
useApi,
|
|
5
|
+
accessApiRef,
|
|
6
|
+
} from "@checkstack/frontend-api";
|
|
7
|
+
import { GitOpsApi, gitopsAccess } from "@checkstack/gitops-common";
|
|
8
|
+
import {
|
|
9
|
+
Card,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardHeaderRow,
|
|
12
|
+
CardTitle,
|
|
13
|
+
CardContent,
|
|
14
|
+
Button,
|
|
15
|
+
EmptyState,
|
|
16
|
+
ConfirmationModal,
|
|
17
|
+
Badge,
|
|
18
|
+
useToast,
|
|
19
|
+
} from "@checkstack/ui";
|
|
20
|
+
import {
|
|
21
|
+
Plus,
|
|
22
|
+
RefreshCw,
|
|
23
|
+
Trash2,
|
|
24
|
+
Pencil,
|
|
25
|
+
Github,
|
|
26
|
+
GitlabIcon,
|
|
27
|
+
} from "lucide-react";
|
|
28
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
29
|
+
import { ProviderEditor } from "./ProviderEditor";
|
|
30
|
+
|
|
31
|
+
const formatInterval = (seconds: number) => {
|
|
32
|
+
if (seconds < 60) return `${seconds}s`;
|
|
33
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
|
34
|
+
return `${Math.floor(seconds / 3600)}h`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const formatLastSync = (date: Date | null) => {
|
|
38
|
+
if (!date) return "Never";
|
|
39
|
+
return new Date(date).toLocaleString();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const ProviderList = () => {
|
|
43
|
+
const client = usePluginClient(GitOpsApi);
|
|
44
|
+
const accessApi = useApi(accessApiRef);
|
|
45
|
+
const toast = useToast();
|
|
46
|
+
|
|
47
|
+
const { allowed: canManage } = accessApi.useAccess(
|
|
48
|
+
gitopsAccess.provider.manage,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
|
52
|
+
const [editingProvider, setEditingProvider] = useState<{
|
|
53
|
+
id: string;
|
|
54
|
+
type: "github" | "gitlab";
|
|
55
|
+
target: string;
|
|
56
|
+
pathPattern: string;
|
|
57
|
+
baseUrl?: string;
|
|
58
|
+
syncInterval: number;
|
|
59
|
+
deletionPolicy: "orphan" | "auto";
|
|
60
|
+
}>();
|
|
61
|
+
const [confirmModal, setConfirmModal] = useState<{
|
|
62
|
+
isOpen: boolean;
|
|
63
|
+
providerId: string;
|
|
64
|
+
providerTarget: string;
|
|
65
|
+
}>({ isOpen: false, providerId: "", providerTarget: "" });
|
|
66
|
+
|
|
67
|
+
const {
|
|
68
|
+
data: providers,
|
|
69
|
+
isLoading,
|
|
70
|
+
refetch,
|
|
71
|
+
} = client.listProviders.useQuery({});
|
|
72
|
+
|
|
73
|
+
const createMutation = client.createProvider.useMutation({
|
|
74
|
+
onSuccess: () => {
|
|
75
|
+
toast.success("Provider created successfully");
|
|
76
|
+
setIsEditorOpen(false);
|
|
77
|
+
void refetch();
|
|
78
|
+
},
|
|
79
|
+
onError: (error) => {
|
|
80
|
+
toast.error(extractErrorMessage(error, "Failed to create provider"));
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const updateMutation = client.updateProvider.useMutation({
|
|
85
|
+
onSuccess: () => {
|
|
86
|
+
toast.success("Provider updated successfully");
|
|
87
|
+
setIsEditorOpen(false);
|
|
88
|
+
setEditingProvider(undefined);
|
|
89
|
+
void refetch();
|
|
90
|
+
},
|
|
91
|
+
onError: (error) => {
|
|
92
|
+
toast.error(extractErrorMessage(error, "Failed to update provider"));
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const deleteMutation = client.deleteProvider.useMutation({
|
|
97
|
+
onSuccess: () => {
|
|
98
|
+
toast.success("Provider deleted successfully");
|
|
99
|
+
setConfirmModal({ isOpen: false, providerId: "", providerTarget: "" });
|
|
100
|
+
void refetch();
|
|
101
|
+
},
|
|
102
|
+
onError: (error) => {
|
|
103
|
+
toast.error(extractErrorMessage(error, "Failed to delete provider"));
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const syncMutation = client.triggerSync.useMutation({
|
|
108
|
+
onSuccess: () => {
|
|
109
|
+
toast.success("Sync triggered successfully");
|
|
110
|
+
void refetch();
|
|
111
|
+
},
|
|
112
|
+
onError: (error) => {
|
|
113
|
+
toast.error(extractErrorMessage(error, "Failed to trigger sync"));
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const handleSave = (data: {
|
|
118
|
+
type: "github" | "gitlab";
|
|
119
|
+
target: string;
|
|
120
|
+
pathPattern: string;
|
|
121
|
+
baseUrl?: string;
|
|
122
|
+
authToken?: string;
|
|
123
|
+
syncInterval?: number;
|
|
124
|
+
deletionPolicy?: "orphan" | "auto";
|
|
125
|
+
}) => {
|
|
126
|
+
if (editingProvider) {
|
|
127
|
+
updateMutation.mutate({
|
|
128
|
+
id: editingProvider.id,
|
|
129
|
+
data: {
|
|
130
|
+
target: data.target,
|
|
131
|
+
pathPattern: data.pathPattern,
|
|
132
|
+
baseUrl: data.baseUrl ?? undefined,
|
|
133
|
+
authToken: data.authToken,
|
|
134
|
+
syncInterval: data.syncInterval,
|
|
135
|
+
deletionPolicy: data.deletionPolicy,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
createMutation.mutate(data);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<>
|
|
145
|
+
<Card>
|
|
146
|
+
<CardHeader>
|
|
147
|
+
<CardHeaderRow>
|
|
148
|
+
<CardTitle>Git Providers</CardTitle>
|
|
149
|
+
{canManage && (
|
|
150
|
+
<Button size="sm" onClick={() => setIsEditorOpen(true)}>
|
|
151
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
152
|
+
Add Provider
|
|
153
|
+
</Button>
|
|
154
|
+
)}
|
|
155
|
+
</CardHeaderRow>
|
|
156
|
+
</CardHeader>
|
|
157
|
+
<CardContent>
|
|
158
|
+
{isLoading ? (
|
|
159
|
+
<div className="text-center py-8 text-muted-foreground">
|
|
160
|
+
Loading...
|
|
161
|
+
</div>
|
|
162
|
+
) : !providers || providers.length === 0 ? (
|
|
163
|
+
<EmptyState
|
|
164
|
+
title="No providers configured"
|
|
165
|
+
description="Add a GitHub or GitLab provider to start syncing infrastructure definitions."
|
|
166
|
+
/>
|
|
167
|
+
) : (
|
|
168
|
+
<div className="space-y-3">
|
|
169
|
+
{providers.map((provider) => (
|
|
170
|
+
<div
|
|
171
|
+
key={provider.id}
|
|
172
|
+
className="flex items-center justify-between p-4 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors"
|
|
173
|
+
>
|
|
174
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
175
|
+
{provider.type === "github" ? (
|
|
176
|
+
<Github className="w-5 h-5 text-muted-foreground shrink-0" />
|
|
177
|
+
) : (
|
|
178
|
+
<GitlabIcon className="w-5 h-5 text-muted-foreground shrink-0" />
|
|
179
|
+
)}
|
|
180
|
+
<div className="min-w-0">
|
|
181
|
+
<div className="font-medium truncate">
|
|
182
|
+
{provider.target}
|
|
183
|
+
</div>
|
|
184
|
+
<div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
|
|
185
|
+
<span className="truncate">{provider.pathPattern}</span>
|
|
186
|
+
<span>·</span>
|
|
187
|
+
<span>
|
|
188
|
+
every {formatInterval(provider.syncInterval)}
|
|
189
|
+
</span>
|
|
190
|
+
<span>·</span>
|
|
191
|
+
<Badge
|
|
192
|
+
variant={
|
|
193
|
+
provider.deletionPolicy === "auto"
|
|
194
|
+
? "destructive"
|
|
195
|
+
: "outline"
|
|
196
|
+
}
|
|
197
|
+
>
|
|
198
|
+
{provider.deletionPolicy}
|
|
199
|
+
</Badge>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div className="flex items-center gap-4 shrink-0">
|
|
205
|
+
<div className="text-right text-xs text-muted-foreground hidden md:block">
|
|
206
|
+
<div>
|
|
207
|
+
Last sync: {formatLastSync(provider.lastSyncAt)}
|
|
208
|
+
</div>
|
|
209
|
+
{provider.lastSyncError && (
|
|
210
|
+
<div className="text-destructive truncate max-w-48">
|
|
211
|
+
{provider.lastSyncError}
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{canManage && (
|
|
217
|
+
<div className="flex items-center gap-1">
|
|
218
|
+
<Button
|
|
219
|
+
variant="ghost"
|
|
220
|
+
size="icon"
|
|
221
|
+
onClick={() =>
|
|
222
|
+
syncMutation.mutate({ providerId: provider.id })
|
|
223
|
+
}
|
|
224
|
+
title="Trigger sync"
|
|
225
|
+
>
|
|
226
|
+
<RefreshCw className="w-4 h-4" />
|
|
227
|
+
</Button>
|
|
228
|
+
<Button
|
|
229
|
+
variant="ghost"
|
|
230
|
+
size="icon"
|
|
231
|
+
onClick={() => {
|
|
232
|
+
setEditingProvider({
|
|
233
|
+
id: provider.id,
|
|
234
|
+
type: provider.type,
|
|
235
|
+
target: provider.target,
|
|
236
|
+
pathPattern: provider.pathPattern,
|
|
237
|
+
baseUrl: provider.baseUrl ?? undefined,
|
|
238
|
+
syncInterval: provider.syncInterval,
|
|
239
|
+
deletionPolicy: provider.deletionPolicy,
|
|
240
|
+
});
|
|
241
|
+
setIsEditorOpen(true);
|
|
242
|
+
}}
|
|
243
|
+
title="Edit provider"
|
|
244
|
+
>
|
|
245
|
+
<Pencil className="w-4 h-4" />
|
|
246
|
+
</Button>
|
|
247
|
+
<Button
|
|
248
|
+
variant="ghost"
|
|
249
|
+
size="icon"
|
|
250
|
+
onClick={() =>
|
|
251
|
+
setConfirmModal({
|
|
252
|
+
isOpen: true,
|
|
253
|
+
providerId: provider.id,
|
|
254
|
+
providerTarget: provider.target,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
title="Delete provider"
|
|
258
|
+
>
|
|
259
|
+
<Trash2 className="w-4 h-4" />
|
|
260
|
+
</Button>
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
</CardContent>
|
|
269
|
+
</Card>
|
|
270
|
+
|
|
271
|
+
<ProviderEditor
|
|
272
|
+
open={isEditorOpen}
|
|
273
|
+
onClose={() => {
|
|
274
|
+
setIsEditorOpen(false);
|
|
275
|
+
setEditingProvider(undefined);
|
|
276
|
+
}}
|
|
277
|
+
onSave={handleSave}
|
|
278
|
+
initialData={editingProvider}
|
|
279
|
+
/>
|
|
280
|
+
|
|
281
|
+
<ConfirmationModal
|
|
282
|
+
isOpen={confirmModal.isOpen}
|
|
283
|
+
onClose={() =>
|
|
284
|
+
setConfirmModal({ isOpen: false, providerId: "", providerTarget: "" })
|
|
285
|
+
}
|
|
286
|
+
onConfirm={() => deleteMutation.mutate({ id: confirmModal.providerId })}
|
|
287
|
+
title="Delete Provider"
|
|
288
|
+
message={`Are you sure you want to delete the provider for "${confirmModal.providerTarget}"? All provenance tracking will be removed.`}
|
|
289
|
+
confirmText="Delete"
|
|
290
|
+
variant="danger"
|
|
291
|
+
/>
|
|
292
|
+
</>
|
|
293
|
+
);
|
|
294
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Input,
|
|
5
|
+
Label,
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
} from "@checkstack/ui";
|
|
13
|
+
|
|
14
|
+
interface SecretEditorProps {
|
|
15
|
+
open: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
onSave: (data: { name: string; value: string; description?: string }) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const SecretEditor: React.FC<SecretEditorProps> = ({
|
|
21
|
+
open,
|
|
22
|
+
onClose,
|
|
23
|
+
onSave,
|
|
24
|
+
}) => {
|
|
25
|
+
const [name, setName] = useState("");
|
|
26
|
+
const [value, setValue] = useState("");
|
|
27
|
+
const [description, setDescription] = useState("");
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (open) {
|
|
31
|
+
setName("");
|
|
32
|
+
setValue("");
|
|
33
|
+
setDescription("");
|
|
34
|
+
}
|
|
35
|
+
}, [open]);
|
|
36
|
+
|
|
37
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
if (!name.trim() || !value.trim()) return;
|
|
40
|
+
|
|
41
|
+
onSave({
|
|
42
|
+
name: name.trim(),
|
|
43
|
+
value: value.trim(),
|
|
44
|
+
description: description.trim() || undefined,
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
|
50
|
+
<DialogContent size="default">
|
|
51
|
+
<form onSubmit={handleSubmit}>
|
|
52
|
+
<DialogHeader>
|
|
53
|
+
<DialogTitle>Create Secret</DialogTitle>
|
|
54
|
+
<DialogDescription className="sr-only">
|
|
55
|
+
Create a new secret for use in GitOps YAML descriptors
|
|
56
|
+
</DialogDescription>
|
|
57
|
+
</DialogHeader>
|
|
58
|
+
|
|
59
|
+
<div className="space-y-4 py-4">
|
|
60
|
+
<div className="space-y-2">
|
|
61
|
+
<Label htmlFor="secret-name">Name</Label>
|
|
62
|
+
<Input
|
|
63
|
+
id="secret-name"
|
|
64
|
+
placeholder="e.g. GITHUB_TOKEN"
|
|
65
|
+
value={name}
|
|
66
|
+
onChange={(e) => setName(e.target.value)}
|
|
67
|
+
className="font-mono"
|
|
68
|
+
required
|
|
69
|
+
/>
|
|
70
|
+
<p className="text-xs text-muted-foreground">
|
|
71
|
+
Referenced as <code className="text-xs">{"${{ secrets.NAME }}"}</code> in descriptors.
|
|
72
|
+
</p>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div className="space-y-2">
|
|
76
|
+
<Label htmlFor="secret-value">Value</Label>
|
|
77
|
+
<Input
|
|
78
|
+
id="secret-value"
|
|
79
|
+
type="password"
|
|
80
|
+
placeholder="Secret value..."
|
|
81
|
+
value={value}
|
|
82
|
+
onChange={(e) => setValue(e.target.value)}
|
|
83
|
+
required
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div className="space-y-2">
|
|
88
|
+
<Label htmlFor="secret-description">Description (optional)</Label>
|
|
89
|
+
<Input
|
|
90
|
+
id="secret-description"
|
|
91
|
+
placeholder="What this secret is used for..."
|
|
92
|
+
value={description}
|
|
93
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<DialogFooter>
|
|
99
|
+
<Button type="button" variant="outline" onClick={onClose}>
|
|
100
|
+
Cancel
|
|
101
|
+
</Button>
|
|
102
|
+
<Button type="submit" disabled={!name.trim() || !value.trim()}>
|
|
103
|
+
Create Secret
|
|
104
|
+
</Button>
|
|
105
|
+
</DialogFooter>
|
|
106
|
+
</form>
|
|
107
|
+
</DialogContent>
|
|
108
|
+
</Dialog>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
usePluginClient,
|
|
4
|
+
useApi,
|
|
5
|
+
accessApiRef,
|
|
6
|
+
} from "@checkstack/frontend-api";
|
|
7
|
+
import { GitOpsApi, gitopsAccess } from "@checkstack/gitops-common";
|
|
8
|
+
import {
|
|
9
|
+
Card,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardHeaderRow,
|
|
12
|
+
CardTitle,
|
|
13
|
+
CardContent,
|
|
14
|
+
Button,
|
|
15
|
+
EmptyState,
|
|
16
|
+
ConfirmationModal,
|
|
17
|
+
useToast,
|
|
18
|
+
} from "@checkstack/ui";
|
|
19
|
+
import { Plus, RotateCw, Trash2, KeyRound } from "lucide-react";
|
|
20
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
21
|
+
import { SecretEditor } from "./SecretEditor";
|
|
22
|
+
import { SecretRotateDialog } from "./SecretRotateDialog";
|
|
23
|
+
|
|
24
|
+
const formatDate = (date: Date) => new Date(date).toLocaleString();
|
|
25
|
+
|
|
26
|
+
export const SecretList = () => {
|
|
27
|
+
const client = usePluginClient(GitOpsApi);
|
|
28
|
+
const accessApi = useApi(accessApiRef);
|
|
29
|
+
const toast = useToast();
|
|
30
|
+
|
|
31
|
+
const { allowed: canManage } = accessApi.useAccess(
|
|
32
|
+
gitopsAccess.secret.manage,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
|
36
|
+
const [rotatingSecret, setRotatingSecret] = useState<{
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
}>();
|
|
40
|
+
const [confirmModal, setConfirmModal] = useState<{
|
|
41
|
+
isOpen: boolean;
|
|
42
|
+
secretId: string;
|
|
43
|
+
secretName: string;
|
|
44
|
+
}>({ isOpen: false, secretId: "", secretName: "" });
|
|
45
|
+
|
|
46
|
+
const { data: secrets, isLoading, refetch } = client.listSecrets.useQuery({});
|
|
47
|
+
|
|
48
|
+
const createMutation = client.createSecret.useMutation({
|
|
49
|
+
onSuccess: () => {
|
|
50
|
+
toast.success("Secret created successfully");
|
|
51
|
+
setIsEditorOpen(false);
|
|
52
|
+
void refetch();
|
|
53
|
+
},
|
|
54
|
+
onError: (error) => {
|
|
55
|
+
toast.error(extractErrorMessage(error, "Failed to create secret"));
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const rotateMutation = client.rotateSecret.useMutation({
|
|
60
|
+
onSuccess: () => {
|
|
61
|
+
toast.success("Secret rotated successfully");
|
|
62
|
+
setRotatingSecret(undefined);
|
|
63
|
+
void refetch();
|
|
64
|
+
},
|
|
65
|
+
onError: (error) => {
|
|
66
|
+
toast.error(extractErrorMessage(error, "Failed to rotate secret"));
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const deleteMutation = client.deleteSecret.useMutation({
|
|
71
|
+
onSuccess: () => {
|
|
72
|
+
toast.success("Secret deleted successfully");
|
|
73
|
+
setConfirmModal({ isOpen: false, secretId: "", secretName: "" });
|
|
74
|
+
void refetch();
|
|
75
|
+
},
|
|
76
|
+
onError: (error) => {
|
|
77
|
+
toast.error(extractErrorMessage(error, "Failed to delete secret"));
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
<Card>
|
|
84
|
+
<CardHeader>
|
|
85
|
+
<CardHeaderRow>
|
|
86
|
+
<CardTitle>Secrets</CardTitle>
|
|
87
|
+
{canManage && (
|
|
88
|
+
<Button size="sm" onClick={() => setIsEditorOpen(true)}>
|
|
89
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
90
|
+
Add Secret
|
|
91
|
+
</Button>
|
|
92
|
+
)}
|
|
93
|
+
</CardHeaderRow>
|
|
94
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
95
|
+
Secrets can be referenced in YAML descriptors using{" "}
|
|
96
|
+
<code className="text-xs">{"${{ secrets.NAME }}"}</code> syntax.
|
|
97
|
+
</p>
|
|
98
|
+
</CardHeader>
|
|
99
|
+
<CardContent>
|
|
100
|
+
{isLoading ? (
|
|
101
|
+
<div className="text-center py-8 text-muted-foreground">
|
|
102
|
+
Loading...
|
|
103
|
+
</div>
|
|
104
|
+
) : !secrets || secrets.length === 0 ? (
|
|
105
|
+
<EmptyState
|
|
106
|
+
title="No secrets created"
|
|
107
|
+
description="Create secrets to securely reference sensitive values in your YAML descriptors."
|
|
108
|
+
/>
|
|
109
|
+
) : (
|
|
110
|
+
<div className="space-y-3">
|
|
111
|
+
{secrets.map((secret) => (
|
|
112
|
+
<div
|
|
113
|
+
key={secret.id}
|
|
114
|
+
className="flex items-center justify-between p-4 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors"
|
|
115
|
+
>
|
|
116
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
117
|
+
<KeyRound className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
118
|
+
<div className="min-w-0">
|
|
119
|
+
<div className="font-medium font-mono text-sm">
|
|
120
|
+
{secret.name}
|
|
121
|
+
</div>
|
|
122
|
+
{secret.description && (
|
|
123
|
+
<div className="text-xs text-muted-foreground mt-0.5 truncate">
|
|
124
|
+
{secret.description}
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="flex items-center gap-4 shrink-0">
|
|
131
|
+
<div className="text-right text-xs text-muted-foreground hidden md:block">
|
|
132
|
+
<div>Updated: {formatDate(secret.updatedAt)}</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{canManage && (
|
|
136
|
+
<div className="flex items-center gap-1">
|
|
137
|
+
<Button
|
|
138
|
+
variant="ghost"
|
|
139
|
+
size="icon"
|
|
140
|
+
onClick={() =>
|
|
141
|
+
setRotatingSecret({
|
|
142
|
+
id: secret.id,
|
|
143
|
+
name: secret.name,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
title="Rotate secret"
|
|
147
|
+
>
|
|
148
|
+
<RotateCw className="w-4 h-4" />
|
|
149
|
+
</Button>
|
|
150
|
+
<Button
|
|
151
|
+
variant="ghost"
|
|
152
|
+
size="icon"
|
|
153
|
+
onClick={() =>
|
|
154
|
+
setConfirmModal({
|
|
155
|
+
isOpen: true,
|
|
156
|
+
secretId: secret.id,
|
|
157
|
+
secretName: secret.name,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
title="Delete secret"
|
|
161
|
+
>
|
|
162
|
+
<Trash2 className="w-4 h-4" />
|
|
163
|
+
</Button>
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
))}
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</CardContent>
|
|
172
|
+
</Card>
|
|
173
|
+
|
|
174
|
+
<SecretEditor
|
|
175
|
+
open={isEditorOpen}
|
|
176
|
+
onClose={() => setIsEditorOpen(false)}
|
|
177
|
+
onSave={(data) => createMutation.mutate(data)}
|
|
178
|
+
/>
|
|
179
|
+
|
|
180
|
+
<SecretRotateDialog
|
|
181
|
+
open={!!rotatingSecret}
|
|
182
|
+
secretName={rotatingSecret?.name ?? ""}
|
|
183
|
+
onClose={() => setRotatingSecret(undefined)}
|
|
184
|
+
onSave={(value) => {
|
|
185
|
+
if (rotatingSecret) {
|
|
186
|
+
rotateMutation.mutate({ id: rotatingSecret.id, value });
|
|
187
|
+
}
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
|
|
191
|
+
<ConfirmationModal
|
|
192
|
+
isOpen={confirmModal.isOpen}
|
|
193
|
+
onClose={() =>
|
|
194
|
+
setConfirmModal({ isOpen: false, secretId: "", secretName: "" })
|
|
195
|
+
}
|
|
196
|
+
onConfirm={() => deleteMutation.mutate({ id: confirmModal.secretId })}
|
|
197
|
+
title="Delete Secret"
|
|
198
|
+
message={`Are you sure you want to delete the secret "${confirmModal.secretName}"? Any descriptors referencing this secret will fail validation.`}
|
|
199
|
+
confirmText="Delete"
|
|
200
|
+
variant="danger"
|
|
201
|
+
/>
|
|
202
|
+
</>
|
|
203
|
+
);
|
|
204
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Input,
|
|
5
|
+
Label,
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
} from "@checkstack/ui";
|
|
13
|
+
|
|
14
|
+
interface SecretRotateDialogProps {
|
|
15
|
+
open: boolean;
|
|
16
|
+
secretName: string;
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
onSave: (value: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const SecretRotateDialog: React.FC<SecretRotateDialogProps> = ({
|
|
22
|
+
open,
|
|
23
|
+
secretName,
|
|
24
|
+
onClose,
|
|
25
|
+
onSave,
|
|
26
|
+
}) => {
|
|
27
|
+
const [value, setValue] = useState("");
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (open) {
|
|
31
|
+
setValue("");
|
|
32
|
+
}
|
|
33
|
+
}, [open]);
|
|
34
|
+
|
|
35
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
if (!value.trim()) return;
|
|
38
|
+
onSave(value.trim());
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
|
43
|
+
<DialogContent size="default">
|
|
44
|
+
<form onSubmit={handleSubmit}>
|
|
45
|
+
<DialogHeader>
|
|
46
|
+
<DialogTitle>Rotate Secret</DialogTitle>
|
|
47
|
+
<DialogDescription>
|
|
48
|
+
Enter a new value for <code className="font-mono text-sm">{secretName}</code>.
|
|
49
|
+
The old value will be permanently replaced.
|
|
50
|
+
</DialogDescription>
|
|
51
|
+
</DialogHeader>
|
|
52
|
+
|
|
53
|
+
<div className="space-y-4 py-4">
|
|
54
|
+
<div className="space-y-2">
|
|
55
|
+
<Label htmlFor="rotate-value">New Value</Label>
|
|
56
|
+
<Input
|
|
57
|
+
id="rotate-value"
|
|
58
|
+
type="password"
|
|
59
|
+
placeholder="New secret value..."
|
|
60
|
+
value={value}
|
|
61
|
+
onChange={(e) => setValue(e.target.value)}
|
|
62
|
+
required
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<DialogFooter>
|
|
68
|
+
<Button type="button" variant="outline" onClick={onClose}>
|
|
69
|
+
Cancel
|
|
70
|
+
</Button>
|
|
71
|
+
<Button type="submit" disabled={!value.trim()}>
|
|
72
|
+
Rotate Secret
|
|
73
|
+
</Button>
|
|
74
|
+
</DialogFooter>
|
|
75
|
+
</form>
|
|
76
|
+
</DialogContent>
|
|
77
|
+
</Dialog>
|
|
78
|
+
);
|
|
79
|
+
};
|