@clef-sh/ui 0.1.15-beta.97 → 0.1.15

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.
@@ -0,0 +1,217 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { theme } from "../theme";
3
+ import { apiFetch } from "../api";
4
+ import { Button } from "./Button";
5
+ import { EnvBadge } from "./EnvBadge";
6
+
7
+ interface SyncCellPlan {
8
+ namespace: string;
9
+ environment: string;
10
+ missingKeys: string[];
11
+ isProtected: boolean;
12
+ }
13
+
14
+ interface SyncPlan {
15
+ cells: SyncCellPlan[];
16
+ totalKeys: number;
17
+ hasProtectedEnvs: boolean;
18
+ }
19
+
20
+ interface SyncResult {
21
+ modifiedCells: string[];
22
+ scaffoldedKeys: Record<string, string[]>;
23
+ totalKeysScaffolded: number;
24
+ }
25
+
26
+ interface SyncPanelProps {
27
+ namespace: string;
28
+ onComplete: () => void;
29
+ onCancel: () => void;
30
+ }
31
+
32
+ export function SyncPanel({ namespace, onComplete, onCancel }: SyncPanelProps) {
33
+ const [phase, setPhase] = useState<"loading" | "preview" | "syncing" | "done" | "error">(
34
+ "loading",
35
+ );
36
+ const [plan, setPlan] = useState<SyncPlan | null>(null);
37
+ const [result, setResult] = useState<SyncResult | null>(null);
38
+ const [error, setError] = useState<string | null>(null);
39
+
40
+ useEffect(() => {
41
+ let cancelled = false;
42
+ (async () => {
43
+ try {
44
+ const res = await apiFetch("/api/sync/preview", {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify({ namespace }),
48
+ });
49
+ if (cancelled) return;
50
+ if (!res.ok) {
51
+ const data = await res.json();
52
+ setError(data.error || "Preview failed");
53
+ setPhase("error");
54
+ return;
55
+ }
56
+ const data = (await res.json()) as SyncPlan;
57
+ setPlan(data);
58
+ setPhase("preview");
59
+ } catch {
60
+ if (!cancelled) {
61
+ setError("Failed to load sync preview");
62
+ setPhase("error");
63
+ }
64
+ }
65
+ })();
66
+ return () => {
67
+ cancelled = true;
68
+ };
69
+ }, [namespace]);
70
+
71
+ const handleSync = async () => {
72
+ setPhase("syncing");
73
+ try {
74
+ const res = await apiFetch("/api/sync", {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/json" },
77
+ body: JSON.stringify({ namespace }),
78
+ });
79
+ if (!res.ok) {
80
+ const data = await res.json();
81
+ setError(data.error || "Sync failed");
82
+ setPhase("error");
83
+ return;
84
+ }
85
+ const data = await res.json();
86
+ setResult(data.result as SyncResult);
87
+ setPhase("done");
88
+ setTimeout(onComplete, 1500);
89
+ } catch {
90
+ setError("Sync failed");
91
+ setPhase("error");
92
+ }
93
+ };
94
+
95
+ return (
96
+ <div
97
+ data-testid="sync-panel"
98
+ style={{
99
+ background: theme.surface,
100
+ border: `1px solid ${theme.border}`,
101
+ borderRadius: 8,
102
+ padding: "16px 20px",
103
+ marginTop: 8,
104
+ marginBottom: 8,
105
+ }}
106
+ >
107
+ {phase === "loading" && (
108
+ <div style={{ fontFamily: theme.sans, fontSize: 13, color: theme.textMuted }}>
109
+ Loading sync preview...
110
+ </div>
111
+ )}
112
+
113
+ {phase === "preview" && plan && (
114
+ <>
115
+ {plan.totalKeys === 0 ? (
116
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
117
+ <span
118
+ data-testid="sync-in-sync"
119
+ style={{ fontFamily: theme.sans, fontSize: 13, color: theme.green }}
120
+ >
121
+ All environments in sync
122
+ </span>
123
+ <Button onClick={onCancel}>Close</Button>
124
+ </div>
125
+ ) : (
126
+ <>
127
+ <div
128
+ style={{
129
+ fontFamily: theme.sans,
130
+ fontSize: 13,
131
+ fontWeight: 600,
132
+ color: theme.text,
133
+ marginBottom: 10,
134
+ }}
135
+ >
136
+ Sync {namespace} — {plan.totalKeys} key{plan.totalKeys !== 1 ? "s" : ""} to scaffold
137
+ </div>
138
+
139
+ {plan.hasProtectedEnvs && (
140
+ <div
141
+ style={{
142
+ fontFamily: theme.sans,
143
+ fontSize: 12,
144
+ color: theme.yellow,
145
+ background: theme.yellowDim,
146
+ border: `1px solid ${theme.yellow}33`,
147
+ borderRadius: 5,
148
+ padding: "6px 12px",
149
+ marginBottom: 10,
150
+ }}
151
+ >
152
+ Includes protected environment(s)
153
+ </div>
154
+ )}
155
+
156
+ <div data-testid="sync-preview-list" style={{ marginBottom: 12 }}>
157
+ {plan.cells.map((cell) => (
158
+ <div
159
+ key={`${cell.namespace}/${cell.environment}`}
160
+ style={{
161
+ display: "flex",
162
+ alignItems: "center",
163
+ gap: 8,
164
+ padding: "4px 0",
165
+ }}
166
+ >
167
+ <EnvBadge env={cell.environment} />
168
+ <span
169
+ style={{
170
+ fontFamily: theme.mono,
171
+ fontSize: 12,
172
+ color: theme.textMuted,
173
+ }}
174
+ >
175
+ {cell.missingKeys.join(", ")}
176
+ </span>
177
+ </div>
178
+ ))}
179
+ </div>
180
+
181
+ <div style={{ display: "flex", gap: 8 }}>
182
+ <Button variant="primary" data-testid="sync-execute-btn" onClick={handleSync}>
183
+ Sync Now
184
+ </Button>
185
+ <Button data-testid="sync-cancel-btn" onClick={onCancel}>
186
+ Cancel
187
+ </Button>
188
+ </div>
189
+ </>
190
+ )}
191
+ </>
192
+ )}
193
+
194
+ {phase === "syncing" && (
195
+ <div style={{ fontFamily: theme.sans, fontSize: 13, color: theme.accent }}>Syncing...</div>
196
+ )}
197
+
198
+ {phase === "done" && result && (
199
+ <div
200
+ data-testid="sync-done"
201
+ style={{ fontFamily: theme.sans, fontSize: 13, color: theme.green }}
202
+ >
203
+ Synced {result.totalKeysScaffolded} key{result.totalKeysScaffolded !== 1 ? "s" : ""}{" "}
204
+ across {result.modifiedCells.length} environment
205
+ {result.modifiedCells.length !== 1 ? "s" : ""}
206
+ </div>
207
+ )}
208
+
209
+ {phase === "error" && (
210
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
211
+ <span style={{ fontFamily: theme.sans, fontSize: 13, color: theme.red }}>{error}</span>
212
+ <Button onClick={onCancel}>Close</Button>
213
+ </div>
214
+ )}
215
+ </div>
216
+ );
217
+ }
@@ -45,7 +45,6 @@ const BACKEND_LABELS: Record<BackendType, string> = {
45
45
  gcpkms: "GCP KMS",
46
46
  azurekv: "Azure Key Vault",
47
47
  pgp: "PGP",
48
- cloud: "Cloud KMS",
49
48
  };
50
49
 
51
50
  const KEY_PLACEHOLDERS: Record<string, string> = {
@@ -1,8 +1,9 @@
1
- import React from "react";
1
+ import React, { useState } from "react";
2
2
  import { theme } from "../theme";
3
3
  import { TopBar } from "../components/TopBar";
4
4
  import { Button } from "../components/Button";
5
5
  import { MatrixGrid } from "../components/MatrixGrid";
6
+ import { SyncPanel } from "../components/SyncPanel";
6
7
  import type { ViewName } from "../components/Sidebar";
7
8
  import type { ClefManifest, MatrixStatus } from "@clef-sh/core";
8
9
 
@@ -11,9 +12,17 @@ interface MatrixViewProps {
11
12
  setNs: (ns: string) => void;
12
13
  manifest: ClefManifest | null;
13
14
  matrixStatuses: MatrixStatus[];
15
+ reloadMatrix?: () => void;
14
16
  }
15
17
 
16
- export function MatrixView({ setView, setNs, manifest, matrixStatuses }: MatrixViewProps) {
18
+ export function MatrixView({
19
+ setView,
20
+ setNs,
21
+ manifest,
22
+ matrixStatuses,
23
+ reloadMatrix,
24
+ }: MatrixViewProps) {
25
+ const [syncingNs, setSyncingNs] = useState<string | null>(null);
17
26
  if (!manifest) {
18
27
  return (
19
28
  <div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
@@ -138,8 +147,21 @@ export function MatrixView({ setView, setNs, manifest, matrixStatuses }: MatrixV
138
147
  setNs(nsName);
139
148
  setView("editor");
140
149
  }}
150
+ onSyncClick={(nsName) => setSyncingNs(nsName)}
151
+ syncingNs={syncingNs}
141
152
  />
142
153
 
154
+ {syncingNs && (
155
+ <SyncPanel
156
+ namespace={syncingNs}
157
+ onComplete={() => {
158
+ setSyncingNs(null);
159
+ reloadMatrix?.();
160
+ }}
161
+ onCancel={() => setSyncingNs(null)}
162
+ />
163
+ )}
164
+
143
165
  {/* Quick actions */}
144
166
  <div style={{ marginTop: 20, display: "flex", gap: 10 }}>
145
167
  <Button data-testid="diff-environments-btn" onClick={() => setView("diff")}>
@@ -42,7 +42,7 @@ describe("NamespaceEditor", () => {
42
42
  } as Response);
43
43
 
44
44
  await act(async () => {
45
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
45
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
46
46
  });
47
47
 
48
48
  expect(screen.getByText("DB_HOST")).toBeInTheDocument();
@@ -56,7 +56,7 @@ describe("NamespaceEditor", () => {
56
56
  } as Response);
57
57
 
58
58
  await act(async () => {
59
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
59
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
60
60
  });
61
61
 
62
62
  // Click production tab
@@ -77,7 +77,7 @@ describe("NamespaceEditor", () => {
77
77
  } as Response);
78
78
 
79
79
  await act(async () => {
80
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
80
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
81
81
  });
82
82
 
83
83
  await act(async () => {
@@ -94,7 +94,7 @@ describe("NamespaceEditor", () => {
94
94
  } as Response);
95
95
 
96
96
  await act(async () => {
97
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
97
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
98
98
  });
99
99
 
100
100
  expect(screen.getByText("Decrypt failed")).toBeInTheDocument();
@@ -107,7 +107,7 @@ describe("NamespaceEditor", () => {
107
107
  } as Response);
108
108
 
109
109
  await act(async () => {
110
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
110
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
111
111
  });
112
112
 
113
113
  await act(async () => {
@@ -126,7 +126,7 @@ describe("NamespaceEditor", () => {
126
126
  } as Response);
127
127
 
128
128
  await act(async () => {
129
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
129
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
130
130
  });
131
131
 
132
132
  await act(async () => {
@@ -149,7 +149,7 @@ describe("NamespaceEditor", () => {
149
149
  global.fetch = fetchMock;
150
150
 
151
151
  await act(async () => {
152
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
152
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
153
153
  });
154
154
 
155
155
  await act(async () => {
@@ -188,7 +188,7 @@ describe("NamespaceEditor", () => {
188
188
  } as Response);
189
189
 
190
190
  await act(async () => {
191
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
191
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
192
192
  });
193
193
 
194
194
  await act(async () => {
@@ -206,7 +206,7 @@ describe("NamespaceEditor", () => {
206
206
  } as Response);
207
207
 
208
208
  await act(async () => {
209
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
209
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
210
210
  });
211
211
 
212
212
  // Open overflow menu
@@ -230,7 +230,7 @@ describe("NamespaceEditor", () => {
230
230
  } as Response);
231
231
 
232
232
  await act(async () => {
233
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
233
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
234
234
  });
235
235
 
236
236
  await act(async () => {
@@ -260,7 +260,7 @@ describe("NamespaceEditor", () => {
260
260
  global.fetch = fetchMock;
261
261
 
262
262
  await act(async () => {
263
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
263
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
264
264
  });
265
265
 
266
266
  await act(async () => {
@@ -292,7 +292,7 @@ describe("NamespaceEditor", () => {
292
292
  } as Response);
293
293
 
294
294
  await act(async () => {
295
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
295
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
296
296
  });
297
297
 
298
298
  // Reveal the value
@@ -318,7 +318,7 @@ describe("NamespaceEditor", () => {
318
318
  } as Response);
319
319
 
320
320
  await act(async () => {
321
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
321
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
322
322
  });
323
323
 
324
324
  // Reveal a value to start the timer
@@ -347,7 +347,7 @@ describe("NamespaceEditor", () => {
347
347
  } as Response);
348
348
 
349
349
  await act(async () => {
350
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
350
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
351
351
  });
352
352
 
353
353
  // Switch to production tab
@@ -384,7 +384,7 @@ describe("NamespaceEditor", () => {
384
384
  global.fetch = fetchMock;
385
385
 
386
386
  await act(async () => {
387
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
387
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
388
388
  });
389
389
 
390
390
  // Switch to production tab
@@ -435,7 +435,7 @@ describe("NamespaceEditor", () => {
435
435
  global.fetch = fetchMock;
436
436
 
437
437
  await act(async () => {
438
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
438
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
439
439
  });
440
440
 
441
441
  // Switch to production tab
@@ -480,7 +480,7 @@ describe("NamespaceEditor", () => {
480
480
  } as Response);
481
481
 
482
482
  await act(async () => {
483
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
483
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
484
484
  });
485
485
 
486
486
  // Switch to production tab
@@ -509,7 +509,7 @@ describe("NamespaceEditor", () => {
509
509
  } as Response);
510
510
 
511
511
  await act(async () => {
512
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
512
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
513
513
  });
514
514
 
515
515
  // Stay on dev tab (default)
@@ -542,7 +542,7 @@ describe("NamespaceEditor", () => {
542
542
  } as Response);
543
543
 
544
544
  await act(async () => {
545
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
545
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
546
546
  });
547
547
 
548
548
  expect(screen.getByTestId("accept-value-DB_HOST")).toBeInTheDocument();
@@ -569,7 +569,7 @@ describe("NamespaceEditor", () => {
569
569
  global.fetch = fetchMock;
570
570
 
571
571
  await act(async () => {
572
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
572
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
573
573
  });
574
574
 
575
575
  // Click accept
@@ -598,7 +598,7 @@ describe("NamespaceEditor", () => {
598
598
  } as Response);
599
599
 
600
600
  await act(async () => {
601
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
601
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
602
602
  });
603
603
 
604
604
  await act(async () => {
@@ -615,7 +615,7 @@ describe("NamespaceEditor", () => {
615
615
  } as Response);
616
616
 
617
617
  await act(async () => {
618
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
618
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
619
619
  });
620
620
 
621
621
  await act(async () => {
@@ -638,7 +638,7 @@ describe("NamespaceEditor", () => {
638
638
  global.fetch = fetchMock;
639
639
 
640
640
  await act(async () => {
641
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
641
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
642
642
  });
643
643
 
644
644
  await act(async () => {
@@ -672,7 +672,7 @@ describe("NamespaceEditor", () => {
672
672
  } as Response);
673
673
 
674
674
  await act(async () => {
675
- render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
675
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
676
676
  });
677
677
 
678
678
  await act(async () => {
@@ -20,10 +20,9 @@ interface EditorRow {
20
20
  interface NamespaceEditorProps {
21
21
  ns: string;
22
22
  manifest: ClefManifest | null;
23
- onCommit: (message: string) => Promise<void>;
24
23
  }
25
24
 
26
- export function NamespaceEditor({ ns, manifest, onCommit }: NamespaceEditorProps) {
25
+ export function NamespaceEditor({ ns, manifest }: NamespaceEditorProps) {
27
26
  const [env, setEnv] = useState("");
28
27
  const [rows, setRows] = useState<EditorRow[]>([]);
29
28
  const [adding, setAdding] = useState(false);
@@ -35,8 +34,7 @@ export function NamespaceEditor({ ns, manifest, onCommit }: NamespaceEditorProps
35
34
  const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
36
35
  const [loading, setLoading] = useState(false);
37
36
  const [error, setError] = useState<string | null>(null);
38
- const [commitMessage, setCommitMessage] = useState("");
39
- const [showCommitInput, setShowCommitInput] = useState(false);
37
+ const [saving, setSaving] = useState(false);
40
38
  const [sopsInfo, setSopsInfo] = useState("");
41
39
  const [lintIssues, setLintIssues] = useState<LintIssue[]>([]);
42
40
  const [protectedConfirm, setProtectedConfirm] = useState<"save" | "add" | null>(null);
@@ -129,33 +127,25 @@ export function NamespaceEditor({ ns, manifest, onCommit }: NamespaceEditorProps
129
127
 
130
128
  const handleSave = async (confirmed?: boolean) => {
131
129
  const dirtyRows = rows.filter((r) => r.edited);
132
- // The edit-multiple-rows flow batches: each PUT writes without committing
133
- // (commit: false), then a single explicit POST /api/git/commit at the end
134
- // wraps all the changes in one commit. The other write paths in this
135
- // editor (handleAdd, handleDelete, handleResetToRandom, handleAccept)
136
- // omit the commit flag so each one auto-commits which is what users
137
- // want for single-action mutations.
138
- await Promise.all(
139
- dirtyRows.map((row) => {
140
- const payload: Record<string, unknown> = { value: row.value, commit: false };
130
+ if (dirtyRows.length === 0) return;
131
+ setSaving(true);
132
+ try {
133
+ // Each PUT auto-commits via the transaction manager, matching CLI
134
+ // behavior where each `clef set` is its own commit. Serialize so
135
+ // each transaction completes before the next starts.
136
+ for (const row of dirtyRows) {
137
+ const payload: Record<string, unknown> = { value: row.value };
141
138
  if (confirmed) payload.confirmed = true;
142
- return apiFetch(`/api/namespace/${ns}/${env}/${row.key}`, {
139
+ await apiFetch(`/api/namespace/${ns}/${env}/${row.key}`, {
143
140
  method: "PUT",
144
141
  headers: { "Content-Type": "application/json" },
145
142
  body: JSON.stringify(payload),
146
143
  });
147
- }),
148
- );
149
- if (commitMessage) {
150
- // Await the commit so the working tree is clean by the time handleSave
151
- // returns. Without this, subsequent transactional operations (here in
152
- // the UI or via the CLI) would race against the in-flight commit and
153
- // hit the dirty-tree preflight refusal.
154
- await onCommit(commitMessage);
144
+ }
145
+ await loadData();
146
+ } finally {
147
+ setSaving(false);
155
148
  }
156
- setShowCommitInput(false);
157
- setCommitMessage("");
158
- await loadData();
159
149
  };
160
150
 
161
151
  const handleAdd = async (confirmed?: boolean) => {
@@ -279,45 +269,21 @@ export function NamespaceEditor({ ns, manifest, onCommit }: NamespaceEditorProps
279
269
  subtitle={`Namespace \u00B7 ${rows.length} keys`}
280
270
  actions={
281
271
  <>
282
- {hasChanges && !showCommitInput && (
283
- <Button variant="primary" onClick={() => setShowCommitInput(true)}>
284
- Commit changes
272
+ {hasChanges && (
273
+ <Button
274
+ variant="primary"
275
+ disabled={saving}
276
+ onClick={() => {
277
+ if (isProduction) {
278
+ setProtectedConfirm("save");
279
+ } else {
280
+ handleSave();
281
+ }
282
+ }}
283
+ >
284
+ {saving ? "Saving..." : "Save"}
285
285
  </Button>
286
286
  )}
287
- {showCommitInput && (
288
- <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
289
- <input
290
- data-testid="commit-message-input"
291
- value={commitMessage}
292
- onChange={(e) => setCommitMessage(e.target.value)}
293
- placeholder="Commit message..."
294
- style={{
295
- background: "#0D0F14",
296
- border: `1px solid ${theme.borderLight}`,
297
- borderRadius: 5,
298
- padding: "5px 10px",
299
- fontFamily: theme.mono,
300
- fontSize: 12,
301
- color: theme.text,
302
- outline: "none",
303
- width: 220,
304
- }}
305
- />
306
- <Button
307
- variant="primary"
308
- onClick={() => {
309
- if (isProduction) {
310
- setProtectedConfirm("save");
311
- } else {
312
- handleSave();
313
- }
314
- }}
315
- >
316
- Save & Commit
317
- </Button>
318
- <Button onClick={() => setShowCommitInput(false)}>Cancel</Button>
319
- </div>
320
- )}
321
287
  <Button variant="primary" data-testid="add-key-btn" onClick={() => setAdding(true)}>
322
288
  + Add key
323
289
  </Button>