@stigmer/react 0.0.39 → 0.0.41

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.
Files changed (104) hide show
  1. package/agent/AgentDetailView.d.ts +14 -3
  2. package/agent/AgentDetailView.d.ts.map +1 -1
  3. package/agent/AgentDetailView.js +8 -7
  4. package/agent/AgentDetailView.js.map +1 -1
  5. package/agent/agentSetupReducer.d.ts +1 -0
  6. package/agent/agentSetupReducer.d.ts.map +1 -1
  7. package/agent/agentSetupReducer.js +11 -0
  8. package/agent/agentSetupReducer.js.map +1 -1
  9. package/agent/useAgentSetup.d.ts.map +1 -1
  10. package/agent/useAgentSetup.js +41 -6
  11. package/agent/useAgentSetup.js.map +1 -1
  12. package/api-key/ApiKeyCreatedAlert.d.ts +33 -0
  13. package/api-key/ApiKeyCreatedAlert.d.ts.map +1 -0
  14. package/api-key/ApiKeyCreatedAlert.js +61 -0
  15. package/api-key/ApiKeyCreatedAlert.js.map +1 -0
  16. package/api-key/ApiKeyListPanel.d.ts +30 -0
  17. package/api-key/ApiKeyListPanel.d.ts.map +1 -0
  18. package/api-key/ApiKeyListPanel.js +126 -0
  19. package/api-key/ApiKeyListPanel.js.map +1 -0
  20. package/api-key/CreateApiKeyForm.d.ts +35 -0
  21. package/api-key/CreateApiKeyForm.d.ts.map +1 -0
  22. package/api-key/CreateApiKeyForm.js +81 -0
  23. package/api-key/CreateApiKeyForm.js.map +1 -0
  24. package/api-key/index.d.ts +13 -0
  25. package/api-key/index.d.ts.map +1 -0
  26. package/api-key/index.js +7 -0
  27. package/api-key/index.js.map +1 -0
  28. package/api-key/useApiKeyList.d.ts +28 -0
  29. package/api-key/useApiKeyList.d.ts.map +1 -0
  30. package/api-key/useApiKeyList.js +52 -0
  31. package/api-key/useApiKeyList.js.map +1 -0
  32. package/api-key/useCreateApiKey.d.ts +40 -0
  33. package/api-key/useCreateApiKey.d.ts.map +1 -0
  34. package/api-key/useCreateApiKey.js +56 -0
  35. package/api-key/useCreateApiKey.js.map +1 -0
  36. package/api-key/useDeleteApiKey.d.ts +26 -0
  37. package/api-key/useDeleteApiKey.d.ts.map +1 -0
  38. package/api-key/useDeleteApiKey.js +43 -0
  39. package/api-key/useDeleteApiKey.js.map +1 -0
  40. package/composer/SessionComposer.d.ts.map +1 -1
  41. package/composer/SessionComposer.js +12 -0
  42. package/composer/SessionComposer.js.map +1 -1
  43. package/index.d.ts +5 -3
  44. package/index.d.ts.map +1 -1
  45. package/index.js +5 -3
  46. package/index.js.map +1 -1
  47. package/library/VisibilityToggle.d.ts +41 -0
  48. package/library/VisibilityToggle.d.ts.map +1 -0
  49. package/library/VisibilityToggle.js +80 -0
  50. package/library/VisibilityToggle.js.map +1 -0
  51. package/library/index.d.ts +4 -0
  52. package/library/index.d.ts.map +1 -1
  53. package/library/index.js +2 -0
  54. package/library/index.js.map +1 -1
  55. package/library/useUpdateVisibility.d.ts +40 -0
  56. package/library/useUpdateVisibility.d.ts.map +1 -0
  57. package/library/useUpdateVisibility.js +67 -0
  58. package/library/useUpdateVisibility.js.map +1 -0
  59. package/mcp-server/McpServerDetailView.d.ts +12 -1
  60. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  61. package/mcp-server/McpServerDetailView.js +6 -5
  62. package/mcp-server/McpServerDetailView.js.map +1 -1
  63. package/package.json +4 -4
  64. package/search/useResourceCount.d.ts +2 -2
  65. package/search/useResourceCount.js +2 -2
  66. package/search/useResourceCount.js.map +1 -1
  67. package/search/useResourceList.d.ts +8 -3
  68. package/search/useResourceList.d.ts.map +1 -1
  69. package/search/useResourceList.js +2 -2
  70. package/search/useResourceList.js.map +1 -1
  71. package/session/index.d.ts +1 -0
  72. package/session/index.d.ts.map +1 -1
  73. package/session/index.js +2 -0
  74. package/session/index.js.map +1 -1
  75. package/session/useCreateSession.d.ts +1 -1
  76. package/session/useCreateSession.d.ts.map +1 -1
  77. package/session/useCreateSession.js +2 -1
  78. package/session/useCreateSession.js.map +1 -1
  79. package/skill/SkillDetailView.d.ts +12 -1
  80. package/skill/SkillDetailView.d.ts.map +1 -1
  81. package/skill/SkillDetailView.js +6 -5
  82. package/skill/SkillDetailView.js.map +1 -1
  83. package/src/agent/AgentDetailView.tsx +34 -8
  84. package/src/agent/agentSetupReducer.ts +12 -0
  85. package/src/agent/useAgentSetup.ts +69 -19
  86. package/src/api-key/ApiKeyCreatedAlert.tsx +184 -0
  87. package/src/api-key/ApiKeyListPanel.tsx +359 -0
  88. package/src/api-key/CreateApiKeyForm.tsx +250 -0
  89. package/src/api-key/index.ts +12 -0
  90. package/src/api-key/useApiKeyList.ts +68 -0
  91. package/src/api-key/useCreateApiKey.ts +71 -0
  92. package/src/api-key/useDeleteApiKey.ts +57 -0
  93. package/src/composer/SessionComposer.tsx +13 -0
  94. package/src/index.ts +26 -1
  95. package/src/library/VisibilityToggle.tsx +205 -0
  96. package/src/library/index.ts +9 -0
  97. package/src/library/useUpdateVisibility.ts +94 -0
  98. package/src/mcp-server/McpServerDetailView.tsx +32 -6
  99. package/src/search/useResourceCount.ts +4 -4
  100. package/src/search/useResourceList.ts +10 -5
  101. package/src/session/index.ts +3 -0
  102. package/src/session/useCreateSession.ts +6 -5
  103. package/src/skill/SkillDetailView.tsx +32 -6
  104. 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 (missingVariables.length === 0) {
232
- // All variables present — create personal instance.
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.agentInstance.create(
241
- buildPersonalInstanceInput({
242
- org,
243
- agentId: agent.metadata!.id,
244
- agentSlug: ref.slug,
245
- environmentRef: envRef,
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 instance = await stigmer.agentInstance.create(
356
- buildPersonalInstanceInput({
357
- org,
358
- agentId,
359
- agentSlug: agentRef.slug,
360
- environmentRef: envRef,
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
+ }