@invect/user-auth 0.0.1

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/backend/index.cjs +1166 -0
  4. package/dist/backend/index.cjs.map +1 -0
  5. package/dist/backend/index.d.ts +42 -0
  6. package/dist/backend/index.d.ts.map +1 -0
  7. package/dist/backend/index.mjs +1164 -0
  8. package/dist/backend/index.mjs.map +1 -0
  9. package/dist/backend/plugin.d.ts +62 -0
  10. package/dist/backend/plugin.d.ts.map +1 -0
  11. package/dist/backend/types.d.ts +299 -0
  12. package/dist/backend/types.d.ts.map +1 -0
  13. package/dist/frontend/components/AuthGate.d.ts +17 -0
  14. package/dist/frontend/components/AuthGate.d.ts.map +1 -0
  15. package/dist/frontend/components/AuthenticatedInvect.d.ts +129 -0
  16. package/dist/frontend/components/AuthenticatedInvect.d.ts.map +1 -0
  17. package/dist/frontend/components/ProfilePage.d.ts +10 -0
  18. package/dist/frontend/components/ProfilePage.d.ts.map +1 -0
  19. package/dist/frontend/components/SidebarUserMenu.d.ts +12 -0
  20. package/dist/frontend/components/SidebarUserMenu.d.ts.map +1 -0
  21. package/dist/frontend/components/SignInForm.d.ts +14 -0
  22. package/dist/frontend/components/SignInForm.d.ts.map +1 -0
  23. package/dist/frontend/components/SignInPage.d.ts +19 -0
  24. package/dist/frontend/components/SignInPage.d.ts.map +1 -0
  25. package/dist/frontend/components/UserButton.d.ts +15 -0
  26. package/dist/frontend/components/UserButton.d.ts.map +1 -0
  27. package/dist/frontend/components/UserManagement.d.ts +19 -0
  28. package/dist/frontend/components/UserManagement.d.ts.map +1 -0
  29. package/dist/frontend/components/UserManagementPage.d.ts +9 -0
  30. package/dist/frontend/components/UserManagementPage.d.ts.map +1 -0
  31. package/dist/frontend/index.cjs +1262 -0
  32. package/dist/frontend/index.cjs.map +1 -0
  33. package/dist/frontend/index.d.ts +29 -0
  34. package/dist/frontend/index.d.ts.map +1 -0
  35. package/dist/frontend/index.mjs +1250 -0
  36. package/dist/frontend/index.mjs.map +1 -0
  37. package/dist/frontend/plugins/authFrontendPlugin.d.ts +14 -0
  38. package/dist/frontend/plugins/authFrontendPlugin.d.ts.map +1 -0
  39. package/dist/frontend/providers/AuthProvider.d.ts +46 -0
  40. package/dist/frontend/providers/AuthProvider.d.ts.map +1 -0
  41. package/dist/roles-BOY5N82v.cjs +74 -0
  42. package/dist/roles-BOY5N82v.cjs.map +1 -0
  43. package/dist/roles-CZuKFEpJ.mjs +33 -0
  44. package/dist/roles-CZuKFEpJ.mjs.map +1 -0
  45. package/dist/shared/roles.d.ts +11 -0
  46. package/dist/shared/roles.d.ts.map +1 -0
  47. package/dist/shared/types.cjs +0 -0
  48. package/dist/shared/types.d.ts +46 -0
  49. package/dist/shared/types.d.ts.map +1 -0
  50. package/dist/shared/types.mjs +1 -0
  51. package/package.json +116 -0
@@ -0,0 +1,1250 @@
1
+ import { a as formatAuthRoleLabel, o as isAuthAssignableRole, r as AUTH_DEFAULT_ROLE } from "../roles-CZuKFEpJ.mjs";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
3
+ import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
+ import { ArrowDown, ArrowUp, ChevronDown, ChevronsUpDown, LogOut, Mail, Search, Shield, Trash2, User, UserPlus, Users } from "lucide-react";
6
+ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, PageLayout, useApiClient } from "@invect/frontend";
7
+ import { Link, useLocation } from "react-router";
8
+ //#region src/frontend/providers/AuthProvider.tsx
9
+ /**
10
+ * AuthProvider — Context provider for authentication state.
11
+ *
12
+ * Fetches the current session from the better-auth proxy endpoints
13
+ * and caches it via React Query. Provides sign-in, sign-up, and
14
+ * sign-out actions to child components.
15
+ */
16
+ const AuthContext = createContext(null);
17
+ function AuthProvider({ children, baseUrl }) {
18
+ const queryClient = useQueryClient();
19
+ const authApiBase = `${baseUrl}/plugins/auth/api/auth`;
20
+ const { data: session, isLoading } = useQuery({
21
+ queryKey: ["auth", "session"],
22
+ queryFn: async () => {
23
+ const response = await fetch(`${authApiBase}/get-session`, { credentials: "include" });
24
+ if (!response.ok) return {
25
+ user: null,
26
+ isAuthenticated: false
27
+ };
28
+ const data = await response.json();
29
+ if (!data?.user) return {
30
+ user: null,
31
+ isAuthenticated: false
32
+ };
33
+ return {
34
+ user: {
35
+ id: data.user.id,
36
+ name: data.user.name ?? void 0,
37
+ email: data.user.email ?? void 0,
38
+ image: data.user.image ?? void 0,
39
+ role: data.user.role ?? void 0
40
+ },
41
+ isAuthenticated: true
42
+ };
43
+ },
44
+ staleTime: 300 * 1e3,
45
+ retry: 1
46
+ });
47
+ const signInMutation = useMutation({
48
+ mutationFn: async (credentials) => {
49
+ const response = await fetch(`${authApiBase}/sign-in/email`, {
50
+ method: "POST",
51
+ headers: { "content-type": "application/json" },
52
+ credentials: "include",
53
+ body: JSON.stringify(credentials)
54
+ });
55
+ if (!response.ok) {
56
+ const err = await response.json().catch(() => ({ message: "Sign in failed" }));
57
+ throw new Error(err.message || `Sign in failed (${response.status})`);
58
+ }
59
+ return response.json();
60
+ },
61
+ onSuccess: () => {
62
+ queryClient.invalidateQueries({ queryKey: ["auth", "session"] });
63
+ }
64
+ });
65
+ const signUpMutation = useMutation({
66
+ mutationFn: async (credentials) => {
67
+ const response = await fetch(`${authApiBase}/sign-up/email`, {
68
+ method: "POST",
69
+ headers: { "content-type": "application/json" },
70
+ credentials: "include",
71
+ body: JSON.stringify(credentials)
72
+ });
73
+ if (!response.ok) {
74
+ const err = await response.json().catch(() => ({ message: "Sign up failed" }));
75
+ throw new Error(err.message || `Sign up failed (${response.status})`);
76
+ }
77
+ return response.json();
78
+ },
79
+ onSuccess: () => {
80
+ queryClient.invalidateQueries({ queryKey: ["auth", "session"] });
81
+ }
82
+ });
83
+ const signOutMutation = useMutation({
84
+ mutationFn: async () => {
85
+ const response = await fetch(`${authApiBase}/sign-out`, {
86
+ method: "POST",
87
+ headers: { "content-type": "application/json" },
88
+ credentials: "include",
89
+ body: JSON.stringify({})
90
+ });
91
+ if (!response.ok) {
92
+ const err = await response.json().catch(async () => ({ message: await response.text().catch(() => "") || "Sign out failed" }));
93
+ throw new Error(err.message || `Sign out failed (${response.status})`);
94
+ }
95
+ },
96
+ onSuccess: () => {
97
+ queryClient.invalidateQueries({ queryKey: ["auth", "session"] });
98
+ }
99
+ });
100
+ const signIn = useCallback(async (credentials) => {
101
+ await signInMutation.mutateAsync(credentials);
102
+ }, [signInMutation]);
103
+ const signUp = useCallback(async (credentials) => {
104
+ await signUpMutation.mutateAsync(credentials);
105
+ }, [signUpMutation]);
106
+ const signOut = useCallback(async () => {
107
+ await signOutMutation.mutateAsync();
108
+ }, [signOutMutation]);
109
+ const error = signInMutation.error?.message ?? signUpMutation.error?.message ?? signOutMutation.error?.message ?? null;
110
+ const value = useMemo(() => ({
111
+ user: session?.isAuthenticated ? session.user : null,
112
+ isAuthenticated: session?.isAuthenticated ?? false,
113
+ isLoading,
114
+ signIn,
115
+ signUp,
116
+ signOut,
117
+ isSigningIn: signInMutation.isPending,
118
+ isSigningUp: signUpMutation.isPending,
119
+ error
120
+ }), [
121
+ session,
122
+ isLoading,
123
+ signIn,
124
+ signUp,
125
+ signOut,
126
+ signInMutation.isPending,
127
+ signUpMutation.isPending,
128
+ error
129
+ ]);
130
+ return /* @__PURE__ */ jsx(AuthContext.Provider, {
131
+ value,
132
+ children
133
+ });
134
+ }
135
+ /**
136
+ * Access auth context — current user, sign-in/sign-up/sign-out actions.
137
+ *
138
+ * Must be used within an `<AuthProvider>`.
139
+ * Returns a safe fallback (unauthenticated) if provider is missing.
140
+ */
141
+ function useAuth() {
142
+ const ctx = useContext(AuthContext);
143
+ if (!ctx) return {
144
+ user: null,
145
+ isAuthenticated: false,
146
+ isLoading: false,
147
+ signIn: async () => {
148
+ throw new Error("AuthProvider not found");
149
+ },
150
+ signUp: async () => {
151
+ throw new Error("AuthProvider not found");
152
+ },
153
+ signOut: async () => {
154
+ throw new Error("AuthProvider not found");
155
+ },
156
+ isSigningIn: false,
157
+ isSigningUp: false,
158
+ error: null
159
+ };
160
+ return ctx;
161
+ }
162
+ //#endregion
163
+ //#region src/frontend/components/SignInForm.tsx
164
+ /**
165
+ * SignInForm — Email/password sign-in form component.
166
+ *
167
+ * Uses the AuthProvider's signIn action. Styled to match the Invect
168
+ * design system with grouped fields, clean labels, and themed inputs.
169
+ */
170
+ function SignInForm({ onSuccess, className }) {
171
+ const { signIn, isSigningIn, error } = useAuth();
172
+ const [email, setEmail] = useState("");
173
+ const [password, setPassword] = useState("");
174
+ const [localError, setLocalError] = useState(null);
175
+ const handleSubmit = async (e) => {
176
+ e.preventDefault();
177
+ setLocalError(null);
178
+ if (!email.trim() || !password.trim()) {
179
+ setLocalError("Email and password are required");
180
+ return;
181
+ }
182
+ try {
183
+ await signIn({
184
+ email,
185
+ password
186
+ });
187
+ onSuccess?.();
188
+ } catch (err) {
189
+ setLocalError(err instanceof Error ? err.message : "Sign in failed");
190
+ }
191
+ };
192
+ const displayError = localError ?? error;
193
+ return /* @__PURE__ */ jsx("form", {
194
+ onSubmit: handleSubmit,
195
+ className,
196
+ children: /* @__PURE__ */ jsxs("div", {
197
+ className: "flex flex-col gap-6",
198
+ children: [
199
+ /* @__PURE__ */ jsxs("div", {
200
+ className: "grid gap-2",
201
+ children: [/* @__PURE__ */ jsx("label", {
202
+ htmlFor: "auth-signin-email",
203
+ className: "text-sm font-medium leading-none",
204
+ children: "Email"
205
+ }), /* @__PURE__ */ jsx("input", {
206
+ id: "auth-signin-email",
207
+ type: "email",
208
+ value: email,
209
+ onChange: (e) => setEmail(e.target.value),
210
+ placeholder: "you@example.com",
211
+ autoComplete: "email",
212
+ required: true,
213
+ className: "flex h-9 w-full rounded-md border border-imp-border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-imp-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-imp-ring disabled:cursor-not-allowed disabled:opacity-50"
214
+ })]
215
+ }),
216
+ /* @__PURE__ */ jsxs("div", {
217
+ className: "grid gap-2",
218
+ children: [/* @__PURE__ */ jsx("label", {
219
+ htmlFor: "auth-signin-password",
220
+ className: "text-sm font-medium leading-none",
221
+ children: "Password"
222
+ }), /* @__PURE__ */ jsx("input", {
223
+ id: "auth-signin-password",
224
+ type: "password",
225
+ value: password,
226
+ onChange: (e) => setPassword(e.target.value),
227
+ placeholder: "••••••••",
228
+ autoComplete: "current-password",
229
+ required: true,
230
+ className: "flex h-9 w-full rounded-md border border-imp-border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-imp-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-imp-ring disabled:cursor-not-allowed disabled:opacity-50"
231
+ })]
232
+ }),
233
+ displayError && /* @__PURE__ */ jsx("div", {
234
+ 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
+ children: displayError
236
+ }),
237
+ /* @__PURE__ */ jsx("button", {
238
+ type: "submit",
239
+ disabled: isSigningIn,
240
+ 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",
241
+ children: isSigningIn ? "Signing in…" : "Login"
242
+ })
243
+ ]
244
+ })
245
+ });
246
+ }
247
+ //#endregion
248
+ //#region src/frontend/components/SignInPage.tsx
249
+ /**
250
+ * SignInPage — Full-page sign-in component with layout.
251
+ *
252
+ * Renders the SignInForm centered on the page with a logo, title,
253
+ * and grouped fields matching the Invect design system.
254
+ * Sign-up is not offered — new users are created by admins.
255
+ */
256
+ function SignInPage({ onSuccess, onNavigateToSignUp, title = "Welcome back", subtitle = "Sign in to your account to continue" }) {
257
+ return /* @__PURE__ */ jsx("div", {
258
+ className: "flex min-h-screen items-center justify-center bg-imp-background p-4 text-imp-foreground",
259
+ children: /* @__PURE__ */ jsxs("div", {
260
+ className: "flex w-full max-w-sm flex-col gap-6",
261
+ children: [
262
+ /* @__PURE__ */ jsxs("div", {
263
+ className: "flex flex-col items-center gap-2 text-center",
264
+ children: [
265
+ /* @__PURE__ */ jsx("div", {
266
+ className: "flex items-center justify-center",
267
+ children: /* @__PURE__ */ jsx(ImpLogo, { className: "h-10 w-auto" })
268
+ }),
269
+ /* @__PURE__ */ jsx("h1", {
270
+ className: "text-xl font-bold",
271
+ children: title
272
+ }),
273
+ /* @__PURE__ */ jsx("p", {
274
+ className: "text-sm text-imp-muted-foreground",
275
+ children: subtitle
276
+ })
277
+ ]
278
+ }),
279
+ /* @__PURE__ */ jsx(SignInForm, { onSuccess }),
280
+ /* @__PURE__ */ jsxs("div", {
281
+ className: "relative text-center text-sm",
282
+ children: [/* @__PURE__ */ jsx("div", { className: "absolute inset-0 top-1/2 border-t border-imp-border" }), /* @__PURE__ */ jsx("span", {
283
+ className: "relative bg-imp-background px-2 text-imp-muted-foreground",
284
+ children: "Admin-managed accounts"
285
+ })]
286
+ }),
287
+ /* @__PURE__ */ jsxs("p", {
288
+ className: "px-6 text-center text-xs text-imp-muted-foreground",
289
+ children: [
290
+ "New accounts are created by your administrator.",
291
+ onNavigateToSignUp ? " Or " : " Contact your admin if you need access.",
292
+ onNavigateToSignUp && /* @__PURE__ */ jsx("button", {
293
+ type: "button",
294
+ onClick: onNavigateToSignUp,
295
+ className: "font-medium underline underline-offset-4 hover:text-imp-foreground",
296
+ children: "Sign up"
297
+ })
298
+ ]
299
+ })
300
+ ]
301
+ })
302
+ });
303
+ }
304
+ function ImpLogo({ className }) {
305
+ return /* @__PURE__ */ jsxs("svg", {
306
+ xmlns: "http://www.w3.org/2000/svg",
307
+ viewBox: "0 0 109 209",
308
+ fill: "none",
309
+ stroke: "currentColor",
310
+ strokeWidth: "9",
311
+ strokeLinecap: "round",
312
+ strokeLinejoin: "round",
313
+ className,
314
+ children: [
315
+ /* @__PURE__ */ jsx("path", { d: "M31.7735 104.5L4.50073 18.7859L54.5007 33.0716L31.7735 104.5Z" }),
316
+ /* @__PURE__ */ jsx("path", { d: "M54.5007 4.5L104.501 18.7857L54.5007 33.0714L4.50073 18.7857L54.5007 4.5Z" }),
317
+ /* @__PURE__ */ jsx("path", { d: "M4.50073 190.214L54.5007 33.0716L104.501 18.7859L4.50073 190.214Z" }),
318
+ /* @__PURE__ */ jsx("path", { d: "M54.5007 204.5L81.4238 104.5L104.501 18.7859L4.50073 190.214L54.5007 204.5Z" }),
319
+ /* @__PURE__ */ jsx("path", { d: "M54.5007 204.5L81.4238 104.5L104.501 190.214L54.5007 204.5Z" })
320
+ ]
321
+ });
322
+ }
323
+ //#endregion
324
+ //#region src/frontend/components/UserButton.tsx
325
+ /**
326
+ * UserButton — Compact user avatar + dropdown for the authenticated user.
327
+ *
328
+ * Shows the user's avatar/initials when signed in, with a dropdown
329
+ * containing their name, email, and sign-out button.
330
+ * Shows a "Sign In" button when not authenticated.
331
+ */
332
+ function UserButton({ onSignInClick, className }) {
333
+ const { user, isAuthenticated, isLoading, signOut } = useAuth();
334
+ const [isOpen, setIsOpen] = useState(false);
335
+ const ref = useRef(null);
336
+ useEffect(() => {
337
+ const handleClick = (e) => {
338
+ if (ref.current && !ref.current.contains(e.target)) setIsOpen(false);
339
+ };
340
+ if (isOpen) document.addEventListener("mousedown", handleClick);
341
+ return () => document.removeEventListener("mousedown", handleClick);
342
+ }, [isOpen]);
343
+ if (isLoading) return /* @__PURE__ */ jsx("div", { className: `h-8 w-8 animate-pulse rounded-full bg-imp-muted ${className ?? ""}` });
344
+ if (!isAuthenticated || !user) return /* @__PURE__ */ jsxs("button", {
345
+ onClick: onSignInClick,
346
+ className: `inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-imp-foreground hover:bg-imp-muted ${className ?? ""}`,
347
+ children: [/* @__PURE__ */ jsx(User, { className: "h-4 w-4" }), "Sign In"]
348
+ });
349
+ const initials = (user.name ?? user.email ?? user.id)[0]?.toUpperCase() ?? "?";
350
+ return /* @__PURE__ */ jsxs("div", {
351
+ ref,
352
+ className: `relative ${className ?? ""}`,
353
+ children: [/* @__PURE__ */ jsx("button", {
354
+ onClick: () => setIsOpen(!isOpen),
355
+ className: "flex h-8 w-8 items-center justify-center rounded-full bg-imp-primary/10 text-sm font-medium text-imp-primary hover:bg-imp-primary/20 transition-colors",
356
+ title: user.name ?? user.email ?? user.id,
357
+ children: user.image ? /* @__PURE__ */ jsx("img", {
358
+ src: user.image,
359
+ alt: user.name ?? "",
360
+ className: "h-8 w-8 rounded-full object-cover"
361
+ }) : initials
362
+ }), isOpen && /* @__PURE__ */ jsxs("div", {
363
+ className: "absolute right-0 top-full z-50 mt-2 w-56 rounded-lg border border-imp-border bg-imp-background shadow-lg",
364
+ children: [/* @__PURE__ */ jsxs("div", {
365
+ className: "border-b border-imp-border px-4 py-3",
366
+ children: [
367
+ /* @__PURE__ */ jsx("p", {
368
+ className: "truncate text-sm font-medium",
369
+ children: user.name ?? "User"
370
+ }),
371
+ user.email && /* @__PURE__ */ jsx("p", {
372
+ className: "truncate text-xs text-imp-muted-foreground",
373
+ children: user.email
374
+ }),
375
+ user.role && /* @__PURE__ */ jsx("span", {
376
+ className: "mt-1 inline-block rounded-full bg-imp-muted px-2 py-0.5 text-xs font-medium",
377
+ children: formatAuthRoleLabel(user.role)
378
+ })
379
+ ]
380
+ }), /* @__PURE__ */ jsx("div", {
381
+ className: "p-1",
382
+ children: /* @__PURE__ */ jsxs("button", {
383
+ onClick: async () => {
384
+ setIsOpen(false);
385
+ await signOut();
386
+ },
387
+ className: "flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-imp-foreground hover:bg-imp-muted",
388
+ children: [/* @__PURE__ */ jsx(LogOut, { className: "h-4 w-4" }), "Sign Out"]
389
+ })
390
+ })]
391
+ })]
392
+ });
393
+ }
394
+ //#endregion
395
+ //#region src/frontend/components/AuthGate.tsx
396
+ function AuthGate({ children, fallback = null, loading = null }) {
397
+ const { isAuthenticated, isLoading } = useAuth();
398
+ if (isLoading) return /* @__PURE__ */ jsx(Fragment, { children: loading });
399
+ if (!isAuthenticated) return /* @__PURE__ */ jsx(Fragment, { children: fallback });
400
+ return /* @__PURE__ */ jsx(Fragment, { children });
401
+ }
402
+ //#endregion
403
+ //#region src/frontend/components/UserManagement.tsx
404
+ /**
405
+ * UserManagement — Admin panel for managing users.
406
+ *
407
+ * Displays a list of users with the ability to:
408
+ * - Create new users (email/password/role)
409
+ * - Change user roles
410
+ * - Delete users
411
+ *
412
+ * Only visible to admin users. Uses the auth plugin's
413
+ * `/plugins/auth/users` endpoints.
414
+ */
415
+ const ASSIGNABLE_ROLE_OPTIONS = [
416
+ {
417
+ value: AUTH_DEFAULT_ROLE,
418
+ label: "None",
419
+ description: "No global access; flow access can still be granted via RBAC."
420
+ },
421
+ {
422
+ value: "owner",
423
+ label: "Owner",
424
+ description: "Can edit and manage sharing for all flows."
425
+ },
426
+ {
427
+ value: "editor",
428
+ label: "Editor",
429
+ description: "Can inspect, run, and edit flows."
430
+ },
431
+ {
432
+ value: "operator",
433
+ label: "Operator",
434
+ description: "Can inspect and run flows."
435
+ },
436
+ {
437
+ value: "viewer",
438
+ label: "Viewer",
439
+ description: "Can inspect flows."
440
+ }
441
+ ];
442
+ const ROLE_BADGE_CLASSES = "border-imp-border bg-imp-muted/50 text-imp-foreground";
443
+ function getInitials(user) {
444
+ if (user.name) {
445
+ const parts = user.name.trim().split(/\s+/);
446
+ if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
447
+ return parts[0][0].toUpperCase();
448
+ }
449
+ if (user.email) return user.email[0].toUpperCase();
450
+ return "?";
451
+ }
452
+ function formatDate(iso) {
453
+ try {
454
+ return new Intl.DateTimeFormat(void 0, { dateStyle: "medium" }).format(new Date(iso));
455
+ } catch {
456
+ return iso;
457
+ }
458
+ }
459
+ function SortHeader({ label, field, sortField, sortDir, onSort, align = "left" }) {
460
+ const active = sortField === field;
461
+ const Icon = active ? sortDir === "asc" ? ArrowUp : ArrowDown : ChevronsUpDown;
462
+ return /* @__PURE__ */ jsx("th", {
463
+ className: `px-4 py-2.5 text-${align} font-medium`,
464
+ children: /* @__PURE__ */ jsxs("button", {
465
+ type: "button",
466
+ onClick: () => onSort(field),
467
+ className: `inline-flex items-center gap-1 rounded transition-colors hover:text-imp-foreground ${active ? "text-imp-foreground" : ""}`,
468
+ children: [label, /* @__PURE__ */ jsx(Icon, { className: "w-3 h-3 shrink-0" })]
469
+ })
470
+ });
471
+ }
472
+ function RoleDropdown({ value, userId, disabled, onChange }) {
473
+ const current = isAuthAssignableRole(value) ? value : AUTH_DEFAULT_ROLE;
474
+ const currentLabel = ASSIGNABLE_ROLE_OPTIONS.find((o) => o.value === current)?.label ?? current;
475
+ return /* @__PURE__ */ jsxs(DropdownMenu, { children: [/* @__PURE__ */ jsx(DropdownMenuTrigger, {
476
+ asChild: true,
477
+ disabled,
478
+ children: /* @__PURE__ */ jsxs("button", {
479
+ type: "button",
480
+ 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}`,
481
+ children: [currentLabel, !disabled && /* @__PURE__ */ jsx(ChevronDown, { className: "w-3 h-3 shrink-0" })]
482
+ })
483
+ }), /* @__PURE__ */ jsxs(DropdownMenuContent, {
484
+ align: "start",
485
+ className: "w-56",
486
+ children: [
487
+ /* @__PURE__ */ jsx(DropdownMenuLabel, {
488
+ className: "text-xs",
489
+ children: "Set role"
490
+ }),
491
+ /* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
492
+ ASSIGNABLE_ROLE_OPTIONS.map((option) => /* @__PURE__ */ jsx(DropdownMenuItem, {
493
+ onSelect: () => onChange(userId, option.value),
494
+ className: `items-start gap-0 px-2 py-2 ${current === option.value ? "bg-accent text-accent-foreground" : ""}`,
495
+ children: /* @__PURE__ */ jsxs("div", {
496
+ className: "min-w-0 text-left",
497
+ children: [/* @__PURE__ */ jsx("div", {
498
+ className: "text-sm font-medium",
499
+ children: option.label
500
+ }), /* @__PURE__ */ jsx("div", {
501
+ className: "text-xs text-muted-foreground",
502
+ children: option.description
503
+ })]
504
+ })
505
+ }, option.value))
506
+ ]
507
+ })] });
508
+ }
509
+ function UserManagement({ apiBaseUrl, className }) {
510
+ const { user, isAuthenticated } = useAuth();
511
+ const [users, setUsers] = useState([]);
512
+ const [isLoading, setIsLoading] = useState(false);
513
+ const [error, setError] = useState(null);
514
+ const [showCreateDialog, setShowCreateDialog] = useState(false);
515
+ const [pendingDeleteUser, setPendingDeleteUser] = useState(null);
516
+ const [hasFetched, setHasFetched] = useState(false);
517
+ const [searchQuery, setSearchQuery] = useState("");
518
+ const [currentPage, setCurrentPage] = useState(1);
519
+ const [sortField, setSortField] = useState("name");
520
+ const [sortDir, setSortDir] = useState("asc");
521
+ const PAGE_SIZE = 10;
522
+ const handleSort = useCallback((field) => {
523
+ setSortField((prev) => {
524
+ if (prev === field) {
525
+ setSortDir((d) => d === "asc" ? "desc" : "asc");
526
+ return prev;
527
+ }
528
+ setSortDir("asc");
529
+ return field;
530
+ });
531
+ setCurrentPage(1);
532
+ }, []);
533
+ const filteredUsers = useMemo(() => {
534
+ let result = users;
535
+ if (searchQuery.trim()) {
536
+ const q = searchQuery.toLowerCase();
537
+ result = result.filter((u) => u.name?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q) || u.role?.toLowerCase().includes(q));
538
+ }
539
+ return [...result].sort((a, b) => {
540
+ let aVal = "";
541
+ let bVal = "";
542
+ if (sortField === "name") {
543
+ aVal = (a.name || a.email || "").toLowerCase();
544
+ bVal = (b.name || b.email || "").toLowerCase();
545
+ } else if (sortField === "createdAt") {
546
+ aVal = a.createdAt ?? "";
547
+ bVal = b.createdAt ?? "";
548
+ } else if (sortField === "role") {
549
+ aVal = (a.role ?? "").toLowerCase();
550
+ bVal = (b.role ?? "").toLowerCase();
551
+ }
552
+ const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
553
+ return sortDir === "asc" ? cmp : -cmp;
554
+ });
555
+ }, [
556
+ users,
557
+ searchQuery,
558
+ sortField,
559
+ sortDir
560
+ ]);
561
+ const totalPages = Math.max(1, Math.ceil(filteredUsers.length / PAGE_SIZE));
562
+ const paginatedUsers = filteredUsers.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
563
+ const authApiBase = `${apiBaseUrl}/plugins/auth`;
564
+ const fetchUsers = useCallback(async () => {
565
+ setIsLoading(true);
566
+ setError(null);
567
+ try {
568
+ const res = await fetch(`${authApiBase}/users`, { credentials: "include" });
569
+ if (!res.ok) {
570
+ const data = await res.json().catch(() => ({ error: "Failed to fetch users" }));
571
+ throw new Error(data.error || data.message || `HTTP ${res.status}`);
572
+ }
573
+ setUsers((await res.json()).users ?? []);
574
+ setHasFetched(true);
575
+ } catch (err) {
576
+ setError(err instanceof Error ? err.message : "Failed to fetch users");
577
+ } finally {
578
+ setIsLoading(false);
579
+ }
580
+ }, [authApiBase]);
581
+ const deleteUser = useCallback(async (userId) => {
582
+ try {
583
+ const res = await fetch(`${authApiBase}/users/${userId}`, {
584
+ method: "DELETE",
585
+ credentials: "include"
586
+ });
587
+ if (!res.ok) {
588
+ const data = await res.json().catch(() => ({}));
589
+ throw new Error(data.error || `HTTP ${res.status}`);
590
+ }
591
+ setUsers((prev) => prev.filter((u) => u.id !== userId));
592
+ setPendingDeleteUser(null);
593
+ } catch (err) {
594
+ setError(err instanceof Error ? err.message : "Failed to delete user");
595
+ setPendingDeleteUser(null);
596
+ }
597
+ }, [authApiBase]);
598
+ const updateRole = useCallback(async (userId, role) => {
599
+ try {
600
+ const res = await fetch(`${authApiBase}/users/${userId}/role`, {
601
+ method: "PATCH",
602
+ credentials: "include",
603
+ headers: { "content-type": "application/json" },
604
+ body: JSON.stringify({ role })
605
+ });
606
+ if (!res.ok) {
607
+ const data = await res.json().catch(() => ({}));
608
+ throw new Error(data.error || `HTTP ${res.status}`);
609
+ }
610
+ setUsers((prev) => prev.map((u) => u.id === userId ? {
611
+ ...u,
612
+ role
613
+ } : u));
614
+ } catch (err) {
615
+ setError(err instanceof Error ? err.message : "Failed to update role");
616
+ }
617
+ }, [authApiBase]);
618
+ if (!isAuthenticated || user?.role !== "admin") return null;
619
+ if (!hasFetched && !isLoading) fetchUsers();
620
+ return /* @__PURE__ */ jsxs("div", {
621
+ className: `space-y-4 ${className ?? ""}`,
622
+ children: [
623
+ /* @__PURE__ */ jsxs("div", {
624
+ className: "flex items-center gap-2",
625
+ children: [/* @__PURE__ */ jsxs("div", {
626
+ className: "relative flex-1 max-w-sm",
627
+ children: [/* @__PURE__ */ jsx(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__ */ jsx("input", {
628
+ type: "text",
629
+ value: searchQuery,
630
+ onChange: (e) => {
631
+ setSearchQuery(e.target.value);
632
+ setCurrentPage(1);
633
+ },
634
+ placeholder: "Search users…",
635
+ 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"
636
+ })]
637
+ }), /* @__PURE__ */ jsxs("button", {
638
+ type: "button",
639
+ onClick: () => setShowCreateDialog(true),
640
+ 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",
641
+ children: [/* @__PURE__ */ jsx(UserPlus, { className: "w-4 h-4" }), " Create User"]
642
+ })]
643
+ }),
644
+ error && /* @__PURE__ */ jsxs("div", {
645
+ className: "p-3 text-sm text-red-600 rounded-md bg-red-50 dark:bg-red-950/20 dark:text-red-400",
646
+ children: [error, /* @__PURE__ */ jsx("button", {
647
+ type: "button",
648
+ onClick: () => setError(null),
649
+ className: "ml-2 underline hover:no-underline",
650
+ children: "Dismiss"
651
+ })]
652
+ }),
653
+ /* @__PURE__ */ jsx("div", {
654
+ className: "overflow-hidden border rounded-xl border-imp-border bg-imp-background/40",
655
+ children: /* @__PURE__ */ jsxs("table", {
656
+ className: "w-full text-sm table-fixed",
657
+ children: [
658
+ /* @__PURE__ */ jsxs("colgroup", { children: [
659
+ /* @__PURE__ */ jsx("col", { className: "w-[40%]" }),
660
+ /* @__PURE__ */ jsx("col", { className: "w-[20%]" }),
661
+ /* @__PURE__ */ jsx("col", { className: "w-[20%]" }),
662
+ /* @__PURE__ */ jsx("col", { className: "w-[20%]" })
663
+ ] }),
664
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", {
665
+ className: "text-xs font-medium border-b border-imp-border bg-imp-muted/20 text-imp-muted-foreground",
666
+ children: [
667
+ /* @__PURE__ */ jsx(SortHeader, {
668
+ label: "User",
669
+ field: "name",
670
+ sortField,
671
+ sortDir,
672
+ onSort: handleSort
673
+ }),
674
+ /* @__PURE__ */ jsx(SortHeader, {
675
+ label: "Global Role",
676
+ field: "role",
677
+ sortField,
678
+ sortDir,
679
+ onSort: handleSort
680
+ }),
681
+ /* @__PURE__ */ jsx(SortHeader, {
682
+ label: "Created",
683
+ field: "createdAt",
684
+ sortField,
685
+ sortDir,
686
+ onSort: handleSort,
687
+ align: "right"
688
+ }),
689
+ /* @__PURE__ */ jsx("th", { className: "px-4 py-2.5 text-right font-medium" })
690
+ ]
691
+ }) }),
692
+ /* @__PURE__ */ jsxs("tbody", {
693
+ className: "divide-y divide-imp-border",
694
+ children: [paginatedUsers.length === 0 && /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", {
695
+ colSpan: 4,
696
+ className: "px-4 py-8 text-sm text-center text-imp-muted-foreground",
697
+ children: !hasFetched || isLoading ? "Loading…" : searchQuery ? "No users match your search." : "No users found."
698
+ }) }), paginatedUsers.map((u) => /* @__PURE__ */ jsxs("tr", {
699
+ className: "group hover:bg-imp-muted/20",
700
+ children: [
701
+ /* @__PURE__ */ jsx("td", {
702
+ className: "px-4 py-3",
703
+ children: /* @__PURE__ */ jsxs("div", {
704
+ className: "flex items-center gap-3",
705
+ children: [/* @__PURE__ */ jsx("div", {
706
+ className: "flex items-center justify-center w-8 h-8 text-xs font-semibold rounded-full shrink-0 bg-imp-primary/10 text-imp-primary",
707
+ children: getInitials(u)
708
+ }), /* @__PURE__ */ jsxs("div", {
709
+ className: "min-w-0",
710
+ children: [/* @__PURE__ */ jsx("div", {
711
+ className: "font-medium truncate text-imp-foreground",
712
+ children: u.name || "Unnamed"
713
+ }), /* @__PURE__ */ jsx("div", {
714
+ className: "text-xs truncate text-imp-muted-foreground",
715
+ children: u.email
716
+ })]
717
+ })]
718
+ })
719
+ }),
720
+ /* @__PURE__ */ jsx("td", {
721
+ className: "px-4 py-3",
722
+ children: u.role === "admin" ? /* @__PURE__ */ jsx("span", {
723
+ className: `inline-flex rounded-full border px-2.5 py-0.5 text-sm font-medium ${ROLE_BADGE_CLASSES}`,
724
+ children: formatAuthRoleLabel(u.role)
725
+ }) : /* @__PURE__ */ jsx(RoleDropdown, {
726
+ value: u.role ?? "default",
727
+ userId: u.id,
728
+ disabled: u.id === user?.id,
729
+ onChange: updateRole
730
+ })
731
+ }),
732
+ /* @__PURE__ */ jsx("td", {
733
+ className: "px-4 py-3 text-xs text-right text-imp-muted-foreground",
734
+ children: u.createdAt ? formatDate(u.createdAt) : "—"
735
+ }),
736
+ /* @__PURE__ */ jsx("td", {
737
+ className: "px-4 py-3 text-right",
738
+ children: u.role === "admin" ? /* @__PURE__ */ jsx("span", {
739
+ className: "text-xs text-imp-muted-foreground",
740
+ children: "Config managed"
741
+ }) : u.id !== user?.id ? /* @__PURE__ */ jsx("button", {
742
+ type: "button",
743
+ onClick: () => setPendingDeleteUser(u),
744
+ 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",
745
+ children: /* @__PURE__ */ jsx(Trash2, { className: "w-4 h-4" })
746
+ }) : null
747
+ })
748
+ ]
749
+ }, u.id))]
750
+ })
751
+ ]
752
+ })
753
+ }),
754
+ /* @__PURE__ */ jsxs("div", {
755
+ className: "flex items-center justify-between text-sm",
756
+ children: [/* @__PURE__ */ jsxs("span", {
757
+ className: "text-xs text-imp-muted-foreground",
758
+ children: [
759
+ filteredUsers.length,
760
+ " user",
761
+ filteredUsers.length !== 1 ? "s" : "",
762
+ searchQuery && ` matching "${searchQuery}"`
763
+ ]
764
+ }), /* @__PURE__ */ jsxs("div", {
765
+ className: "flex items-center gap-2",
766
+ children: [
767
+ /* @__PURE__ */ jsx("button", {
768
+ type: "button",
769
+ onClick: () => setCurrentPage((p) => Math.max(1, p - 1)),
770
+ disabled: currentPage === 1,
771
+ className: "rounded-md border border-imp-border px-2.5 py-1 text-xs hover:bg-imp-muted disabled:opacity-50",
772
+ children: "Previous"
773
+ }),
774
+ /* @__PURE__ */ jsxs("span", {
775
+ className: "text-xs text-imp-muted-foreground",
776
+ children: [
777
+ currentPage,
778
+ " / ",
779
+ totalPages
780
+ ]
781
+ }),
782
+ /* @__PURE__ */ jsx("button", {
783
+ type: "button",
784
+ onClick: () => setCurrentPage((p) => Math.min(totalPages, p + 1)),
785
+ disabled: currentPage === totalPages,
786
+ className: "rounded-md border border-imp-border px-2.5 py-1 text-xs hover:bg-imp-muted disabled:opacity-50",
787
+ children: "Next"
788
+ })
789
+ ]
790
+ })]
791
+ }),
792
+ /* @__PURE__ */ jsx(Dialog, {
793
+ open: showCreateDialog,
794
+ onOpenChange: (open) => !open && setShowCreateDialog(false),
795
+ children: /* @__PURE__ */ jsxs(DialogContent, {
796
+ className: "max-w-md border-imp-border bg-imp-background text-imp-foreground sm:max-w-md",
797
+ children: [/* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, {
798
+ className: "text-sm font-semibold",
799
+ children: "Create New User"
800
+ }) }), /* @__PURE__ */ jsx(CreateUserForm, {
801
+ apiBaseUrl: authApiBase,
802
+ onCreated: (newUser) => {
803
+ setUsers((prev) => [...prev, newUser]);
804
+ setShowCreateDialog(false);
805
+ },
806
+ onCancel: () => setShowCreateDialog(false)
807
+ })]
808
+ })
809
+ }),
810
+ /* @__PURE__ */ jsx(Dialog, {
811
+ open: pendingDeleteUser !== null,
812
+ onOpenChange: (open) => !open && setPendingDeleteUser(null),
813
+ children: /* @__PURE__ */ jsxs(DialogContent, {
814
+ className: "max-w-sm border-imp-border bg-imp-background text-imp-foreground sm:max-w-sm",
815
+ children: [
816
+ /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, {
817
+ className: "text-sm font-semibold",
818
+ children: "Delete user"
819
+ }) }),
820
+ /* @__PURE__ */ jsxs("p", {
821
+ className: "text-sm text-imp-muted-foreground",
822
+ children: [
823
+ "Are you sure you want to delete",
824
+ " ",
825
+ /* @__PURE__ */ jsx("span", {
826
+ className: "font-medium text-imp-foreground",
827
+ children: pendingDeleteUser?.name || pendingDeleteUser?.email || "this user"
828
+ }),
829
+ "? This action cannot be undone."
830
+ ]
831
+ }),
832
+ /* @__PURE__ */ jsxs(DialogFooter, {
833
+ className: "gap-2",
834
+ children: [/* @__PURE__ */ jsx("button", {
835
+ type: "button",
836
+ onClick: () => setPendingDeleteUser(null),
837
+ className: "rounded-md border border-imp-border px-3 py-1.5 text-sm hover:bg-imp-muted",
838
+ children: "Cancel"
839
+ }), /* @__PURE__ */ jsx("button", {
840
+ type: "button",
841
+ onClick: () => pendingDeleteUser && deleteUser(pendingDeleteUser.id),
842
+ className: "rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700",
843
+ children: "Delete"
844
+ })]
845
+ })
846
+ ]
847
+ })
848
+ })
849
+ ]
850
+ });
851
+ }
852
+ function CreateUserForm({ apiBaseUrl, onCreated, onCancel }) {
853
+ const [email, setEmail] = useState("");
854
+ const [password, setPassword] = useState("");
855
+ const [name, setName] = useState("");
856
+ const [role, setRole] = useState(AUTH_DEFAULT_ROLE);
857
+ const [isSubmitting, setIsSubmitting] = useState(false);
858
+ const [error, setError] = useState(null);
859
+ const handleSubmit = async (e) => {
860
+ e.preventDefault();
861
+ setError(null);
862
+ if (!email.trim() || !password.trim()) {
863
+ setError("Email and password are required");
864
+ return;
865
+ }
866
+ if (password.length < 8) {
867
+ setError("Password must be at least 8 characters");
868
+ return;
869
+ }
870
+ setIsSubmitting(true);
871
+ try {
872
+ const res = await fetch(`${apiBaseUrl}/users`, {
873
+ method: "POST",
874
+ credentials: "include",
875
+ headers: { "content-type": "application/json" },
876
+ body: JSON.stringify({
877
+ email,
878
+ password,
879
+ name: name.trim() || void 0,
880
+ role
881
+ })
882
+ });
883
+ const data = await res.json();
884
+ if (!res.ok) throw new Error(data.error || data.message || `HTTP ${res.status}`);
885
+ onCreated(data.user);
886
+ } catch (err) {
887
+ setError(err instanceof Error ? err.message : "Failed to create user");
888
+ } finally {
889
+ setIsSubmitting(false);
890
+ }
891
+ };
892
+ return /* @__PURE__ */ jsxs("form", {
893
+ onSubmit: handleSubmit,
894
+ className: "space-y-3",
895
+ children: [
896
+ /* @__PURE__ */ jsxs("div", {
897
+ className: "grid grid-cols-2 gap-3",
898
+ children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("label", {
899
+ className: "block mb-1 text-xs font-medium text-imp-foreground",
900
+ children: "Name"
901
+ }), /* @__PURE__ */ jsx("input", {
902
+ type: "text",
903
+ value: name,
904
+ onChange: (e) => setName(e.target.value),
905
+ placeholder: "User name",
906
+ 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:outline-none focus:border-imp-primary/50"
907
+ })] }), /* @__PURE__ */ jsxs("div", { children: [
908
+ /* @__PURE__ */ jsx("label", {
909
+ className: "block mb-1 text-xs font-medium text-imp-foreground",
910
+ children: "Role"
911
+ }),
912
+ /* @__PURE__ */ jsx("select", {
913
+ value: role,
914
+ onChange: (e) => setRole(e.target.value),
915
+ className: "w-full rounded-md border border-imp-border bg-imp-background px-3 py-1.5 text-sm focus:outline-none focus:border-imp-primary/50",
916
+ children: ASSIGNABLE_ROLE_OPTIONS.map((option) => /* @__PURE__ */ jsx("option", {
917
+ value: option.value,
918
+ children: option.label
919
+ }, option.value))
920
+ }),
921
+ /* @__PURE__ */ jsx("p", {
922
+ className: "mt-1 text-xs text-imp-muted-foreground",
923
+ children: "Flow access can still be granted via RBAC."
924
+ })
925
+ ] })]
926
+ }),
927
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("label", {
928
+ className: "block mb-1 text-xs font-medium text-imp-foreground",
929
+ children: ["Email ", /* @__PURE__ */ jsx("span", {
930
+ className: "text-red-500",
931
+ children: "*"
932
+ })]
933
+ }), /* @__PURE__ */ jsx("input", {
934
+ type: "email",
935
+ value: email,
936
+ onChange: (e) => setEmail(e.target.value),
937
+ placeholder: "user@example.com",
938
+ required: true,
939
+ autoComplete: "off",
940
+ 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:outline-none focus:border-imp-primary/50"
941
+ })] }),
942
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("label", {
943
+ className: "block mb-1 text-xs font-medium text-imp-foreground",
944
+ children: ["Password ", /* @__PURE__ */ jsx("span", {
945
+ className: "text-red-500",
946
+ children: "*"
947
+ })]
948
+ }), /* @__PURE__ */ jsx("input", {
949
+ type: "password",
950
+ value: password,
951
+ onChange: (e) => setPassword(e.target.value),
952
+ placeholder: "Min 8 characters",
953
+ required: true,
954
+ minLength: 8,
955
+ autoComplete: "new-password",
956
+ 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:outline-none focus:border-imp-primary/50"
957
+ })] }),
958
+ error && /* @__PURE__ */ jsx("div", {
959
+ className: "p-2 text-xs text-red-600 rounded-md bg-red-50 dark:bg-red-950/20 dark:text-red-400",
960
+ children: error
961
+ }),
962
+ /* @__PURE__ */ jsxs("div", {
963
+ className: "flex justify-end gap-2 pt-1",
964
+ children: [/* @__PURE__ */ jsx("button", {
965
+ type: "button",
966
+ onClick: onCancel,
967
+ className: "rounded-md border border-imp-border px-3 py-1.5 text-sm hover:bg-imp-muted",
968
+ children: "Cancel"
969
+ }), /* @__PURE__ */ jsx("button", {
970
+ type: "submit",
971
+ disabled: isSubmitting,
972
+ className: "px-4 py-2 text-sm font-semibold rounded-md bg-imp-primary text-imp-primary-foreground hover:bg-imp-primary/90 disabled:opacity-50",
973
+ children: isSubmitting ? "Creating…" : "Create User"
974
+ })]
975
+ })
976
+ ]
977
+ });
978
+ }
979
+ //#endregion
980
+ //#region src/frontend/components/AuthenticatedInvect.tsx
981
+ const defaultQueryClient = new QueryClient({ defaultOptions: { queries: {
982
+ staleTime: 300 * 1e3,
983
+ retry: 1
984
+ } } });
985
+ function AuthenticatedInvect({ apiBaseUrl = "http://localhost:3000/invect", basePath = "/invect", InvectComponent, ShellComponent, reactQueryClient, loading, theme = "light", plugins }) {
986
+ const client = reactQueryClient ?? defaultQueryClient;
987
+ const Invect = InvectComponent;
988
+ const Shell = ShellComponent;
989
+ const content = /* @__PURE__ */ jsx(QueryClientProvider, {
990
+ client,
991
+ children: /* @__PURE__ */ jsx(AuthProvider, {
992
+ baseUrl: apiBaseUrl,
993
+ children: /* @__PURE__ */ jsx(AuthGate, {
994
+ loading: loading ?? /* @__PURE__ */ jsx(LoadingSpinner, {}),
995
+ fallback: /* @__PURE__ */ jsx(SignInOnly, {}),
996
+ children: /* @__PURE__ */ jsx(Invect, {
997
+ apiBaseUrl,
998
+ basePath,
999
+ reactQueryClient: client,
1000
+ plugins
1001
+ })
1002
+ })
1003
+ })
1004
+ });
1005
+ if (Shell) return /* @__PURE__ */ jsx(Shell, {
1006
+ theme,
1007
+ className: "h-full",
1008
+ children: content
1009
+ });
1010
+ return content;
1011
+ }
1012
+ function SignInOnly() {
1013
+ return /* @__PURE__ */ jsx(SignInPage, {
1014
+ onSuccess: () => {},
1015
+ subtitle: "Sign in to access Invect"
1016
+ });
1017
+ }
1018
+ function LoadingSpinner() {
1019
+ return /* @__PURE__ */ jsx("div", {
1020
+ className: "flex min-h-screen items-center justify-center bg-imp-background",
1021
+ children: /* @__PURE__ */ jsx("div", { className: "h-8 w-8 animate-spin rounded-full border-4 border-imp-muted border-t-imp-primary" })
1022
+ });
1023
+ }
1024
+ //#endregion
1025
+ //#region src/frontend/components/UserManagementPage.tsx
1026
+ /**
1027
+ * UserManagementPage — Standalone page for user management.
1028
+ *
1029
+ * Wraps the existing UserManagement component in a page layout
1030
+ * consistent with the Access Control page style. Registered as a
1031
+ * plugin route contribution at '/users'.
1032
+ */
1033
+ function UserManagementPage() {
1034
+ const api = useApiClient();
1035
+ const { user, isAuthenticated } = useAuth();
1036
+ const apiBaseUrl = api.getBaseURL();
1037
+ if (!isAuthenticated) return /* @__PURE__ */ jsx("div", {
1038
+ className: "imp-page w-full h-full min-h-0 overflow-y-auto bg-imp-background text-imp-foreground flex items-center justify-center",
1039
+ children: /* @__PURE__ */ jsx("p", {
1040
+ className: "text-sm text-imp-muted-foreground",
1041
+ children: "Please sign in to access this page."
1042
+ })
1043
+ });
1044
+ if (user?.role !== "admin") return /* @__PURE__ */ jsx(PageLayout, {
1045
+ title: "User Management",
1046
+ subtitle: "Manage users for your Invect instance.",
1047
+ icon: Users,
1048
+ children: /* @__PURE__ */ jsx("div", {
1049
+ className: "rounded-md bg-yellow-50 p-3 text-sm text-yellow-800 dark:bg-yellow-950/20 dark:text-yellow-400",
1050
+ children: "Only administrators can manage users. Contact an admin for access."
1051
+ })
1052
+ });
1053
+ return /* @__PURE__ */ jsx(PageLayout, {
1054
+ title: "User Management",
1055
+ subtitle: "Create, manage, and remove users for your Invect instance.",
1056
+ icon: Users,
1057
+ children: /* @__PURE__ */ jsx(UserManagement, { apiBaseUrl })
1058
+ });
1059
+ }
1060
+ //#endregion
1061
+ //#region src/frontend/components/ProfilePage.tsx
1062
+ /**
1063
+ * ProfilePage — Standalone page for the current authenticated user.
1064
+ *
1065
+ * Shows basic account information and provides a sign-out action.
1066
+ */
1067
+ function ProfilePage({ basePath }) {
1068
+ const { user, isAuthenticated, isLoading, signOut } = useAuth();
1069
+ if (isLoading) return /* @__PURE__ */ jsx("div", {
1070
+ className: "imp-page flex h-full min-h-0 w-full items-center justify-center overflow-y-auto bg-imp-background text-imp-foreground",
1071
+ children: /* @__PURE__ */ jsx("p", {
1072
+ className: "text-sm text-imp-muted-foreground",
1073
+ children: "Loading profile…"
1074
+ })
1075
+ });
1076
+ if (!isAuthenticated || !user) return /* @__PURE__ */ jsx("div", {
1077
+ className: "imp-page flex h-full min-h-0 w-full items-center justify-center overflow-y-auto bg-imp-background text-imp-foreground",
1078
+ children: /* @__PURE__ */ jsx("p", {
1079
+ className: "text-sm text-imp-muted-foreground",
1080
+ children: "Please sign in to view your profile."
1081
+ })
1082
+ });
1083
+ const displayName = user.name ?? user.email ?? user.id;
1084
+ const initials = displayName[0]?.toUpperCase() ?? "?";
1085
+ return /* @__PURE__ */ jsx(PageLayout, {
1086
+ title: "Profile",
1087
+ subtitle: "View your account details and manage your current session.",
1088
+ icon: User,
1089
+ children: /* @__PURE__ */ jsxs("div", {
1090
+ className: "max-w-2xl rounded-xl border border-imp-border bg-imp-card p-6 shadow-sm",
1091
+ children: [
1092
+ /* @__PURE__ */ jsxs("div", {
1093
+ className: "mb-6 flex items-center gap-4",
1094
+ children: [/* @__PURE__ */ jsx("div", {
1095
+ className: "flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-full bg-imp-primary/10 text-lg font-semibold text-imp-primary",
1096
+ children: user.image ? /* @__PURE__ */ jsx("img", {
1097
+ src: user.image,
1098
+ alt: displayName,
1099
+ className: "h-16 w-16 object-cover"
1100
+ }) : initials
1101
+ }), /* @__PURE__ */ jsxs("div", {
1102
+ className: "min-w-0",
1103
+ children: [/* @__PURE__ */ jsx("p", {
1104
+ className: "truncate text-lg font-semibold text-imp-foreground",
1105
+ children: displayName
1106
+ }), /* @__PURE__ */ jsx("p", {
1107
+ className: "truncate text-sm text-imp-muted-foreground",
1108
+ children: user.email ?? user.id
1109
+ })]
1110
+ })]
1111
+ }),
1112
+ /* @__PURE__ */ jsxs("div", {
1113
+ className: "grid gap-4 md:grid-cols-2",
1114
+ children: [
1115
+ /* @__PURE__ */ jsxs("div", {
1116
+ className: "rounded-lg border border-imp-border bg-imp-background p-4",
1117
+ children: [/* @__PURE__ */ jsxs("div", {
1118
+ className: "mb-2 flex items-center gap-2 text-sm font-medium text-imp-foreground",
1119
+ children: [/* @__PURE__ */ jsx(User, { className: "h-4 w-4 text-imp-muted-foreground" }), "Name"]
1120
+ }), /* @__PURE__ */ jsx("p", {
1121
+ className: "text-sm text-imp-muted-foreground",
1122
+ children: user.name ?? "Not set"
1123
+ })]
1124
+ }),
1125
+ /* @__PURE__ */ jsxs("div", {
1126
+ className: "rounded-lg border border-imp-border bg-imp-background p-4",
1127
+ children: [/* @__PURE__ */ jsxs("div", {
1128
+ className: "mb-2 flex items-center gap-2 text-sm font-medium text-imp-foreground",
1129
+ children: [/* @__PURE__ */ jsx(Mail, { className: "h-4 w-4 text-imp-muted-foreground" }), "Email"]
1130
+ }), /* @__PURE__ */ jsx("p", {
1131
+ className: "text-sm text-imp-muted-foreground",
1132
+ children: user.email ?? "Not available"
1133
+ })]
1134
+ }),
1135
+ /* @__PURE__ */ jsxs("div", {
1136
+ className: "rounded-lg border border-imp-border bg-imp-background p-4",
1137
+ children: [/* @__PURE__ */ jsxs("div", {
1138
+ className: "mb-2 flex items-center gap-2 text-sm font-medium text-imp-foreground",
1139
+ children: [/* @__PURE__ */ jsx(Shield, { className: "h-4 w-4 text-imp-muted-foreground" }), "Role"]
1140
+ }), /* @__PURE__ */ jsx("p", {
1141
+ className: "text-sm text-imp-muted-foreground",
1142
+ children: formatAuthRoleLabel(user.role)
1143
+ })]
1144
+ }),
1145
+ /* @__PURE__ */ jsxs("div", {
1146
+ className: "rounded-lg border border-imp-border bg-imp-background p-4",
1147
+ children: [/* @__PURE__ */ jsxs("div", {
1148
+ className: "mb-2 flex items-center gap-2 text-sm font-medium text-imp-foreground",
1149
+ children: [/* @__PURE__ */ jsx(User, { className: "h-4 w-4 text-imp-muted-foreground" }), "User ID"]
1150
+ }), /* @__PURE__ */ jsx("p", {
1151
+ className: "break-all text-sm text-imp-muted-foreground",
1152
+ children: user.id
1153
+ })]
1154
+ })
1155
+ ]
1156
+ }),
1157
+ /* @__PURE__ */ jsx("div", {
1158
+ className: "mt-6 flex justify-end border-t border-imp-border pt-4",
1159
+ children: /* @__PURE__ */ jsxs("button", {
1160
+ onClick: async () => {
1161
+ await signOut();
1162
+ },
1163
+ className: "inline-flex items-center gap-2 rounded-md border border-imp-border px-4 py-2 text-sm font-medium text-imp-foreground transition-colors hover:bg-imp-muted",
1164
+ children: [/* @__PURE__ */ jsx(LogOut, { className: "h-4 w-4" }), "Sign Out"]
1165
+ })
1166
+ })
1167
+ ]
1168
+ })
1169
+ });
1170
+ }
1171
+ //#endregion
1172
+ //#region src/frontend/components/SidebarUserMenu.tsx
1173
+ /**
1174
+ * SidebarUserMenu — User avatar link in the sidebar footer.
1175
+ *
1176
+ * Clicking navigates directly to the profile page.
1177
+ * Sign-out is available on the profile page itself.
1178
+ */
1179
+ function SidebarUserMenu({ collapsed = false, basePath = "" }) {
1180
+ const { user, isAuthenticated, isLoading } = useAuth();
1181
+ const location = useLocation();
1182
+ if (isLoading || !isAuthenticated || !user) return null;
1183
+ const initials = (user.name ?? user.email ?? user.id)[0]?.toUpperCase() ?? "?";
1184
+ const displayName = user.name ?? user.email ?? "User";
1185
+ const profilePath = `${basePath}/profile`;
1186
+ const isActive = location.pathname === profilePath;
1187
+ return /* @__PURE__ */ jsxs(Link, {
1188
+ to: profilePath,
1189
+ title: `${displayName}${user.role ? ` — ${user.role}` : ""}`,
1190
+ className: [
1191
+ "flex w-full items-center gap-3 rounded-md px-2 py-2 transition-colors",
1192
+ "hover:bg-imp-muted/60",
1193
+ isActive ? "bg-imp-muted/60" : "",
1194
+ collapsed ? "justify-center" : ""
1195
+ ].filter(Boolean).join(" "),
1196
+ children: [/* @__PURE__ */ jsx("div", {
1197
+ className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-imp-primary/10 text-sm font-medium text-imp-primary",
1198
+ children: user.image ? /* @__PURE__ */ jsx("img", {
1199
+ src: user.image,
1200
+ alt: displayName,
1201
+ className: "h-8 w-8 rounded-full object-cover"
1202
+ }) : initials
1203
+ }), !collapsed && /* @__PURE__ */ jsxs("div", {
1204
+ className: "min-w-0 flex-1",
1205
+ children: [/* @__PURE__ */ jsx("p", {
1206
+ className: "truncate text-sm font-medium",
1207
+ children: displayName
1208
+ }), user.role && /* @__PURE__ */ jsx("p", {
1209
+ className: "truncate text-xs capitalize text-imp-muted-foreground",
1210
+ children: user.role
1211
+ })]
1212
+ })]
1213
+ });
1214
+ }
1215
+ //#endregion
1216
+ //#region src/frontend/plugins/authFrontendPlugin.ts
1217
+ /**
1218
+ * @invect/user-auth — Auth Frontend Plugin Definition
1219
+ *
1220
+ * Registers the auth plugin's frontend contributions:
1221
+ * - Sidebar item: "Users" (admin-only)
1222
+ * - Route: /users → UserManagementPage
1223
+ *
1224
+ * Note: AuthProvider is NOT included as a plugin provider because
1225
+ * AuthenticatedInvect already wraps the tree with it. This plugin
1226
+ * only adds the user management UI.
1227
+ */
1228
+ const authFrontendPlugin = {
1229
+ id: "user-auth",
1230
+ name: "User Authentication",
1231
+ sidebar: [{
1232
+ label: "Users",
1233
+ icon: Users,
1234
+ path: "/users",
1235
+ position: "top",
1236
+ permission: "admin:*"
1237
+ }],
1238
+ sidebarFooter: SidebarUserMenu,
1239
+ routes: [{
1240
+ path: "/profile",
1241
+ component: ProfilePage
1242
+ }, {
1243
+ path: "/users",
1244
+ component: UserManagementPage
1245
+ }]
1246
+ };
1247
+ //#endregion
1248
+ export { AuthGate, AuthProvider, AuthenticatedInvect, ProfilePage, SidebarUserMenu, SignInForm, SignInPage, UserButton, UserManagement, UserManagementPage, authFrontendPlugin, useAuth };
1249
+
1250
+ //# sourceMappingURL=index.mjs.map