@invect/user-auth 0.0.1 → 0.0.3

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 (38) hide show
  1. package/README.md +81 -72
  2. package/dist/backend/index.cjs +410 -54
  3. package/dist/backend/index.cjs.map +1 -1
  4. package/dist/backend/index.d.cts +456 -0
  5. package/dist/backend/index.d.cts.map +1 -0
  6. package/dist/backend/index.d.mts +456 -0
  7. package/dist/backend/index.d.mts.map +1 -0
  8. package/dist/backend/index.d.ts +28 -18
  9. package/dist/backend/index.d.ts.map +1 -1
  10. package/dist/backend/index.mjs +408 -53
  11. package/dist/backend/index.mjs.map +1 -1
  12. package/dist/backend/plugin.d.ts +15 -15
  13. package/dist/backend/plugin.d.ts.map +1 -1
  14. package/dist/backend/types.d.ts +85 -9
  15. package/dist/backend/types.d.ts.map +1 -1
  16. package/dist/frontend/components/ApiKeysDialog.d.ts +17 -0
  17. package/dist/frontend/components/ApiKeysDialog.d.ts.map +1 -0
  18. package/dist/frontend/components/AuthenticatedInvect.d.ts +10 -10
  19. package/dist/frontend/components/SignInForm.d.ts.map +1 -1
  20. package/dist/frontend/components/SignInPage.d.ts.map +1 -1
  21. package/dist/frontend/components/UserManagement.d.ts.map +1 -1
  22. package/dist/frontend/index.cjs +434 -58
  23. package/dist/frontend/index.cjs.map +1 -1
  24. package/dist/frontend/index.d.cts +317 -0
  25. package/dist/frontend/index.d.cts.map +1 -0
  26. package/dist/frontend/index.d.mts +317 -0
  27. package/dist/frontend/index.d.mts.map +1 -0
  28. package/dist/frontend/index.d.ts +3 -1
  29. package/dist/frontend/index.d.ts.map +1 -1
  30. package/dist/frontend/index.mjs +418 -43
  31. package/dist/frontend/index.mjs.map +1 -1
  32. package/dist/frontend/plugins/authFrontendPlugin.d.ts +2 -2
  33. package/dist/frontend/plugins/authFrontendPlugin.d.ts.map +1 -1
  34. package/dist/shared/types.d.cts +49 -0
  35. package/dist/shared/types.d.cts.map +1 -0
  36. package/dist/shared/types.d.mts +49 -0
  37. package/dist/shared/types.d.mts.map +1 -0
  38. package/package.json +68 -66
@@ -4,7 +4,7 @@ let react = require("react");
4
4
  let _tanstack_react_query = require("@tanstack/react-query");
5
5
  let react_jsx_runtime = require("react/jsx-runtime");
6
6
  let lucide_react = require("lucide-react");
7
- let _invect_frontend = require("@invect/frontend");
7
+ let _invect_ui = require("@invect/ui");
8
8
  let react_router = require("react-router");
9
9
  //#region src/frontend/providers/AuthProvider.tsx
10
10
  /**
@@ -232,14 +232,14 @@ function SignInForm({ onSuccess, className }) {
232
232
  })]
233
233
  }),
234
234
  displayError && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
235
- className: "rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900/50 dark:bg-red-950/20 dark:text-red-400",
235
+ className: "rounded-md border border-imp-destructive/30 bg-imp-destructive/10 px-3 py-2 text-sm text-imp-destructive",
236
236
  children: displayError
237
237
  }),
238
238
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
239
239
  type: "submit",
240
240
  disabled: isSigningIn,
241
- className: "inline-flex h-9 w-full items-center justify-center gap-2 whitespace-nowrap rounded-md bg-imp-foreground px-4 py-2 text-sm font-medium text-imp-background shadow transition-colors hover:bg-imp-foreground/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-imp-ring disabled:pointer-events-none disabled:opacity-50",
242
- children: isSigningIn ? "Signing in…" : "Login"
241
+ className: "inline-flex h-9 w-full items-center justify-center gap-2 whitespace-nowrap rounded-md bg-imp-primary px-4 py-2 text-sm font-medium text-imp-primary-foreground shadow transition-colors hover:bg-imp-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-imp-ring disabled:pointer-events-none disabled:opacity-50",
242
+ children: isSigningIn ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Loader2, { className: "w-4 h-4 animate-spin" }), "Signing in…"] }) : "Sign in"
243
243
  })
244
244
  ]
245
245
  })
@@ -256,16 +256,16 @@ function SignInForm({ onSuccess, className }) {
256
256
  */
257
257
  function SignInPage({ onSuccess, onNavigateToSignUp, title = "Welcome back", subtitle = "Sign in to your account to continue" }) {
258
258
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
259
- className: "flex min-h-screen items-center justify-center bg-imp-background p-4 text-imp-foreground",
259
+ className: "flex items-center justify-center min-h-screen p-4 bg-imp-background text-imp-foreground",
260
260
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
261
- className: "flex w-full max-w-sm flex-col gap-6",
261
+ className: "flex flex-col w-full max-w-sm gap-6",
262
262
  children: [
263
263
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
264
264
  className: "flex flex-col items-center gap-2 text-center",
265
265
  children: [
266
266
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
267
267
  className: "flex items-center justify-center",
268
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImpLogo, { className: "h-10 w-auto" })
268
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImpLogo, { className: "w-auto h-24" })
269
269
  }),
270
270
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h1", {
271
271
  className: "text-xl font-bold",
@@ -279,24 +279,20 @@ function SignInPage({ onSuccess, onNavigateToSignUp, title = "Welcome back", sub
279
279
  }),
280
280
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SignInForm, { onSuccess }),
281
281
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
282
- className: "relative text-center text-sm",
283
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "absolute inset-0 top-1/2 border-t border-imp-border" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
284
- className: "relative bg-imp-background px-2 text-imp-muted-foreground",
282
+ className: "relative text-sm text-center",
283
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "absolute inset-0 border-t top-1/2 border-imp-border" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
284
+ className: "relative px-2 bg-imp-background text-imp-muted-foreground",
285
285
  children: "Admin-managed accounts"
286
286
  })]
287
287
  }),
288
288
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
289
- className: "px-6 text-center text-xs text-imp-muted-foreground",
290
- children: [
291
- "New accounts are created by your administrator.",
292
- onNavigateToSignUp ? " Or " : " Contact your admin if you need access.",
293
- onNavigateToSignUp && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
294
- type: "button",
295
- onClick: onNavigateToSignUp,
296
- className: "font-medium underline underline-offset-4 hover:text-imp-foreground",
297
- children: "Sign up"
298
- })
299
- ]
289
+ className: "px-6 text-xs text-center text-imp-muted-foreground",
290
+ children: [onNavigateToSignUp ? " Or " : " Contact your admin if you need access.", onNavigateToSignUp && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
291
+ type: "button",
292
+ onClick: onNavigateToSignUp,
293
+ className: "font-medium underline underline-offset-4 hover:text-imp-foreground",
294
+ children: "Sign up"
295
+ })]
300
296
  })
301
297
  ]
302
298
  })
@@ -401,6 +397,359 @@ function AuthGate({ children, fallback = null, loading = null }) {
401
397
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children });
402
398
  }
403
399
  //#endregion
400
+ //#region src/frontend/components/ApiKeysDialog.tsx
401
+ /**
402
+ * ApiKeysDialog — Admin dialog for managing API keys.
403
+ *
404
+ * Displays a list of API keys with the ability to:
405
+ * - Create new API keys (name, optional expiry)
406
+ * - Copy newly created keys
407
+ * - Delete existing keys
408
+ *
409
+ * Only functional when the auth plugin has API keys enabled.
410
+ */
411
+ function formatDate$1(iso) {
412
+ try {
413
+ return new Intl.DateTimeFormat(void 0, {
414
+ dateStyle: "medium",
415
+ timeStyle: "short"
416
+ }).format(new Date(iso));
417
+ } catch {
418
+ return iso;
419
+ }
420
+ }
421
+ function isExpired(expiresAt) {
422
+ if (!expiresAt) return false;
423
+ return new Date(expiresAt) < /* @__PURE__ */ new Date();
424
+ }
425
+ function ApiKeysDialog({ open, onOpenChange, apiBaseUrl }) {
426
+ const [apiKeys, setApiKeys] = (0, react.useState)([]);
427
+ const [isLoading, setIsLoading] = (0, react.useState)(false);
428
+ const [error, setError] = (0, react.useState)(null);
429
+ const [hasFetched, setHasFetched] = (0, react.useState)(false);
430
+ const [showCreateForm, setShowCreateForm] = (0, react.useState)(false);
431
+ const [newlyCreatedKey, setNewlyCreatedKey] = (0, react.useState)(null);
432
+ const [copiedKeyId, setCopiedKeyId] = (0, react.useState)(null);
433
+ const [pendingDeleteId, setPendingDeleteId] = (0, react.useState)(null);
434
+ const authApiBase = `${apiBaseUrl}/plugins/auth`;
435
+ const fetchApiKeys = (0, react.useCallback)(async () => {
436
+ setIsLoading(true);
437
+ setError(null);
438
+ try {
439
+ const res = await fetch(`${authApiBase}/api-keys`, { credentials: "include" });
440
+ if (!res.ok) {
441
+ const data = await res.json().catch(() => ({ error: "Failed to fetch API keys" }));
442
+ throw new Error(data.error || data.message || `HTTP ${res.status}`);
443
+ }
444
+ setApiKeys((await res.json()).apiKeys ?? []);
445
+ setHasFetched(true);
446
+ } catch (err) {
447
+ setError(err instanceof Error ? err.message : "Failed to fetch API keys");
448
+ } finally {
449
+ setIsLoading(false);
450
+ }
451
+ }, [authApiBase]);
452
+ const deleteApiKey = (0, react.useCallback)(async (keyId) => {
453
+ try {
454
+ const res = await fetch(`${authApiBase}/api-keys/${keyId}`, {
455
+ method: "DELETE",
456
+ credentials: "include"
457
+ });
458
+ if (!res.ok) {
459
+ const data = await res.json().catch(() => ({}));
460
+ throw new Error(data.error || `HTTP ${res.status}`);
461
+ }
462
+ setApiKeys((prev) => prev.filter((k) => k.id !== keyId));
463
+ setPendingDeleteId(null);
464
+ } catch (err) {
465
+ setError(err instanceof Error ? err.message : "Failed to delete API key");
466
+ setPendingDeleteId(null);
467
+ }
468
+ }, [authApiBase]);
469
+ const copyToClipboard = (0, react.useCallback)(async (text, id) => {
470
+ try {
471
+ await navigator.clipboard.writeText(text);
472
+ setCopiedKeyId(id);
473
+ setTimeout(() => setCopiedKeyId(null), 2e3);
474
+ } catch {
475
+ const textArea = document.createElement("textarea");
476
+ textArea.value = text;
477
+ textArea.style.position = "fixed";
478
+ textArea.style.opacity = "0";
479
+ document.body.appendChild(textArea);
480
+ textArea.select();
481
+ document.execCommand("copy");
482
+ document.body.removeChild(textArea);
483
+ setCopiedKeyId(id);
484
+ setTimeout(() => setCopiedKeyId(null), 2e3);
485
+ }
486
+ }, []);
487
+ if (open && !hasFetched && !isLoading) fetchApiKeys();
488
+ const handleOpenChange = (nextOpen) => {
489
+ if (!nextOpen) {
490
+ setShowCreateForm(false);
491
+ setNewlyCreatedKey(null);
492
+ setError(null);
493
+ setPendingDeleteId(null);
494
+ setHasFetched(false);
495
+ }
496
+ onOpenChange(nextOpen);
497
+ };
498
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.Dialog, {
499
+ open,
500
+ onOpenChange: handleOpenChange,
501
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_ui.DialogContent, {
502
+ className: "max-w-lg border-imp-border bg-imp-background text-imp-foreground sm:max-w-lg",
503
+ children: [
504
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.DialogHeader, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_ui.DialogTitle, {
505
+ className: "flex items-center gap-2 text-sm font-semibold",
506
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Key, { className: "h-4 w-4" }), "API Keys"]
507
+ }) }),
508
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
509
+ className: "space-y-3",
510
+ children: [
511
+ newlyCreatedKey && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
512
+ className: "rounded-md border border-green-500/30 bg-green-50 p-3 dark:bg-green-950/20",
513
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
514
+ className: "mb-1.5 text-xs font-medium text-green-700 dark:text-green-400",
515
+ children: "API key created! Copy it now — it won't be shown again."
516
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
517
+ className: "flex items-center gap-2",
518
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
519
+ className: "flex-1 rounded bg-green-100 px-2 py-1 text-xs font-mono text-green-800 dark:bg-green-900/30 dark:text-green-300 break-all",
520
+ children: newlyCreatedKey
521
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
522
+ type: "button",
523
+ onClick: () => copyToClipboard(newlyCreatedKey, "new-key"),
524
+ className: "shrink-0 rounded-md border border-green-300 p-1.5 text-green-700 hover:bg-green-100 dark:border-green-700 dark:text-green-400 dark:hover:bg-green-900/40",
525
+ children: copiedKeyId === "new-key" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Check, { className: "h-3.5 w-3.5" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Copy, { className: "h-3.5 w-3.5" })
526
+ })]
527
+ })]
528
+ }),
529
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
530
+ className: "rounded-md bg-red-50 p-2 text-xs text-red-600 dark:bg-red-950/20 dark:text-red-400",
531
+ children: [error, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
532
+ type: "button",
533
+ onClick: () => setError(null),
534
+ className: "ml-2 underline hover:no-underline",
535
+ children: "Dismiss"
536
+ })]
537
+ }),
538
+ !showCreateForm && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
539
+ type: "button",
540
+ onClick: () => setShowCreateForm(true),
541
+ className: "flex items-center gap-1.5 rounded-lg border border-imp-border px-3 py-1.5 text-xs font-medium text-imp-muted-foreground transition-colors hover:border-imp-primary/50 hover:text-imp-foreground",
542
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Plus, { className: "h-3.5 w-3.5" }), " Create API Key"]
543
+ }),
544
+ showCreateForm && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CreateApiKeyForm, {
545
+ apiBaseUrl: authApiBase,
546
+ onCreated: (key, fullKey) => {
547
+ setApiKeys((prev) => [key, ...prev]);
548
+ setNewlyCreatedKey(fullKey);
549
+ setShowCreateForm(false);
550
+ },
551
+ onCancel: () => setShowCreateForm(false)
552
+ }),
553
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
554
+ className: "max-h-[300px] space-y-1.5 overflow-y-auto",
555
+ children: [
556
+ isLoading && !hasFetched && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
557
+ className: "flex items-center justify-center py-8 text-xs text-imp-muted-foreground",
558
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), "Loading…"]
559
+ }),
560
+ hasFetched && apiKeys.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
561
+ className: "py-8 text-center text-xs text-imp-muted-foreground",
562
+ children: "No API keys yet. Create one to get started."
563
+ }),
564
+ apiKeys.map((key) => {
565
+ const expired = isExpired(key.expiresAt);
566
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
567
+ className: `flex items-center justify-between rounded-lg border px-3 py-2.5 ${expired ? "border-red-300/50 bg-red-50/50 dark:border-red-800/30 dark:bg-red-950/10" : "border-imp-border bg-imp-muted/10"}`,
568
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
569
+ className: "min-w-0 flex-1",
570
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
571
+ className: "flex items-center gap-2",
572
+ children: [
573
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
574
+ className: "text-sm font-medium text-imp-foreground truncate",
575
+ children: key.name || "Unnamed key"
576
+ }),
577
+ expired && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
578
+ className: "shrink-0 rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400",
579
+ children: "Expired"
580
+ }),
581
+ key.enabled === false && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
582
+ className: "shrink-0 rounded-full bg-yellow-100 px-1.5 py-0.5 text-[10px] font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
583
+ children: "Disabled"
584
+ })
585
+ ]
586
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
587
+ className: "mt-0.5 flex items-center gap-2 text-[11px] text-imp-muted-foreground",
588
+ children: [
589
+ key.start && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
590
+ className: "font-mono",
591
+ children: [
592
+ key.prefix ? `${key.prefix}_` : "",
593
+ key.start,
594
+ "…"
595
+ ]
596
+ }),
597
+ key.createdAt && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["Created ", formatDate$1(key.createdAt)] }),
598
+ key.expiresAt && !expired && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["Expires ", formatDate$1(key.expiresAt)] })
599
+ ]
600
+ })]
601
+ }), pendingDeleteId === key.id ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
602
+ className: "flex items-center gap-1.5 shrink-0 ml-2",
603
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
604
+ type: "button",
605
+ onClick: () => deleteApiKey(key.id),
606
+ className: "rounded-md bg-red-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-red-700",
607
+ children: "Confirm"
608
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
609
+ type: "button",
610
+ onClick: () => setPendingDeleteId(null),
611
+ className: "rounded-md border border-imp-border px-2 py-1 text-[11px] hover:bg-imp-muted",
612
+ children: "Cancel"
613
+ })]
614
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
615
+ type: "button",
616
+ onClick: () => setPendingDeleteId(key.id),
617
+ className: "shrink-0 ml-2 rounded-md p-1.5 text-imp-muted-foreground transition-colors hover:bg-imp-destructive/10 hover:text-imp-destructive",
618
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Trash2, { className: "h-3.5 w-3.5" })
619
+ })]
620
+ }, key.id);
621
+ })
622
+ ]
623
+ })
624
+ ]
625
+ }),
626
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.DialogFooter, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
627
+ type: "button",
628
+ onClick: () => handleOpenChange(false),
629
+ className: "rounded-md border border-imp-border px-3 py-1.5 text-sm hover:bg-imp-muted",
630
+ children: "Close"
631
+ }) })
632
+ ]
633
+ })
634
+ });
635
+ }
636
+ function CreateApiKeyForm({ apiBaseUrl, onCreated, onCancel }) {
637
+ const [name, setName] = (0, react.useState)("");
638
+ const [expiresIn, setExpiresIn] = (0, react.useState)("");
639
+ const [isSubmitting, setIsSubmitting] = (0, react.useState)(false);
640
+ const [error, setError] = (0, react.useState)(null);
641
+ const EXPIRY_OPTIONS = [
642
+ {
643
+ value: "",
644
+ label: "No expiry"
645
+ },
646
+ {
647
+ value: "86400",
648
+ label: "1 day"
649
+ },
650
+ {
651
+ value: "604800",
652
+ label: "7 days"
653
+ },
654
+ {
655
+ value: "2592000",
656
+ label: "30 days"
657
+ },
658
+ {
659
+ value: "7776000",
660
+ label: "90 days"
661
+ },
662
+ {
663
+ value: "31536000",
664
+ label: "1 year"
665
+ }
666
+ ];
667
+ const handleSubmit = async (e) => {
668
+ e.preventDefault();
669
+ setError(null);
670
+ setIsSubmitting(true);
671
+ try {
672
+ const body = {};
673
+ if (name.trim()) body.name = name.trim();
674
+ if (expiresIn) body.expiresIn = parseInt(expiresIn, 10);
675
+ const res = await fetch(`${apiBaseUrl}/api-keys`, {
676
+ method: "POST",
677
+ credentials: "include",
678
+ headers: { "content-type": "application/json" },
679
+ body: JSON.stringify(body)
680
+ });
681
+ const data = await res.json();
682
+ if (!res.ok) throw new Error(data.error || data.message || `HTTP ${res.status}`);
683
+ const fullKey = data.key ?? data.apiKey?.key ?? "";
684
+ onCreated({
685
+ id: data.id ?? data.apiKey?.id ?? "",
686
+ name: data.name ?? data.apiKey?.name ?? (name.trim() || null),
687
+ start: data.start ?? data.apiKey?.start ?? null,
688
+ prefix: data.prefix ?? data.apiKey?.prefix ?? null,
689
+ enabled: data.enabled ?? data.apiKey?.enabled ?? true,
690
+ expiresAt: data.expiresAt ?? data.apiKey?.expiresAt ?? null,
691
+ createdAt: data.createdAt ?? data.apiKey?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
692
+ }, fullKey);
693
+ } catch (err) {
694
+ setError(err instanceof Error ? err.message : "Failed to create API key");
695
+ } finally {
696
+ setIsSubmitting(false);
697
+ }
698
+ };
699
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
700
+ onSubmit: handleSubmit,
701
+ className: "space-y-3 rounded-lg border border-imp-border bg-imp-muted/10 p-3",
702
+ children: [
703
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
704
+ className: "text-xs font-medium text-imp-foreground",
705
+ children: "Create API Key"
706
+ }),
707
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
708
+ className: "grid grid-cols-2 gap-3",
709
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
710
+ className: "mb-1 block text-xs font-medium text-imp-foreground",
711
+ children: "Name"
712
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
713
+ type: "text",
714
+ value: name,
715
+ onChange: (e) => setName(e.target.value),
716
+ placeholder: "e.g. Production API",
717
+ className: "w-full rounded-md border border-imp-border bg-imp-background px-3 py-1.5 text-sm placeholder:text-imp-muted-foreground focus:border-imp-primary/50 focus:outline-none"
718
+ })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
719
+ className: "mb-1 block text-xs font-medium text-imp-foreground",
720
+ children: "Expiry"
721
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
722
+ value: expiresIn,
723
+ onChange: (e) => setExpiresIn(e.target.value),
724
+ className: "w-full rounded-md border border-imp-border bg-imp-background px-3 py-1.5 text-sm focus:border-imp-primary/50 focus:outline-none",
725
+ children: EXPIRY_OPTIONS.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
726
+ value: option.value,
727
+ children: option.label
728
+ }, option.value))
729
+ })] })]
730
+ }),
731
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
732
+ className: "rounded-md bg-red-50 p-2 text-xs text-red-600 dark:bg-red-950/20 dark:text-red-400",
733
+ children: error
734
+ }),
735
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
736
+ className: "flex justify-end gap-2",
737
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
738
+ type: "button",
739
+ onClick: onCancel,
740
+ className: "rounded-md border border-imp-border px-3 py-1.5 text-xs hover:bg-imp-muted",
741
+ children: "Cancel"
742
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
743
+ type: "submit",
744
+ disabled: isSubmitting,
745
+ className: "rounded-md bg-imp-primary px-3 py-1.5 text-xs font-semibold text-imp-primary-foreground hover:bg-imp-primary/90 disabled:opacity-50",
746
+ children: isSubmitting ? "Creating…" : "Create"
747
+ })]
748
+ })
749
+ ]
750
+ });
751
+ }
752
+ //#endregion
404
753
  //#region src/frontend/components/UserManagement.tsx
405
754
  /**
406
755
  * UserManagement — Admin panel for managing users.
@@ -473,7 +822,7 @@ function SortHeader({ label, field, sortField, sortDir, onSort, align = "left" }
473
822
  function RoleDropdown({ value, userId, disabled, onChange }) {
474
823
  const current = require_roles.isAuthAssignableRole(value) ? value : require_roles.AUTH_DEFAULT_ROLE;
475
824
  const currentLabel = ASSIGNABLE_ROLE_OPTIONS.find((o) => o.value === current)?.label ?? current;
476
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DropdownMenu, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuTrigger, {
825
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_ui.DropdownMenu, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.DropdownMenuTrigger, {
477
826
  asChild: true,
478
827
  disabled,
479
828
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -481,16 +830,16 @@ function RoleDropdown({ value, userId, disabled, onChange }) {
481
830
  className: `inline-flex w-28 items-center justify-between gap-1.5 rounded-full border px-2.5 py-0.5 text-sm font-medium transition-colors hover:bg-imp-muted disabled:cursor-not-allowed disabled:opacity-50 ${ROLE_BADGE_CLASSES}`,
482
831
  children: [currentLabel, !disabled && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ChevronDown, { className: "w-3 h-3 shrink-0" })]
483
832
  })
484
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DropdownMenuContent, {
833
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_ui.DropdownMenuContent, {
485
834
  align: "start",
486
835
  className: "w-56",
487
836
  children: [
488
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuLabel, {
837
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.DropdownMenuLabel, {
489
838
  className: "text-xs",
490
839
  children: "Set role"
491
840
  }),
492
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuSeparator, {}),
493
- ASSIGNABLE_ROLE_OPTIONS.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuItem, {
841
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.DropdownMenuSeparator, {}),
842
+ ASSIGNABLE_ROLE_OPTIONS.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.DropdownMenuItem, {
494
843
  onSelect: () => onChange(userId, option.value),
495
844
  className: `items-start gap-0 px-2 py-2 ${current === option.value ? "bg-accent text-accent-foreground" : ""}`,
496
845
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -519,6 +868,9 @@ function UserManagement({ apiBaseUrl, className }) {
519
868
  const [currentPage, setCurrentPage] = (0, react.useState)(1);
520
869
  const [sortField, setSortField] = (0, react.useState)("name");
521
870
  const [sortDir, setSortDir] = (0, react.useState)("asc");
871
+ const [apiKeysEnabled, setApiKeysEnabled] = (0, react.useState)(false);
872
+ const [showApiKeysDialog, setShowApiKeysDialog] = (0, react.useState)(false);
873
+ const [hasCheckedApiKeys, setHasCheckedApiKeys] = (0, react.useState)(false);
522
874
  const PAGE_SIZE = 10;
523
875
  const handleSort = (0, react.useCallback)((field) => {
524
876
  setSortField((prev) => {
@@ -562,6 +914,14 @@ function UserManagement({ apiBaseUrl, className }) {
562
914
  const totalPages = Math.max(1, Math.ceil(filteredUsers.length / PAGE_SIZE));
563
915
  const paginatedUsers = filteredUsers.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
564
916
  const authApiBase = `${apiBaseUrl}/plugins/auth`;
917
+ const checkApiKeys = (0, react.useCallback)(async () => {
918
+ try {
919
+ const res = await fetch(`${authApiBase}/info`, { credentials: "include" });
920
+ if (res.ok) setApiKeysEnabled(!!(await res.json()).apiKeysEnabled);
921
+ } catch {} finally {
922
+ setHasCheckedApiKeys(true);
923
+ }
924
+ }, [authApiBase]);
565
925
  const fetchUsers = (0, react.useCallback)(async () => {
566
926
  setIsLoading(true);
567
927
  setError(null);
@@ -618,29 +978,39 @@ function UserManagement({ apiBaseUrl, className }) {
618
978
  }, [authApiBase]);
619
979
  if (!isAuthenticated || user?.role !== "admin") return null;
620
980
  if (!hasFetched && !isLoading) fetchUsers();
981
+ if (!hasCheckedApiKeys) checkApiKeys();
621
982
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
622
983
  className: `space-y-4 ${className ?? ""}`,
623
984
  children: [
624
985
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
625
986
  className: "flex items-center gap-2",
626
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
627
- className: "relative flex-1 max-w-sm",
628
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Search, { className: "absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 pointer-events-none text-imp-muted-foreground" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
629
- type: "text",
630
- value: searchQuery,
631
- onChange: (e) => {
632
- setSearchQuery(e.target.value);
633
- setCurrentPage(1);
634
- },
635
- placeholder: "Search users…",
636
- className: "w-full py-2 pr-3 text-sm border rounded-lg outline-none border-imp-border bg-transparent pl-9 placeholder:text-imp-muted-foreground focus:border-imp-primary/50"
637
- })]
638
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
639
- type: "button",
640
- onClick: () => setShowCreateDialog(true),
641
- className: "flex items-center gap-1.5 rounded-lg border border-imp-border px-3 py-2 text-sm font-medium text-imp-muted-foreground transition-colors hover:border-imp-primary/50 hover:text-imp-foreground",
642
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.UserPlus, { className: "w-4 h-4" }), " Create User"]
643
- })]
987
+ children: [
988
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
989
+ className: "relative flex-1 max-w-sm",
990
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Search, { className: "absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 pointer-events-none text-imp-muted-foreground" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
991
+ type: "text",
992
+ value: searchQuery,
993
+ onChange: (e) => {
994
+ setSearchQuery(e.target.value);
995
+ setCurrentPage(1);
996
+ },
997
+ placeholder: "Search users…",
998
+ className: "w-full py-2 pr-3 text-sm border rounded-lg outline-none border-imp-border bg-transparent pl-9 placeholder:text-imp-muted-foreground focus:border-imp-primary/50"
999
+ })]
1000
+ }),
1001
+ apiKeysEnabled && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1002
+ type: "button",
1003
+ onClick: () => setShowApiKeysDialog(true),
1004
+ className: "flex items-center gap-1.5 rounded-lg border border-imp-border px-3 py-2 text-sm font-medium text-imp-muted-foreground transition-colors hover:border-imp-primary/50 hover:text-imp-foreground",
1005
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Key, { className: "w-4 h-4" }), " API Keys"]
1006
+ }),
1007
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1008
+ type: "button",
1009
+ onClick: () => setShowCreateDialog(true),
1010
+ className: "flex items-center gap-1.5 rounded-lg border border-imp-border px-3 py-2 text-sm font-medium text-imp-muted-foreground transition-colors hover:border-imp-primary/50 hover:text-imp-foreground",
1011
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.UserPlus, { className: "w-4 h-4" }), " Create User"]
1012
+ })
1013
+ ]
644
1014
  }),
645
1015
  error && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
646
1016
  className: "p-3 text-sm text-red-600 rounded-md bg-red-50 dark:bg-red-950/20 dark:text-red-400",
@@ -742,7 +1112,7 @@ function UserManagement({ apiBaseUrl, className }) {
742
1112
  }) : u.id !== user?.id ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
743
1113
  type: "button",
744
1114
  onClick: () => setPendingDeleteUser(u),
745
- className: "rounded-md p-1.5 text-imp-muted-foreground opacity-0 transition-opacity hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-950/20 dark:hover:text-red-400",
1115
+ className: "rounded-md p-1.5 text-imp-muted-foreground opacity-0 transition-opacity hover:bg-imp-destructive/10 hover:text-imp-destructive group-hover:opacity-100",
746
1116
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Trash2, { className: "w-4 h-4" })
747
1117
  }) : null
748
1118
  })
@@ -790,12 +1160,17 @@ function UserManagement({ apiBaseUrl, className }) {
790
1160
  ]
791
1161
  })]
792
1162
  }),
793
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.Dialog, {
1163
+ apiKeysEnabled && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ApiKeysDialog, {
1164
+ open: showApiKeysDialog,
1165
+ onOpenChange: setShowApiKeysDialog,
1166
+ apiBaseUrl
1167
+ }),
1168
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.Dialog, {
794
1169
  open: showCreateDialog,
795
1170
  onOpenChange: (open) => !open && setShowCreateDialog(false),
796
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DialogContent, {
1171
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_ui.DialogContent, {
797
1172
  className: "max-w-md border-imp-border bg-imp-background text-imp-foreground sm:max-w-md",
798
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DialogHeader, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DialogTitle, {
1173
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.DialogHeader, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.DialogTitle, {
799
1174
  className: "text-sm font-semibold",
800
1175
  children: "Create New User"
801
1176
  }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CreateUserForm, {
@@ -808,13 +1183,13 @@ function UserManagement({ apiBaseUrl, className }) {
808
1183
  })]
809
1184
  })
810
1185
  }),
811
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.Dialog, {
1186
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.Dialog, {
812
1187
  open: pendingDeleteUser !== null,
813
1188
  onOpenChange: (open) => !open && setPendingDeleteUser(null),
814
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DialogContent, {
1189
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_ui.DialogContent, {
815
1190
  className: "max-w-sm border-imp-border bg-imp-background text-imp-foreground sm:max-w-sm",
816
1191
  children: [
817
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DialogHeader, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DialogTitle, {
1192
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.DialogHeader, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.DialogTitle, {
818
1193
  className: "text-sm font-semibold",
819
1194
  children: "Delete user"
820
1195
  }) }),
@@ -830,7 +1205,7 @@ function UserManagement({ apiBaseUrl, className }) {
830
1205
  "? This action cannot be undone."
831
1206
  ]
832
1207
  }),
833
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DialogFooter, {
1208
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_ui.DialogFooter, {
834
1209
  className: "gap-2",
835
1210
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
836
1211
  type: "button",
@@ -1032,7 +1407,7 @@ function LoadingSpinner() {
1032
1407
  * plugin route contribution at '/users'.
1033
1408
  */
1034
1409
  function UserManagementPage() {
1035
- const api = (0, _invect_frontend.useApiClient)();
1410
+ const api = (0, _invect_ui.useApiClient)();
1036
1411
  const { user, isAuthenticated } = useAuth();
1037
1412
  const apiBaseUrl = api.getBaseURL();
1038
1413
  if (!isAuthenticated) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -1042,7 +1417,7 @@ function UserManagementPage() {
1042
1417
  children: "Please sign in to access this page."
1043
1418
  })
1044
1419
  });
1045
- if (user?.role !== "admin") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.PageLayout, {
1420
+ if (user?.role !== "admin") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.PageLayout, {
1046
1421
  title: "User Management",
1047
1422
  subtitle: "Manage users for your Invect instance.",
1048
1423
  icon: lucide_react.Users,
@@ -1051,7 +1426,7 @@ function UserManagementPage() {
1051
1426
  children: "Only administrators can manage users. Contact an admin for access."
1052
1427
  })
1053
1428
  });
1054
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.PageLayout, {
1429
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.PageLayout, {
1055
1430
  title: "User Management",
1056
1431
  subtitle: "Create, manage, and remove users for your Invect instance.",
1057
1432
  icon: lucide_react.Users,
@@ -1083,7 +1458,7 @@ function ProfilePage({ basePath }) {
1083
1458
  });
1084
1459
  const displayName = user.name ?? user.email ?? user.id;
1085
1460
  const initials = displayName[0]?.toUpperCase() ?? "?";
1086
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.PageLayout, {
1461
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_ui.PageLayout, {
1087
1462
  title: "Profile",
1088
1463
  subtitle: "View your account details and manage your current session.",
1089
1464
  icon: lucide_react.User,
@@ -1226,7 +1601,7 @@ function SidebarUserMenu({ collapsed = false, basePath = "" }) {
1226
1601
  * AuthenticatedInvect already wraps the tree with it. This plugin
1227
1602
  * only adds the user management UI.
1228
1603
  */
1229
- const authFrontendPlugin = {
1604
+ const authFrontend = {
1230
1605
  id: "user-auth",
1231
1606
  name: "User Authentication",
1232
1607
  sidebar: [{
@@ -1246,6 +1621,7 @@ const authFrontendPlugin = {
1246
1621
  }]
1247
1622
  };
1248
1623
  //#endregion
1624
+ exports.ApiKeysDialog = ApiKeysDialog;
1249
1625
  exports.AuthGate = AuthGate;
1250
1626
  exports.AuthProvider = AuthProvider;
1251
1627
  exports.AuthenticatedInvect = AuthenticatedInvect;
@@ -1256,7 +1632,7 @@ exports.SignInPage = SignInPage;
1256
1632
  exports.UserButton = UserButton;
1257
1633
  exports.UserManagement = UserManagement;
1258
1634
  exports.UserManagementPage = UserManagementPage;
1259
- exports.authFrontendPlugin = authFrontendPlugin;
1635
+ exports.authFrontend = authFrontend;
1260
1636
  exports.useAuth = useAuth;
1261
1637
 
1262
1638
  //# sourceMappingURL=index.cjs.map