@stigmer/react 0.0.39 → 0.0.40
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/agent/AgentDetailView.d.ts +12 -1
- package/agent/AgentDetailView.d.ts.map +1 -1
- package/agent/AgentDetailView.js +6 -5
- package/agent/AgentDetailView.js.map +1 -1
- package/agent/agentSetupReducer.d.ts +1 -0
- package/agent/agentSetupReducer.d.ts.map +1 -1
- package/agent/agentSetupReducer.js +11 -0
- package/agent/agentSetupReducer.js.map +1 -1
- package/agent/useAgentSetup.d.ts.map +1 -1
- package/agent/useAgentSetup.js +41 -6
- package/agent/useAgentSetup.js.map +1 -1
- package/api-key/ApiKeyCreatedAlert.d.ts +33 -0
- package/api-key/ApiKeyCreatedAlert.d.ts.map +1 -0
- package/api-key/ApiKeyCreatedAlert.js +61 -0
- package/api-key/ApiKeyCreatedAlert.js.map +1 -0
- package/api-key/ApiKeyListPanel.d.ts +30 -0
- package/api-key/ApiKeyListPanel.d.ts.map +1 -0
- package/api-key/ApiKeyListPanel.js +126 -0
- package/api-key/ApiKeyListPanel.js.map +1 -0
- package/api-key/CreateApiKeyForm.d.ts +35 -0
- package/api-key/CreateApiKeyForm.d.ts.map +1 -0
- package/api-key/CreateApiKeyForm.js +81 -0
- package/api-key/CreateApiKeyForm.js.map +1 -0
- package/api-key/index.d.ts +13 -0
- package/api-key/index.d.ts.map +1 -0
- package/api-key/index.js +7 -0
- package/api-key/index.js.map +1 -0
- package/api-key/useApiKeyList.d.ts +28 -0
- package/api-key/useApiKeyList.d.ts.map +1 -0
- package/api-key/useApiKeyList.js +52 -0
- package/api-key/useApiKeyList.js.map +1 -0
- package/api-key/useCreateApiKey.d.ts +40 -0
- package/api-key/useCreateApiKey.d.ts.map +1 -0
- package/api-key/useCreateApiKey.js +56 -0
- package/api-key/useCreateApiKey.js.map +1 -0
- package/api-key/useDeleteApiKey.d.ts +26 -0
- package/api-key/useDeleteApiKey.d.ts.map +1 -0
- package/api-key/useDeleteApiKey.js +43 -0
- package/api-key/useDeleteApiKey.js.map +1 -0
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +12 -0
- package/composer/SessionComposer.js.map +1 -1
- package/index.d.ts +5 -3
- package/index.d.ts.map +1 -1
- package/index.js +5 -3
- package/index.js.map +1 -1
- package/library/VisibilityToggle.d.ts +41 -0
- package/library/VisibilityToggle.d.ts.map +1 -0
- package/library/VisibilityToggle.js +80 -0
- package/library/VisibilityToggle.js.map +1 -0
- package/library/index.d.ts +4 -0
- package/library/index.d.ts.map +1 -1
- package/library/index.js +2 -0
- package/library/index.js.map +1 -1
- package/library/useUpdateVisibility.d.ts +40 -0
- package/library/useUpdateVisibility.d.ts.map +1 -0
- package/library/useUpdateVisibility.js +67 -0
- package/library/useUpdateVisibility.js.map +1 -0
- package/mcp-server/McpServerDetailView.d.ts +12 -1
- package/mcp-server/McpServerDetailView.d.ts.map +1 -1
- package/mcp-server/McpServerDetailView.js +6 -5
- package/mcp-server/McpServerDetailView.js.map +1 -1
- package/package.json +4 -4
- package/search/useResourceCount.d.ts +2 -2
- package/search/useResourceCount.js +2 -2
- package/search/useResourceCount.js.map +1 -1
- package/search/useResourceList.d.ts +8 -3
- package/search/useResourceList.d.ts.map +1 -1
- package/search/useResourceList.js +2 -2
- package/search/useResourceList.js.map +1 -1
- package/session/index.d.ts +1 -0
- package/session/index.d.ts.map +1 -1
- package/session/index.js +2 -0
- package/session/index.js.map +1 -1
- package/session/useCreateSession.d.ts +1 -1
- package/session/useCreateSession.d.ts.map +1 -1
- package/session/useCreateSession.js +2 -1
- package/session/useCreateSession.js.map +1 -1
- package/skill/SkillDetailView.d.ts +12 -1
- package/skill/SkillDetailView.d.ts.map +1 -1
- package/skill/SkillDetailView.js +6 -5
- package/skill/SkillDetailView.js.map +1 -1
- package/src/agent/AgentDetailView.tsx +32 -6
- package/src/agent/agentSetupReducer.ts +12 -0
- package/src/agent/useAgentSetup.ts +69 -19
- package/src/api-key/ApiKeyCreatedAlert.tsx +184 -0
- package/src/api-key/ApiKeyListPanel.tsx +359 -0
- package/src/api-key/CreateApiKeyForm.tsx +250 -0
- package/src/api-key/index.ts +12 -0
- package/src/api-key/useApiKeyList.ts +68 -0
- package/src/api-key/useCreateApiKey.ts +71 -0
- package/src/api-key/useDeleteApiKey.ts +57 -0
- package/src/composer/SessionComposer.tsx +13 -0
- package/src/index.ts +26 -1
- package/src/library/VisibilityToggle.tsx +205 -0
- package/src/library/index.ts +9 -0
- package/src/library/useUpdateVisibility.ts +94 -0
- package/src/mcp-server/McpServerDetailView.tsx +32 -6
- package/src/search/useResourceCount.ts +4 -4
- package/src/search/useResourceList.ts +10 -5
- package/src/session/index.ts +3 -0
- package/src/session/useCreateSession.ts +6 -5
- package/src/skill/SkillDetailView.tsx +32 -6
- package/styles.css +1 -1
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useReducer } from "react";
|
|
4
4
|
import { create } from "@bufbuild/protobuf";
|
|
5
|
-
import type { EnvVarInput, ResourceRef } from "@stigmer/sdk";
|
|
5
|
+
import type { EnvVarInput, ResourceRef, Stigmer } from "@stigmer/sdk";
|
|
6
6
|
import { ListAgentInstancesRequestSchema } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/io_pb";
|
|
7
|
+
import type { AgentInstance } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/api_pb";
|
|
7
8
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
8
9
|
import { useStigmer } from "../hooks";
|
|
9
10
|
import { toError } from "../internal/toError";
|
|
@@ -22,6 +23,42 @@ import {
|
|
|
22
23
|
const PERSONAL_LABEL = "stigmer.ai/personal";
|
|
23
24
|
const FOR_AGENT_LABEL = "stigmer.ai/for-agent";
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Re-checks for an existing personal instance immediately before
|
|
28
|
+
* creating one. Narrows the race window when multiple clients
|
|
29
|
+
* (tabs, double-clicks) attempt to create simultaneously.
|
|
30
|
+
*/
|
|
31
|
+
async function findOrCreatePersonalInstance(
|
|
32
|
+
stigmer: Stigmer,
|
|
33
|
+
params: {
|
|
34
|
+
org: string;
|
|
35
|
+
agentId: string;
|
|
36
|
+
agentSlug: string;
|
|
37
|
+
agentLabel: string;
|
|
38
|
+
environmentRef: ResourceRef;
|
|
39
|
+
},
|
|
40
|
+
): Promise<AgentInstance> {
|
|
41
|
+
const { org, agentId, agentSlug, agentLabel, environmentRef } = params;
|
|
42
|
+
|
|
43
|
+
const recheck = await stigmer.agentInstance.list(
|
|
44
|
+
create(ListAgentInstancesRequestSchema, {
|
|
45
|
+
org,
|
|
46
|
+
labels: {
|
|
47
|
+
[PERSONAL_LABEL]: "true",
|
|
48
|
+
[FOR_AGENT_LABEL]: agentLabel,
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (recheck.items.length > 0) {
|
|
54
|
+
return recheck.items[0];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return stigmer.agentInstance.create(
|
|
58
|
+
buildPersonalInstanceInput({ org, agentId, agentSlug, environmentRef }),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
25
62
|
// ---------------------------------------------------------------------------
|
|
26
63
|
// Public types (re-exported from agentSetupReducer for convenience)
|
|
27
64
|
// ---------------------------------------------------------------------------
|
|
@@ -226,10 +263,11 @@ export function useAgentSetup(
|
|
|
226
263
|
const existingKeys = new Set(
|
|
227
264
|
Object.keys(personalEnv.environment?.spec?.data ?? {}),
|
|
228
265
|
);
|
|
266
|
+
const personalOnlyMissing = diffEnvSpec(envSpecData, existingKeys);
|
|
229
267
|
const missingVariables = diffEnvSpec(envSpecData, existingKeys, poolKeys);
|
|
230
268
|
|
|
231
|
-
if (
|
|
232
|
-
//
|
|
269
|
+
if (personalOnlyMissing.length === 0) {
|
|
270
|
+
// Personal env covers all keys — create personal instance.
|
|
233
271
|
const env = await personalEnv.getOrCreate();
|
|
234
272
|
const envRef: ResourceRef = {
|
|
235
273
|
org,
|
|
@@ -237,14 +275,13 @@ export function useAgentSetup(
|
|
|
237
275
|
kind: ApiResourceKind.environment,
|
|
238
276
|
};
|
|
239
277
|
|
|
240
|
-
const instance = await stigmer
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
);
|
|
278
|
+
const instance = await findOrCreatePersonalInstance(stigmer, {
|
|
279
|
+
org,
|
|
280
|
+
agentId: agent.metadata!.id,
|
|
281
|
+
agentSlug: ref.slug,
|
|
282
|
+
agentLabel,
|
|
283
|
+
environmentRef: envRef,
|
|
284
|
+
});
|
|
248
285
|
|
|
249
286
|
const resolution: AgentResolution = {
|
|
250
287
|
mode: "saved",
|
|
@@ -259,6 +296,19 @@ export function useAgentSetup(
|
|
|
259
296
|
return { status: "ready", agentRef: ref, agentName, resolution };
|
|
260
297
|
}
|
|
261
298
|
|
|
299
|
+
if (missingVariables.length === 0) {
|
|
300
|
+
// Pool covers remaining keys — use default instance, pool
|
|
301
|
+
// values flow via sessionVariables.toRuntimeEnv() at submit.
|
|
302
|
+
const resolution: AgentResolution = { mode: "direct" };
|
|
303
|
+
dispatch({
|
|
304
|
+
type: "RESOLVE_READY",
|
|
305
|
+
agentRef: ref,
|
|
306
|
+
agentName,
|
|
307
|
+
resolution,
|
|
308
|
+
});
|
|
309
|
+
return { status: "ready", agentRef: ref, agentName, resolution };
|
|
310
|
+
}
|
|
311
|
+
|
|
262
312
|
// Missing variables — transition to needsEnvVars.
|
|
263
313
|
dispatch({
|
|
264
314
|
type: "RESOLVE_NEEDS_ENV",
|
|
@@ -352,14 +402,14 @@ export function useAgentSetup(
|
|
|
352
402
|
kind: ApiResourceKind.environment,
|
|
353
403
|
};
|
|
354
404
|
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
);
|
|
405
|
+
const agentLabel = `${agentRef.org}/${agentRef.slug}`;
|
|
406
|
+
const instance = await findOrCreatePersonalInstance(stigmer, {
|
|
407
|
+
org,
|
|
408
|
+
agentId,
|
|
409
|
+
agentSlug: agentRef.slug,
|
|
410
|
+
agentLabel,
|
|
411
|
+
environmentRef: envRef,
|
|
412
|
+
});
|
|
363
413
|
|
|
364
414
|
const resolution: AgentResolution = {
|
|
365
415
|
mode: "saved",
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from "react";
|
|
4
|
+
import { cn } from "@stigmer/theme";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Public API
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export interface ApiKeyCreatedAlertProps {
|
|
11
|
+
/** The raw API key value to display. Shown exactly once. */
|
|
12
|
+
readonly rawKey: string;
|
|
13
|
+
/** Human-readable name of the key for context. */
|
|
14
|
+
readonly keyName: string;
|
|
15
|
+
/** Fired when the user dismisses the alert. */
|
|
16
|
+
readonly onDismiss: () => void;
|
|
17
|
+
readonly className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One-time reveal component displayed after a new API key is created.
|
|
22
|
+
*
|
|
23
|
+
* Shows the raw key value in a monospace read-only field with a
|
|
24
|
+
* **Copy** button. The key is only available at creation time — the
|
|
25
|
+
* server never returns it again — so this component prominently warns
|
|
26
|
+
* the user to copy it immediately.
|
|
27
|
+
*
|
|
28
|
+
* This is a standalone alert component, not a modal — the parent
|
|
29
|
+
* decides how to present and position it.
|
|
30
|
+
*
|
|
31
|
+
* All visual properties flow through `--stgm-*` design tokens.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```tsx
|
|
35
|
+
* <ApiKeyCreatedAlert
|
|
36
|
+
* rawKey="stgm_live_abc123..."
|
|
37
|
+
* keyName="ci-deploy-key"
|
|
38
|
+
* onDismiss={() => setRevealedKey(null)}
|
|
39
|
+
* />
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function ApiKeyCreatedAlert({
|
|
43
|
+
rawKey,
|
|
44
|
+
keyName,
|
|
45
|
+
onDismiss,
|
|
46
|
+
className,
|
|
47
|
+
}: ApiKeyCreatedAlertProps) {
|
|
48
|
+
const [copied, setCopied] = useState(false);
|
|
49
|
+
|
|
50
|
+
const handleCopy = useCallback(async () => {
|
|
51
|
+
try {
|
|
52
|
+
await navigator.clipboard.writeText(rawKey);
|
|
53
|
+
setCopied(true);
|
|
54
|
+
setTimeout(() => setCopied(false), 2000);
|
|
55
|
+
} catch {
|
|
56
|
+
// Fallback: select the text so the user can manually copy
|
|
57
|
+
const el = document.getElementById("stgm-api-key-reveal");
|
|
58
|
+
if (el) {
|
|
59
|
+
const selection = window.getSelection();
|
|
60
|
+
const range = document.createRange();
|
|
61
|
+
range.selectNodeContents(el);
|
|
62
|
+
selection?.removeAllRanges();
|
|
63
|
+
selection?.addRange(range);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}, [rawKey]);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
role="alert"
|
|
71
|
+
className={cn(
|
|
72
|
+
"rounded-lg border border-primary/30 bg-primary-subtle p-4",
|
|
73
|
+
className,
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
<div className="mb-2 flex items-start justify-between gap-3">
|
|
77
|
+
<div className="min-w-0">
|
|
78
|
+
<p className="text-sm font-medium text-foreground">
|
|
79
|
+
API key created: {keyName}
|
|
80
|
+
</p>
|
|
81
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
82
|
+
Copy this key now. It will not be shown again.
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={onDismiss}
|
|
88
|
+
aria-label="Dismiss"
|
|
89
|
+
className={cn(
|
|
90
|
+
"shrink-0 rounded p-1",
|
|
91
|
+
"text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
|
92
|
+
"transition-colors",
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
<CloseIcon />
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div className="flex items-center gap-2">
|
|
100
|
+
<code
|
|
101
|
+
id="stgm-api-key-reveal"
|
|
102
|
+
className={cn(
|
|
103
|
+
"min-w-0 flex-1 select-all truncate rounded-md",
|
|
104
|
+
"border border-input bg-background px-2.5 py-1.5",
|
|
105
|
+
"font-mono text-xs text-foreground",
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
{rawKey}
|
|
109
|
+
</code>
|
|
110
|
+
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
onClick={handleCopy}
|
|
114
|
+
className={cn(
|
|
115
|
+
"inline-flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium",
|
|
116
|
+
"bg-primary text-primary-foreground hover:bg-primary/90",
|
|
117
|
+
"transition-colors",
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
121
|
+
{copied ? "Copied" : "Copy"}
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Icons
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
function CopyIcon() {
|
|
133
|
+
return (
|
|
134
|
+
<svg
|
|
135
|
+
width="12"
|
|
136
|
+
height="12"
|
|
137
|
+
viewBox="0 0 16 16"
|
|
138
|
+
fill="none"
|
|
139
|
+
stroke="currentColor"
|
|
140
|
+
strokeWidth="1.5"
|
|
141
|
+
strokeLinecap="round"
|
|
142
|
+
strokeLinejoin="round"
|
|
143
|
+
aria-hidden="true"
|
|
144
|
+
>
|
|
145
|
+
<rect x="5" y="5" width="9" height="9" rx="1.5" />
|
|
146
|
+
<path d="M5 11H3.5A1.5 1.5 0 0 1 2 9.5V3.5A1.5 1.5 0 0 1 3.5 2h6A1.5 1.5 0 0 1 11 3.5V5" />
|
|
147
|
+
</svg>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function CheckIcon() {
|
|
152
|
+
return (
|
|
153
|
+
<svg
|
|
154
|
+
width="12"
|
|
155
|
+
height="12"
|
|
156
|
+
viewBox="0 0 16 16"
|
|
157
|
+
fill="none"
|
|
158
|
+
stroke="currentColor"
|
|
159
|
+
strokeWidth="2"
|
|
160
|
+
strokeLinecap="round"
|
|
161
|
+
strokeLinejoin="round"
|
|
162
|
+
aria-hidden="true"
|
|
163
|
+
>
|
|
164
|
+
<path d="M3 8.5l3.5 3.5L13 5" />
|
|
165
|
+
</svg>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function CloseIcon() {
|
|
170
|
+
return (
|
|
171
|
+
<svg
|
|
172
|
+
width="14"
|
|
173
|
+
height="14"
|
|
174
|
+
viewBox="0 0 16 16"
|
|
175
|
+
fill="none"
|
|
176
|
+
stroke="currentColor"
|
|
177
|
+
strokeWidth="1.5"
|
|
178
|
+
strokeLinecap="round"
|
|
179
|
+
aria-hidden="true"
|
|
180
|
+
>
|
|
181
|
+
<path d="M4 4l8 8M12 4l-8 8" />
|
|
182
|
+
</svg>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from "react";
|
|
4
|
+
import { cn } from "@stigmer/theme";
|
|
5
|
+
import { getUserMessage } from "@stigmer/sdk";
|
|
6
|
+
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
7
|
+
import type { ApiKey } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/api_pb";
|
|
8
|
+
import { useApiKeyList } from "./useApiKeyList";
|
|
9
|
+
import { useDeleteApiKey } from "./useDeleteApiKey";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Public API
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface ApiKeyListPanelProps {
|
|
16
|
+
/** Re-expose refetch so parents can trigger a list refresh. */
|
|
17
|
+
readonly onRefetchRef?: (refetch: () => void) => void;
|
|
18
|
+
readonly className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Displays a list of {@link ApiKey} resources for the authenticated
|
|
23
|
+
* identity with inline delete confirmation.
|
|
24
|
+
*
|
|
25
|
+
* Each key is rendered as a row showing name, fingerprint, creation
|
|
26
|
+
* date, expiry, and last-used time. A delete button triggers an
|
|
27
|
+
* inline confirmation — the row transforms to show confirm/cancel
|
|
28
|
+
* actions.
|
|
29
|
+
*
|
|
30
|
+
* API keys are identity-scoped: the server returns all keys belonging
|
|
31
|
+
* to the authenticated user, regardless of organization.
|
|
32
|
+
*
|
|
33
|
+
* All visual properties flow through `--stgm-*` design tokens.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* <ApiKeyListPanel />
|
|
38
|
+
*
|
|
39
|
+
* <ApiKeyListPanel
|
|
40
|
+
* onRefetchRef={(refetch) => { listRefetchRef.current = refetch; }}
|
|
41
|
+
* />
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function ApiKeyListPanel({
|
|
45
|
+
onRefetchRef,
|
|
46
|
+
className,
|
|
47
|
+
}: ApiKeyListPanelProps) {
|
|
48
|
+
const { apiKeys, isLoading, error, refetch } = useApiKeyList();
|
|
49
|
+
const [confirmingId, setConfirmingId] = useState<string | null>(null);
|
|
50
|
+
|
|
51
|
+
if (onRefetchRef) {
|
|
52
|
+
onRefetchRef(refetch);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isLoading) {
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className={cn("space-y-2", className)}
|
|
59
|
+
aria-busy="true"
|
|
60
|
+
aria-label="Loading API keys"
|
|
61
|
+
>
|
|
62
|
+
{Array.from({ length: 2 }, (_, i) => (
|
|
63
|
+
<div
|
|
64
|
+
key={i}
|
|
65
|
+
className="bg-muted/40 h-14 animate-pulse rounded-lg"
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (error) {
|
|
73
|
+
return (
|
|
74
|
+
<p className={cn("text-destructive text-xs", className)} role="alert">
|
|
75
|
+
{getUserMessage(error)}
|
|
76
|
+
</p>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (apiKeys.length === 0) {
|
|
81
|
+
return (
|
|
82
|
+
<p
|
|
83
|
+
className={cn(
|
|
84
|
+
"text-muted-foreground py-4 text-center text-xs",
|
|
85
|
+
className,
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
No API keys yet.
|
|
89
|
+
</p>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
className={cn("space-y-2", className)}
|
|
96
|
+
role="list"
|
|
97
|
+
aria-label="API keys"
|
|
98
|
+
>
|
|
99
|
+
{apiKeys.map((key) => {
|
|
100
|
+
const id = key.metadata?.id ?? "";
|
|
101
|
+
return (
|
|
102
|
+
<ApiKeyRow
|
|
103
|
+
key={id}
|
|
104
|
+
apiKey={key}
|
|
105
|
+
isConfirming={confirmingId === id}
|
|
106
|
+
onConfirmDelete={() => setConfirmingId(id)}
|
|
107
|
+
onCancelDelete={() => setConfirmingId(null)}
|
|
108
|
+
onDeleted={() => {
|
|
109
|
+
setConfirmingId(null);
|
|
110
|
+
refetch();
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// ApiKeyRow (internal)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
function ApiKeyRow({
|
|
124
|
+
apiKey,
|
|
125
|
+
isConfirming,
|
|
126
|
+
onConfirmDelete,
|
|
127
|
+
onCancelDelete,
|
|
128
|
+
onDeleted,
|
|
129
|
+
}: {
|
|
130
|
+
apiKey: ApiKey;
|
|
131
|
+
isConfirming: boolean;
|
|
132
|
+
onConfirmDelete: () => void;
|
|
133
|
+
onCancelDelete: () => void;
|
|
134
|
+
onDeleted: () => void;
|
|
135
|
+
}) {
|
|
136
|
+
const { deleteKey, isDeleting, error } = useDeleteApiKey();
|
|
137
|
+
|
|
138
|
+
const id = apiKey.metadata?.id ?? "";
|
|
139
|
+
const name = apiKey.metadata?.name || apiKey.metadata?.slug || "Unnamed key";
|
|
140
|
+
const fingerprint = apiKey.spec?.fingerprint;
|
|
141
|
+
const neverExpires = apiKey.spec?.neverExpires;
|
|
142
|
+
const expiresAt = apiKey.spec?.expiresAt;
|
|
143
|
+
const lastUsedAt = apiKey.status?.lastUsedAt;
|
|
144
|
+
const createdAt = apiKey.status?.audit?.specAudit?.createdAt;
|
|
145
|
+
|
|
146
|
+
const handleDelete = useCallback(async () => {
|
|
147
|
+
try {
|
|
148
|
+
await deleteKey(id);
|
|
149
|
+
onDeleted();
|
|
150
|
+
} catch {
|
|
151
|
+
// error state is surfaced via the hook
|
|
152
|
+
}
|
|
153
|
+
}, [id, deleteKey, onDeleted]);
|
|
154
|
+
|
|
155
|
+
if (isConfirming) {
|
|
156
|
+
return (
|
|
157
|
+
<div
|
|
158
|
+
role="listitem"
|
|
159
|
+
className="flex items-center justify-between rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2.5"
|
|
160
|
+
>
|
|
161
|
+
<div className="min-w-0 flex-1">
|
|
162
|
+
<p className="text-xs text-foreground">
|
|
163
|
+
Delete <span className="font-medium">{name}</span>?
|
|
164
|
+
{fingerprint && (
|
|
165
|
+
<span className="text-muted-foreground">
|
|
166
|
+
{" "}
|
|
167
|
+
(…{fingerprint})
|
|
168
|
+
</span>
|
|
169
|
+
)}
|
|
170
|
+
</p>
|
|
171
|
+
{error && (
|
|
172
|
+
<p className="mt-0.5 text-[0.65rem] text-destructive">
|
|
173
|
+
{getUserMessage(error)}
|
|
174
|
+
</p>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<div className="flex shrink-0 items-center gap-1.5">
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={handleDelete}
|
|
182
|
+
disabled={isDeleting}
|
|
183
|
+
className={cn(
|
|
184
|
+
"inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium",
|
|
185
|
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
186
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
{isDeleting && <SpinnerIcon />}
|
|
190
|
+
Delete
|
|
191
|
+
</button>
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
onClick={onCancelDelete}
|
|
195
|
+
disabled={isDeleting}
|
|
196
|
+
className={cn(
|
|
197
|
+
"rounded-md px-2.5 py-1 text-xs",
|
|
198
|
+
"text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
|
199
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
200
|
+
)}
|
|
201
|
+
>
|
|
202
|
+
Cancel
|
|
203
|
+
</button>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div
|
|
211
|
+
role="listitem"
|
|
212
|
+
className="flex items-center gap-3 rounded-lg border border-border/60 px-3 py-2.5 hover:border-border transition-colors"
|
|
213
|
+
>
|
|
214
|
+
{/* Key icon */}
|
|
215
|
+
<KeyIcon />
|
|
216
|
+
|
|
217
|
+
{/* Name + fingerprint */}
|
|
218
|
+
<div className="min-w-0 flex-1">
|
|
219
|
+
<span className="block truncate text-sm font-medium text-foreground">
|
|
220
|
+
{name}
|
|
221
|
+
</span>
|
|
222
|
+
{fingerprint && (
|
|
223
|
+
<span className="block text-xs text-muted-foreground font-mono">
|
|
224
|
+
…{fingerprint}
|
|
225
|
+
</span>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{/* Metadata columns */}
|
|
230
|
+
<div className="hidden sm:flex shrink-0 items-center gap-4 text-xs text-muted-foreground">
|
|
231
|
+
{createdAt && (
|
|
232
|
+
<span title={`Created ${timestampDate(createdAt).toISOString()}`}>
|
|
233
|
+
{formatShortDate(timestampDate(createdAt))}
|
|
234
|
+
</span>
|
|
235
|
+
)}
|
|
236
|
+
<span>
|
|
237
|
+
{neverExpires
|
|
238
|
+
? "No expiry"
|
|
239
|
+
: expiresAt
|
|
240
|
+
? `Expires ${formatShortDate(timestampDate(expiresAt))}`
|
|
241
|
+
: "No expiry"}
|
|
242
|
+
</span>
|
|
243
|
+
<span>
|
|
244
|
+
{lastUsedAt ? formatRelativeTime(timestampDate(lastUsedAt)) : "Never used"}
|
|
245
|
+
</span>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
{/* Delete button */}
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
onClick={onConfirmDelete}
|
|
252
|
+
aria-label={`Delete ${name}`}
|
|
253
|
+
className={cn(
|
|
254
|
+
"shrink-0 rounded p-1",
|
|
255
|
+
"text-muted-foreground hover:text-destructive hover:bg-destructive/10",
|
|
256
|
+
"transition-colors",
|
|
257
|
+
)}
|
|
258
|
+
>
|
|
259
|
+
<TrashIcon />
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Formatting helpers
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
function formatShortDate(date: Date): string {
|
|
270
|
+
return date.toLocaleDateString(undefined, {
|
|
271
|
+
month: "short",
|
|
272
|
+
day: "numeric",
|
|
273
|
+
year: "numeric",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function formatRelativeTime(date: Date): string {
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
const diffMs = now - date.getTime();
|
|
280
|
+
|
|
281
|
+
if (diffMs < 0) return "Just now";
|
|
282
|
+
|
|
283
|
+
const seconds = Math.floor(diffMs / 1000);
|
|
284
|
+
if (seconds < 60) return "Just now";
|
|
285
|
+
|
|
286
|
+
const minutes = Math.floor(seconds / 60);
|
|
287
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
288
|
+
|
|
289
|
+
const hours = Math.floor(minutes / 60);
|
|
290
|
+
if (hours < 24) return `${hours}h ago`;
|
|
291
|
+
|
|
292
|
+
const days = Math.floor(hours / 24);
|
|
293
|
+
if (days < 30) return `${days}d ago`;
|
|
294
|
+
|
|
295
|
+
return formatShortDate(date);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Icons
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
function KeyIcon() {
|
|
303
|
+
return (
|
|
304
|
+
<svg
|
|
305
|
+
width="14"
|
|
306
|
+
height="14"
|
|
307
|
+
viewBox="0 0 16 16"
|
|
308
|
+
fill="none"
|
|
309
|
+
stroke="currentColor"
|
|
310
|
+
strokeWidth="1.5"
|
|
311
|
+
strokeLinecap="round"
|
|
312
|
+
strokeLinejoin="round"
|
|
313
|
+
aria-hidden="true"
|
|
314
|
+
className="shrink-0 text-muted-foreground"
|
|
315
|
+
>
|
|
316
|
+
<circle cx="5.5" cy="10.5" r="3" />
|
|
317
|
+
<path d="M8 8l5.5-5.5M11 5l2-2M10.5 2.5l2 2" />
|
|
318
|
+
</svg>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function TrashIcon() {
|
|
323
|
+
return (
|
|
324
|
+
<svg
|
|
325
|
+
width="14"
|
|
326
|
+
height="14"
|
|
327
|
+
viewBox="0 0 16 16"
|
|
328
|
+
fill="none"
|
|
329
|
+
stroke="currentColor"
|
|
330
|
+
strokeWidth="1.5"
|
|
331
|
+
strokeLinecap="round"
|
|
332
|
+
strokeLinejoin="round"
|
|
333
|
+
aria-hidden="true"
|
|
334
|
+
>
|
|
335
|
+
<path d="M2.5 4h11M5.5 4V2.5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1V4" />
|
|
336
|
+
<path d="M12.5 4v9a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1V4" />
|
|
337
|
+
<line x1="6.5" y1="7" x2="6.5" y2="11" />
|
|
338
|
+
<line x1="9.5" y1="7" x2="9.5" y2="11" />
|
|
339
|
+
</svg>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function SpinnerIcon() {
|
|
344
|
+
return (
|
|
345
|
+
<svg
|
|
346
|
+
width="12"
|
|
347
|
+
height="12"
|
|
348
|
+
viewBox="0 0 16 16"
|
|
349
|
+
fill="none"
|
|
350
|
+
stroke="currentColor"
|
|
351
|
+
strokeWidth="2"
|
|
352
|
+
strokeLinecap="round"
|
|
353
|
+
className="animate-spin"
|
|
354
|
+
aria-hidden="true"
|
|
355
|
+
>
|
|
356
|
+
<path d="M8 2a6 6 0 1 0 6 6" />
|
|
357
|
+
</svg>
|
|
358
|
+
);
|
|
359
|
+
}
|