@seedgrid/fe-security 2026.7.0 → 2026.7.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 (124) hide show
  1. package/dist/client/index.d.ts +4 -0
  2. package/dist/client/index.d.ts.map +1 -0
  3. package/dist/client/index.js +5 -0
  4. package/dist/client/permission-provider.d.ts +18 -0
  5. package/dist/client/permission-provider.d.ts.map +1 -0
  6. package/dist/client/permission-provider.js +57 -0
  7. package/dist/client/pick-list-field.d.ts +15 -0
  8. package/dist/client/pick-list-field.d.ts.map +1 -0
  9. package/dist/client/pick-list-field.js +30 -0
  10. package/dist/client/status-badge.d.ts +6 -0
  11. package/dist/client/status-badge.d.ts.map +1 -0
  12. package/dist/client/status-badge.js +6 -0
  13. package/dist/config.d.ts +34 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +4 -0
  16. package/dist/dtos.d.ts +88 -0
  17. package/dist/dtos.d.ts.map +1 -0
  18. package/dist/dtos.js +3 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +10 -0
  22. package/dist/menu.d.ts +19 -0
  23. package/dist/menu.d.ts.map +1 -0
  24. package/dist/menu.js +41 -0
  25. package/dist/messages.d.ts +1858 -0
  26. package/dist/messages.d.ts.map +1 -0
  27. package/dist/messages.js +990 -0
  28. package/dist/module.d.ts +3 -0
  29. package/dist/module.d.ts.map +1 -0
  30. package/dist/module.js +37 -0
  31. package/dist/module.json +21 -0
  32. package/dist/paths.d.ts +1 -0
  33. package/dist/paths.d.ts.map +1 -1
  34. package/dist/paths.js +3 -0
  35. package/dist/permissions.d.ts +14 -0
  36. package/dist/permissions.d.ts.map +1 -0
  37. package/dist/permissions.js +39 -0
  38. package/dist/server/action.d.ts +3 -0
  39. package/dist/server/action.d.ts.map +1 -0
  40. package/dist/server/action.js +21 -0
  41. package/dist/server/create-security-server.d.ts +4 -0
  42. package/dist/server/create-security-server.d.ts.map +1 -0
  43. package/dist/server/create-security-server.js +166 -0
  44. package/dist/server/errors.d.ts +13 -0
  45. package/dist/server/errors.d.ts.map +1 -0
  46. package/dist/server/errors.js +26 -0
  47. package/dist/server/index.d.ts +8 -0
  48. package/dist/server/index.d.ts.map +1 -0
  49. package/dist/server/index.js +8 -0
  50. package/dist/server/problem-response.d.ts +3 -0
  51. package/dist/server/problem-response.d.ts.map +1 -0
  52. package/dist/server/problem-response.js +28 -0
  53. package/dist/server/route.d.ts +3 -0
  54. package/dist/server/route.d.ts.map +1 -0
  55. package/dist/server/route.js +22 -0
  56. package/dist/server/to-problem.d.ts +3 -0
  57. package/dist/server/to-problem.d.ts.map +1 -0
  58. package/dist/server/to-problem.js +22 -0
  59. package/dist/server-contract.d.ts +23 -0
  60. package/dist/server-contract.d.ts.map +1 -0
  61. package/dist/server-contract.js +4 -0
  62. package/dist/session-contract.d.ts +36 -0
  63. package/dist/session-contract.d.ts.map +1 -0
  64. package/dist/session-contract.js +8 -0
  65. package/dist/session-result.d.ts +22 -0
  66. package/dist/session-result.d.ts.map +1 -0
  67. package/dist/session-result.js +14 -0
  68. package/package.json +38 -3
  69. package/scaffold/README.md +97 -0
  70. package/scaffold/src/app/api/security/session/2fa/resend/route.ts +8 -0
  71. package/scaffold/src/app/api/security/session/2fa/verify/route.ts +8 -0
  72. package/scaffold/src/app/api/security/session/login/route.ts +7 -0
  73. package/scaffold/src/app/api/security/session/logout/route.ts +5 -0
  74. package/scaffold/src/app/api/security/session/refresh/route.ts +7 -0
  75. package/scaffold/src/app/api/security/users/[publicId]/avatar/route.ts +15 -0
  76. package/scaffold/src/app/auth/2fa/verify/page.tsx +286 -0
  77. package/scaffold/src/app/auth/forgot-password/confirm/page.tsx +96 -0
  78. package/scaffold/src/app/auth/forgot-password/page.tsx +165 -0
  79. package/scaffold/src/app/auth/forgot-password/success/page.tsx +31 -0
  80. package/scaffold/src/app/auth/login/page.tsx +386 -0
  81. package/scaffold/src/app/auth/welcome/page.tsx +90 -0
  82. package/scaffold/src/app/login/page.tsx +7 -0
  83. package/scaffold/src/app/page.tsx +12 -0
  84. package/scaffold/src/app/security/2fa/page.tsx +370 -0
  85. package/scaffold/src/app/security/companies/(create)/new/page.tsx +12 -0
  86. package/scaffold/src/app/security/companies/(edit)/[publicId]/page.tsx +18 -0
  87. package/scaffold/src/app/security/companies/(list)/page.tsx +298 -0
  88. package/scaffold/src/app/security/companies/editors/CompanyEditor.tsx +754 -0
  89. package/scaffold/src/app/security/profile/page.tsx +492 -0
  90. package/scaffold/src/app/security/roles/(create)/new/page.tsx +11 -0
  91. package/scaffold/src/app/security/roles/(edit)/[publicId]/page.tsx +17 -0
  92. package/scaffold/src/app/security/roles/(list)/page.tsx +269 -0
  93. package/scaffold/src/app/security/roles/editors/RoleEditor.tsx +328 -0
  94. package/scaffold/src/app/security/users/(create)/new/page.tsx +10 -0
  95. package/scaffold/src/app/security/users/(edit)/[publicId]/page.tsx +16 -0
  96. package/scaffold/src/app/security/users/(list)/page.tsx +289 -0
  97. package/scaffold/src/app/security/users/editors/UserEditor.tsx +478 -0
  98. package/scaffold/src/app/users/change-password/page.tsx +172 -0
  99. package/scaffold/src/modules/security/AuthProvider.tsx +405 -0
  100. package/scaffold/src/modules/security/components/AppShell.tsx.hbs +889 -0
  101. package/scaffold/src/modules/security/components/CompanyManagementGuard.tsx +59 -0
  102. package/scaffold/src/modules/security/components/PageFrame.tsx +105 -0
  103. package/scaffold/src/modules/security/components/RouteGuard.tsx +114 -0
  104. package/scaffold/src/modules/security/components/SecurityDashboard.tsx.hbs +315 -0
  105. package/scaffold/src/modules/security/config.ts +55 -0
  106. package/scaffold/src/modules/security/hooks/useAuth.ts +7 -0
  107. package/scaffold/src/modules/security/hooks/useDebouncedSearch.ts +13 -0
  108. package/scaffold/src/modules/security/hooks/usePermission.ts +35 -0
  109. package/scaffold/src/modules/security/host-access.ts +59 -0
  110. package/scaffold/src/modules/security/index.ts +44 -0
  111. package/scaffold/src/modules/security/navigation.ts +41 -0
  112. package/scaffold/src/modules/security/server/avatar-routes.ts +86 -0
  113. package/scaffold/src/modules/security/server/public-api-base-url.ts +26 -0
  114. package/scaffold/src/modules/security/server/session-routes.ts.hbs +322 -0
  115. package/scaffold/src/modules/security/server/session-server.ts.hbs +30 -0
  116. package/scaffold/src/modules/security/services/auth-service.ts +332 -0
  117. package/scaffold/src/modules/security/services/security-api.test.ts +178 -0
  118. package/scaffold/src/modules/security/services/security-api.ts +281 -0
  119. package/scaffold/src/modules/security/services/security-service.test.ts +71 -0
  120. package/scaffold/src/modules/security/services/security-service.ts +727 -0
  121. package/scaffold/src/modules/security/services/session-storage.ts +80 -0
  122. package/scaffold/src/modules/security/types.ts +295 -0
  123. package/scaffold/src/modules/security/useSecurityAppShellSections.tsx.hbs +180 -0
  124. package/scaffold/src/modules/security/utils.ts +194 -0
@@ -0,0 +1,405 @@
1
+ "use client";
2
+
3
+ // seedgrid:managed
4
+
5
+ import {
6
+ createContext,
7
+ useCallback,
8
+ useContext,
9
+ useEffect,
10
+ useMemo,
11
+ useState,
12
+ type ReactNode,
13
+ } from "react";
14
+ import { usePathname } from "next/navigation";
15
+
16
+ import { captureException } from "@/modules/core";
17
+
18
+ import {
19
+ createPermissionSet,
20
+ hasAllPermissions,
21
+ hasAnyPermission,
22
+ hasPermission,
23
+ } from "@seedgrid/fe-security";
24
+ import { securityConfig } from "./config";
25
+ import { authService, type LoginOutcome } from "./services/auth-service";
26
+ import {
27
+ configureSecurityApi,
28
+ isApiClientError,
29
+ } from "./services/security-api";
30
+ import {
31
+ clearPendingTwoFactorSession,
32
+ clearStoredSession,
33
+ readPendingTwoFactorSession,
34
+ readStoredSession,
35
+ writePendingTwoFactorSession,
36
+ writeStoredSession,
37
+ } from "./services/session-storage";
38
+ import type {
39
+ AuthenticatedSession,
40
+ ForgotPasswordPayload,
41
+ LoginCredentials,
42
+ PendingTwoFactorSession,
43
+ PermissionEnvelope,
44
+ } from "./types";
45
+
46
+ type AuthContextValue = {
47
+ status: "anonymous" | "authenticated" | "initializing";
48
+ session: AuthenticatedSession | null;
49
+ permissions: string[];
50
+ roles: string[];
51
+ pendingTwoFactor: PendingTwoFactorSession | null;
52
+ isAuthenticated: boolean;
53
+ isInitializing: boolean;
54
+ login: (credentials: LoginCredentials) => Promise<LoginOutcome>;
55
+ verifyTwoFactor: (code: string) => Promise<void>;
56
+ forgotPassword: (payload: ForgotPasswordPayload) => Promise<void>;
57
+ reloadPermissions: () => Promise<PermissionEnvelope>;
58
+ refreshSession: () => Promise<boolean>;
59
+ logout: () => Promise<void>;
60
+ hasPermission: (permission: string) => boolean;
61
+ hasAnyPermission: (permissions: string[]) => boolean;
62
+ hasAllPermissions: (permissions: string[]) => boolean;
63
+ };
64
+
65
+ const AuthContext = createContext<AuthContextValue | null>(null);
66
+
67
+ function isPublicRoute(pathname?: string | null) {
68
+ const normalizedPathname = pathname?.trim() || "";
69
+
70
+ return (
71
+ normalizedPathname === securityConfig.routes.login ||
72
+ normalizedPathname === securityConfig.routes.forgotPassword ||
73
+ normalizedPathname === securityConfig.routes.forgotPasswordConfirm ||
74
+ normalizedPathname === securityConfig.routes.forgotPasswordSuccess ||
75
+ normalizedPathname === securityConfig.routes.verifyTwoFactor ||
76
+ normalizedPathname === securityConfig.routes.selfEnterPublic ||
77
+ normalizedPathname === "/signup" ||
78
+ normalizedPathname.startsWith("/public/")
79
+ );
80
+ }
81
+
82
+ function shouldCaptureAuthError(error: unknown) {
83
+ return !(isApiClientError(error) && error.status === 401);
84
+ }
85
+
86
+ export function AuthProvider({ children }: { children: ReactNode }) {
87
+ const pathname = usePathname();
88
+ const [status, setStatus] = useState<AuthContextValue["status"]>("initializing");
89
+ const [session, setSession] = useState<AuthenticatedSession | null>(null);
90
+ const [pendingTwoFactor, setPendingTwoFactor] =
91
+ useState<PendingTwoFactorSession | null>(null);
92
+ const currentRouteIsPublic = useMemo(
93
+ () => isPublicRoute(pathname),
94
+ [pathname]
95
+ );
96
+
97
+ const applyAuthenticatedSession = useCallback(
98
+ (
99
+ nextSession: Omit<AuthenticatedSession, "permissions" | "roles">,
100
+ permissionEnvelope: PermissionEnvelope
101
+ ) => {
102
+ const normalizedPermissions = Array.from(
103
+ createPermissionSet(permissionEnvelope.permissions)
104
+ );
105
+ const authenticatedSession: AuthenticatedSession = {
106
+ ...nextSession,
107
+ permissions: normalizedPermissions,
108
+ roles: permissionEnvelope.roles,
109
+ };
110
+
111
+ setSession(authenticatedSession);
112
+ setStatus("authenticated");
113
+ writeStoredSession(authenticatedSession);
114
+ setPendingTwoFactor(null);
115
+ clearPendingTwoFactorSession();
116
+ },
117
+ []
118
+ );
119
+
120
+ const clearClientAuthState = useCallback(() => {
121
+ clearStoredSession();
122
+ clearPendingTwoFactorSession();
123
+ setSession(null);
124
+ setPendingTwoFactor(null);
125
+ setStatus("anonymous");
126
+ }, []);
127
+
128
+ const logout = useCallback(async () => {
129
+ try {
130
+ await authService.logout();
131
+ } catch (error) {
132
+ await captureException(error, {
133
+ area: "client",
134
+ source: "security.auth.logout",
135
+ });
136
+ } finally {
137
+ clearClientAuthState();
138
+ }
139
+ }, [clearClientAuthState]);
140
+
141
+ const handleAuthFailure = useCallback(() => {
142
+ clearClientAuthState();
143
+ void authService.logout();
144
+
145
+ if (typeof window === "undefined") {
146
+ return;
147
+ }
148
+
149
+ if (isPublicRoute(window.location.pathname)) {
150
+ return;
151
+ }
152
+
153
+ window.location.replace(securityConfig.routes.login);
154
+ }, [clearClientAuthState]);
155
+
156
+ const refreshSessionInternal = useCallback(async (redirectOnFailure: boolean) => {
157
+ const currentSession = readStoredSession() ?? session;
158
+
159
+ try {
160
+ const refreshedTokens = await authService.refresh();
161
+ const permissionEnvelope = await authService.loadPermissions(
162
+ refreshedTokens.accessToken
163
+ );
164
+
165
+ applyAuthenticatedSession(
166
+ {
167
+ ...refreshedTokens,
168
+ login: permissionEnvelope.login ?? currentSession?.login,
169
+ publicId:
170
+ refreshedTokens.publicId ??
171
+ permissionEnvelope.publicId ??
172
+ currentSession?.publicId,
173
+ name:
174
+ refreshedTokens.name ??
175
+ permissionEnvelope.name ??
176
+ currentSession?.name,
177
+ },
178
+ permissionEnvelope
179
+ );
180
+
181
+ return true;
182
+ } catch (error) {
183
+ if (shouldCaptureAuthError(error)) {
184
+ await captureException(error, {
185
+ area: "client",
186
+ source: "security.auth.refreshSession",
187
+ });
188
+ }
189
+
190
+ if (redirectOnFailure) {
191
+ handleAuthFailure();
192
+ } else {
193
+ clearClientAuthState();
194
+ }
195
+
196
+ return false;
197
+ }
198
+ }, [applyAuthenticatedSession, clearClientAuthState, handleAuthFailure]);
199
+
200
+ const refreshSession = useCallback(
201
+ async () => refreshSessionInternal(true),
202
+ [refreshSessionInternal]
203
+ );
204
+
205
+ const reloadPermissions = useCallback(async () => {
206
+ const currentSession = readStoredSession() ?? session;
207
+
208
+ if (!currentSession) {
209
+ return {
210
+ permissions: [],
211
+ roles: [],
212
+ };
213
+ }
214
+
215
+ const permissionEnvelope = await authService.loadPermissions(
216
+ currentSession.accessToken
217
+ );
218
+
219
+ applyAuthenticatedSession(
220
+ {
221
+ ...currentSession,
222
+ login: permissionEnvelope.login ?? currentSession.login,
223
+ publicId: permissionEnvelope.publicId ?? currentSession.publicId,
224
+ name: permissionEnvelope.name ?? currentSession.name,
225
+ },
226
+ permissionEnvelope
227
+ );
228
+
229
+ return permissionEnvelope;
230
+ }, [applyAuthenticatedSession, session]);
231
+
232
+ const login = useCallback(
233
+ async (credentials: LoginCredentials) => {
234
+ const outcome = await authService.signIn(credentials);
235
+
236
+ if (outcome.kind === "two_factor_required") {
237
+ setPendingTwoFactor(outcome.pending);
238
+ writePendingTwoFactorSession(outcome.pending);
239
+ setStatus("anonymous");
240
+ return outcome;
241
+ }
242
+
243
+ const permissionEnvelope = await authService.loadPermissions(
244
+ outcome.tokens.accessToken
245
+ );
246
+
247
+ applyAuthenticatedSession(
248
+ {
249
+ ...outcome.tokens,
250
+ login: permissionEnvelope.login ?? credentials.login,
251
+ publicId: outcome.tokens.publicId ?? permissionEnvelope.publicId,
252
+ name: outcome.tokens.name ?? permissionEnvelope.name,
253
+ },
254
+ permissionEnvelope
255
+ );
256
+
257
+ return outcome;
258
+ },
259
+ [applyAuthenticatedSession]
260
+ );
261
+
262
+ const verifyTwoFactor = useCallback(
263
+ async (code: string) => {
264
+ const pendingSession = pendingTwoFactor ?? readPendingTwoFactorSession();
265
+
266
+ if (!pendingSession) {
267
+ throw new Error("No pending two-factor session was found.");
268
+ }
269
+
270
+ const tokens = await authService.verifyTwoFactor(pendingSession, code);
271
+ const permissionEnvelope = await authService.loadPermissions(
272
+ tokens.accessToken
273
+ );
274
+
275
+ applyAuthenticatedSession(
276
+ {
277
+ ...tokens,
278
+ login: permissionEnvelope.login ?? pendingSession.login,
279
+ publicId: tokens.publicId ?? permissionEnvelope.publicId,
280
+ name: tokens.name ?? permissionEnvelope.name,
281
+ },
282
+ permissionEnvelope
283
+ );
284
+ },
285
+ [applyAuthenticatedSession, pendingTwoFactor]
286
+ );
287
+
288
+ const forgotPassword = useCallback(async (payload: ForgotPasswordPayload) => {
289
+ await authService.forgotPassword(payload);
290
+ }, []);
291
+
292
+ useEffect(() => {
293
+ configureSecurityApi({
294
+ getAccessToken: async () => readStoredSession()?.accessToken ?? null,
295
+ refreshAccessToken: async () => {
296
+ const refreshed = await refreshSession();
297
+ return refreshed ? readStoredSession()?.accessToken ?? null : null;
298
+ },
299
+ onAuthFailure: handleAuthFailure,
300
+ });
301
+ }, [handleAuthFailure, refreshSession]);
302
+
303
+ useEffect(() => {
304
+ const existingPendingSession = readPendingTwoFactorSession();
305
+
306
+ if (existingPendingSession) {
307
+ setPendingTwoFactor(existingPendingSession);
308
+ }
309
+
310
+ const existingSession = readStoredSession();
311
+
312
+ if (!existingSession) {
313
+ if (currentRouteIsPublic) {
314
+ setStatus("anonymous");
315
+ return;
316
+ }
317
+
318
+ void (async () => {
319
+ const refreshed = await refreshSessionInternal(false);
320
+
321
+ if (!refreshed) {
322
+ setStatus("anonymous");
323
+ }
324
+ })();
325
+
326
+ return;
327
+ }
328
+
329
+ setSession(existingSession);
330
+
331
+ void (async () => {
332
+ try {
333
+ const permissionEnvelope = await authService.loadPermissions(
334
+ existingSession.accessToken
335
+ );
336
+
337
+ if (existingSession.publicId && existingSession.name) {
338
+ applyAuthenticatedSession(existingSession, permissionEnvelope);
339
+ return;
340
+ }
341
+
342
+ const refreshed = await refreshSessionInternal(false);
343
+
344
+ if (!refreshed) {
345
+ applyAuthenticatedSession(existingSession, permissionEnvelope);
346
+ }
347
+ } catch (error) {
348
+ if (shouldCaptureAuthError(error)) {
349
+ await captureException(error, {
350
+ area: "client",
351
+ source: "security.auth.bootstrap",
352
+ });
353
+ }
354
+
355
+ const refreshed = await refreshSessionInternal(!currentRouteIsPublic);
356
+
357
+ if (!refreshed) {
358
+ setStatus("anonymous");
359
+ }
360
+ }
361
+ })();
362
+ }, [
363
+ applyAuthenticatedSession,
364
+ currentRouteIsPublic,
365
+ refreshSessionInternal,
366
+ ]);
367
+
368
+ const permissionSet = useMemo(
369
+ () => createPermissionSet(session?.permissions ?? []),
370
+ [session?.permissions]
371
+ );
372
+
373
+ const value: AuthContextValue = {
374
+ status,
375
+ session,
376
+ permissions: session?.permissions ?? [],
377
+ roles: session?.roles ?? [],
378
+ pendingTwoFactor,
379
+ isAuthenticated: status === "authenticated" && Boolean(session),
380
+ isInitializing: status === "initializing",
381
+ login,
382
+ verifyTwoFactor,
383
+ forgotPassword,
384
+ reloadPermissions,
385
+ refreshSession,
386
+ logout,
387
+ hasPermission: (permission) => hasPermission(permissionSet, permission),
388
+ hasAnyPermission: (permissions) =>
389
+ hasAnyPermission(permissionSet, permissions),
390
+ hasAllPermissions: (permissions) =>
391
+ hasAllPermissions(permissionSet, permissions),
392
+ };
393
+
394
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
395
+ }
396
+
397
+ export function useAuthContext() {
398
+ const context = useContext(AuthContext);
399
+
400
+ if (!context) {
401
+ throw new Error("useAuthContext must be used inside AuthProvider");
402
+ }
403
+
404
+ return context;
405
+ }