@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,889 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { useEffect, useMemo, useRef, useState } from "react";
5
+
6
+ import {
7
+ SgAvatar,
8
+ SgButton,
9
+ SgCombobox,
10
+ SgMenu,
11
+ type SgMenuNode,
12
+ SgStack,
13
+ SgToggleSwitch,
14
+ } from "@seedgrid/fe-components";
15
+ import {
16
+ Bell,
17
+ ChevronDown,
18
+ KeyRound,
19
+ LogOut,
20
+ Mail,
21
+ Menu,
22
+ Moon,
23
+ Palette,
24
+ PencilLine,
25
+ ShieldCheck,
26
+ Sun,
27
+ UserCog,
28
+ } from "lucide-react";
29
+ import { usePathname, useRouter } from "next/navigation";
30
+
31
+ import { useAppShellSections } from "@/components/layout/app-shell-navigation";
32
+ import { appEnvironment } from "@/config/environment";
33
+ import { locales } from "@/config/i18n";
34
+ import { useI18n } from "@/i18n";
35
+ import { useSgTheme } from "@/theme";
36
+
37
+ import { securityConfig } from "../config";
38
+ import { useAuth } from "../hooks/useAuth";
39
+ import { usePermission } from "../hooks/usePermission";
40
+ import { securityService } from "../services/security-service";
41
+
42
+ type LocaleOption = "pt-BR" | "pt-PT" | "en" | "es" | "fr";
43
+
44
+ const languageOptions: Array<{
45
+ id: LocaleOption;
46
+ value: LocaleOption;
47
+ label: string;
48
+ flag: string;
49
+ }> = [
50
+ {
51
+ id: "pt-BR",
52
+ value: "pt-BR",
53
+ label: "Portugues (Brasil)",
54
+ flag: "\uD83C\uDDE7\uD83C\uDDF7",
55
+ },
56
+ {
57
+ id: "pt-PT",
58
+ value: "pt-PT",
59
+ label: "Portugues (Portugal)",
60
+ flag: "\uD83C\uDDF5\uD83C\uDDF9",
61
+ },
62
+ { id: "en", value: "en", label: "English", flag: "\uD83C\uDDFA\uD83C\uDDF8" },
63
+ { id: "es", value: "es", label: "Espanol", flag: "\uD83C\uDDEA\uD83C\uDDF8" },
64
+ { id: "fr", value: "fr", label: "Francais", flag: "\uD83C\uDDEB\uD83C\uDDF7" },
65
+ ];
66
+
67
+ // `locales` e uma tupla `as const` (so os idiomas configurados no projeto);
68
+ // `option.id` percorre todos os LocaleOption. O check e de pertinencia de
69
+ // string, entao alargamos `locales` pra `readonly string[]` (senao o TS
70
+ // recusa comparar um LocaleOption fora da uniao configurada — TS2345).
71
+ const enabledLanguageOptions = languageOptions.filter((option) =>
72
+ (locales as readonly string[]).includes(option.id)
73
+ );
74
+
75
+ function LocaleFlag({ locale }: { locale: LocaleOption }) {
76
+ if (locale === "pt-BR") {
77
+ return (
78
+ <svg viewBox="0 0 20 14" className="h-3.5 w-5 rounded-[3px] shadow-sm" aria-hidden="true">
79
+ <rect width="20" height="14" rx="2" fill="#1f8f4d" />
80
+ <path d="M10 2.1 16.3 7 10 11.9 3.7 7Z" fill="#f3c942" />
81
+ <circle cx="10" cy="7" r="2.15" fill="#21468b" />
82
+ </svg>
83
+ );
84
+ }
85
+
86
+ if (locale === "pt-PT") {
87
+ return (
88
+ <svg viewBox="0 0 20 14" className="h-3.5 w-5 rounded-[3px] shadow-sm" aria-hidden="true">
89
+ <rect width="8" height="14" fill="#1f8f4d" />
90
+ <rect x="8" width="12" height="14" fill="#d0392f" />
91
+ <circle cx="8" cy="7" r="2.2" fill="#f3c942" />
92
+ </svg>
93
+ );
94
+ }
95
+
96
+ if (locale === "en") {
97
+ return (
98
+ <svg viewBox="0 0 20 14" className="h-3.5 w-5 rounded-[3px] shadow-sm" aria-hidden="true">
99
+ <rect width="20" height="14" rx="2" fill="#fff" />
100
+ <rect width="20" height="2" y="0" fill="#c53030" />
101
+ <rect width="20" height="2" y="4" fill="#c53030" />
102
+ <rect width="20" height="2" y="8" fill="#c53030" />
103
+ <rect width="20" height="2" y="12" fill="#c53030" />
104
+ <rect width="9" height="7.5" fill="#21468b" />
105
+ </svg>
106
+ );
107
+ }
108
+
109
+ if (locale === "es") {
110
+ return (
111
+ <svg viewBox="0 0 20 14" className="h-3.5 w-5 rounded-[3px] shadow-sm" aria-hidden="true">
112
+ <rect width="20" height="14" rx="2" fill="#f0c419" />
113
+ <rect width="20" height="3" y="0" fill="#c53030" />
114
+ <rect width="20" height="3" y="11" fill="#c53030" />
115
+ </svg>
116
+ );
117
+ }
118
+
119
+ return (
120
+ <svg viewBox="0 0 20 14" className="h-3.5 w-5 rounded-[3px] shadow-sm" aria-hidden="true">
121
+ <rect width="20" height="14" rx="2" fill="#1f4aa8" />
122
+ <rect width="20" height="4.6" y="4.7" fill="#fff" />
123
+ <rect width="20" height="4.6" y="9.4" fill="#c53030" />
124
+ </svg>
125
+ );
126
+ }
127
+
128
+ function toMenuNodes(sections: ReturnType<typeof useAppShellSections>): SgMenuNode[] {
129
+ return sections.flatMap((section) =>
130
+ section.items.map(function mapItem(item): SgMenuNode {
131
+ return {
132
+ id: item.id,
133
+ label: item.label,
134
+ url: item.href,
135
+ icon: item.icon,
136
+ badge: item.badge,
137
+ disabled: item.disabled,
138
+ children: item.children?.map(mapItem),
139
+ };
140
+ })
141
+ );
142
+ }
143
+
144
+ const COLOR_PRESETS = [
145
+ { label: "Verde", value: "#16a34a" },
146
+ { label: "Azul", value: "#2563eb" },
147
+ { label: "Roxo", value: "#7c3aed" },
148
+ { label: "Vermelho", value: "#dc2626" },
149
+ { label: "Laranja", value: "#ea580c" },
150
+ { label: "Rosa", value: "#db2777" },
151
+ { label: "Ciano", value: "#0891b2" },
152
+ { label: "Ardósia", value: "#475569" },
153
+ ];
154
+
155
+ export function AppShell({ children }: { children: ReactNode }) {
156
+ const router = useRouter();
157
+ const pathname = usePathname();
158
+ const { locale, setLocale, t } = useI18n();
159
+ const { currentMode, currentTheme, setMode, setTheme } = useSgTheme();
160
+ const auth = useAuth();
161
+ const sections = useAppShellSections();
162
+ const canChangePassword = usePermission("USER_CHANGE_PASSWORD");
163
+ const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
164
+ const [isDesktopViewport, setIsDesktopViewport] = useState(false);
165
+ const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
166
+ const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
167
+ const [currentUserName, setCurrentUserName] = useState("");
168
+ const [currentUserAvatarStorageKey, setCurrentUserAvatarStorageKey] = useState("");
169
+ const [currentUserAvatarVersion, setCurrentUserAvatarVersion] = useState("");
170
+ const [currentUserAvatarObjectUrl, setCurrentUserAvatarObjectUrl] = useState("");
171
+ const [isTwoFactorEnabled, setIsTwoFactorEnabled] = useState(false);
172
+ const [profileRefreshToken, setProfileRefreshToken] = useState(0);
173
+ const userMenuRef = useRef<HTMLDivElement | null>(null);
174
+ const colorPickerRef = useRef<HTMLDivElement | null>(null);
175
+ const isDarkMode = currentMode === "dark";
176
+ const primaryColor = currentTheme.seed;
177
+ const selectedLanguage = enabledLanguageOptions.find(
178
+ (option) => option.id === locale
179
+ );
180
+
181
+ const currentUserLogin = auth.session?.login?.trim() ?? "";
182
+ const currentUserPublicId = auth.session?.publicId?.trim() ?? "";
183
+ const resolvedCurrentUserName =
184
+ currentUserName.trim() || auth.session?.name?.trim() || "";
185
+ const currentUser = resolvedCurrentUserName || currentUserLogin || t("common.brand");
186
+ const currentUserSubtitle =
187
+ resolvedCurrentUserName && currentUserLogin
188
+ ? currentUserLogin
189
+ : t("security.dashboard.user.subtitle");
190
+ const currentUserAvatarUrl =
191
+ currentUserAvatarObjectUrl ||
192
+ (currentUserPublicId
193
+ ? securityService.buildUserAvatarUrl(
194
+ currentUserPublicId,
195
+ currentUserAvatarVersion || currentUserAvatarStorageKey
196
+ )
197
+ : undefined);
198
+ const menuNodes = useMemo(() => toMenuNodes(sections), [sections]);
199
+ const menuSelection = {
200
+ activeUrl: pathname ?? undefined,
201
+ };
202
+ const menuBrand = {
203
+ title: appEnvironment.branding.productName.trim() || "SeedGrid CRM",
204
+ imageSrc: appEnvironment.branding.masterLogoPath.trim() || undefined,
205
+ onClick: () => handleNavigate(securityConfig.routes.dashboard),
206
+ };
207
+ const menuSearch = {
208
+ enabled: true,
209
+ placeholder: t("security.dashboard.menu.search_placeholder"),
210
+ };
211
+ const menuUser = {
212
+ name: currentUser,
213
+ subtitle: currentUserSubtitle,
214
+ avatarSrc: currentUserAvatarUrl,
215
+ avatar: (
216
+ <SgAvatar
217
+ src={currentUserAvatarUrl}
218
+ alt={currentUser}
219
+ label={currentUser.slice(0, 2).toUpperCase()}
220
+ onImageError={handleCurrentUserAvatarError}
221
+ />
222
+ ),
223
+ };
224
+ const shellUserMenu: SgMenuNode[] = [
225
+ {
226
+ id: "edit-profile",
227
+ label: t("security.profile.edit_account"),
228
+ url: securityConfig.routes.profile,
229
+ icon: <PencilLine size={16} />,
230
+ },
231
+ {
232
+ id: "change-password",
233
+ label: t("security.change_password.title"),
234
+ url: securityConfig.routes.changePassword,
235
+ icon: <KeyRound size={16} />,
236
+ disabled: !canChangePassword,
237
+ },
238
+ {
239
+ id: "toggle-two-factor",
240
+ label: t(
241
+ isTwoFactorEnabled
242
+ ? "security.profile.disable_two_factor"
243
+ : "security.profile.enable_two_factor"
244
+ ),
245
+ url: securityConfig.routes.twoFactor,
246
+ icon: <ShieldCheck size={16} />,
247
+ },
248
+ ];
249
+
250
+ useEffect(() => {
251
+ if (typeof window === "undefined") {
252
+ return;
253
+ }
254
+
255
+ const mediaQuery = window.matchMedia("(min-width: 1024px)");
256
+ const syncViewport = () => setIsDesktopViewport(mediaQuery.matches);
257
+
258
+ syncViewport();
259
+ mediaQuery.addEventListener("change", syncViewport);
260
+
261
+ return () => {
262
+ mediaQuery.removeEventListener("change", syncViewport);
263
+ };
264
+ }, []);
265
+
266
+ useEffect(() => {
267
+ setCurrentUserAvatarObjectUrl((current) => {
268
+ if (current.startsWith("blob:")) {
269
+ URL.revokeObjectURL(current);
270
+ }
271
+
272
+ return "";
273
+ });
274
+ }, [currentUserAvatarStorageKey, currentUserAvatarVersion, currentUserPublicId]);
275
+
276
+ useEffect(() => {
277
+ return () => {
278
+ if (currentUserAvatarObjectUrl.startsWith("blob:")) {
279
+ URL.revokeObjectURL(currentUserAvatarObjectUrl);
280
+ }
281
+ };
282
+ }, [currentUserAvatarObjectUrl]);
283
+
284
+ useEffect(() => {
285
+ let active = true;
286
+
287
+ void (async () => {
288
+ if (auth.session?.name?.trim()) {
289
+ setCurrentUserName(auth.session.name.trim());
290
+ }
291
+
292
+ if (!currentUserPublicId && !currentUserLogin) {
293
+ setCurrentUserName("");
294
+ setCurrentUserAvatarStorageKey("");
295
+ setCurrentUserAvatarVersion("");
296
+ return;
297
+ }
298
+
299
+ try {
300
+ let resolvedName = "";
301
+
302
+ if (currentUserPublicId) {
303
+ const user = await securityService.getUser(currentUserPublicId);
304
+ resolvedName = user?.name?.trim() ?? "";
305
+ if (active) {
306
+ const nextAvatarStorageKey = user?.avatarStorageKey?.trim() ?? "";
307
+ setCurrentUserAvatarStorageKey(nextAvatarStorageKey);
308
+ setCurrentUserAvatarVersion(nextAvatarStorageKey);
309
+ }
310
+ } else {
311
+ const response = await securityService.listUsersPage({
312
+ page: 0,
313
+ size: 20,
314
+ search: currentUserLogin,
315
+ sort: {
316
+ sortField: "email",
317
+ sortOrder: 1,
318
+ },
319
+ });
320
+
321
+ const matchedUser =
322
+ response.items.find(
323
+ (user) => user.email?.toLowerCase() === currentUserLogin.toLowerCase()
324
+ ) ??
325
+ response.items.find(
326
+ (user) => user.name?.toLowerCase() === currentUserLogin.toLowerCase()
327
+ ) ??
328
+ null;
329
+
330
+ resolvedName = matchedUser?.name?.trim() ?? "";
331
+ if (active) {
332
+ const nextAvatarStorageKey =
333
+ matchedUser?.avatarStorageKey?.trim() ?? "";
334
+ setCurrentUserAvatarStorageKey(nextAvatarStorageKey);
335
+ setCurrentUserAvatarVersion(nextAvatarStorageKey);
336
+ }
337
+ }
338
+
339
+ if (!active) {
340
+ return;
341
+ }
342
+
343
+ setCurrentUserName(resolvedName);
344
+ } catch {
345
+ if (active) {
346
+ setCurrentUserName("");
347
+ setCurrentUserAvatarStorageKey("");
348
+ setCurrentUserAvatarVersion("");
349
+ }
350
+ }
351
+ })();
352
+
353
+ return () => {
354
+ active = false;
355
+ };
356
+ }, [auth.session?.name, currentUserLogin, currentUserPublicId, profileRefreshToken]);
357
+
358
+ async function handleCurrentUserAvatarError() {
359
+ if (!currentUserPublicId || currentUserAvatarObjectUrl) {
360
+ return;
361
+ }
362
+
363
+ const fallbackObjectUrl = await securityService.fetchUserAvatarObjectUrl(
364
+ currentUserPublicId,
365
+ currentUserAvatarVersion || currentUserAvatarStorageKey
366
+ );
367
+
368
+ if (!fallbackObjectUrl) {
369
+ return;
370
+ }
371
+
372
+ setCurrentUserAvatarObjectUrl((current) => {
373
+ if (current.startsWith("blob:")) {
374
+ URL.revokeObjectURL(current);
375
+ }
376
+
377
+ return fallbackObjectUrl;
378
+ });
379
+ }
380
+
381
+ useEffect(() => {
382
+ let active = true;
383
+
384
+ void (async () => {
385
+ try {
386
+ const status = await securityService.getTwoFactorStatus();
387
+
388
+ if (active) {
389
+ setIsTwoFactorEnabled(Boolean(status.enabled));
390
+ }
391
+ } catch {
392
+ if (active) {
393
+ setIsTwoFactorEnabled(false);
394
+ }
395
+ }
396
+ })();
397
+
398
+ return () => {
399
+ active = false;
400
+ };
401
+ }, [pathname]);
402
+
403
+ useEffect(() => {
404
+ function handlePointerDown(event: MouseEvent) {
405
+ if (!userMenuRef.current?.contains(event.target as Node)) {
406
+ setIsUserMenuOpen(false);
407
+ }
408
+ if (!colorPickerRef.current?.contains(event.target as Node)) {
409
+ setIsColorPickerOpen(false);
410
+ }
411
+ }
412
+
413
+ function handleKeyDown(event: KeyboardEvent) {
414
+ if (event.key === "Escape") {
415
+ setIsMobileSidebarOpen(false);
416
+ setIsUserMenuOpen(false);
417
+ setIsColorPickerOpen(false);
418
+ }
419
+ }
420
+
421
+ function handleProfileUpdated(event: Event) {
422
+ const detail =
423
+ event instanceof CustomEvent && event.detail && typeof event.detail === "object"
424
+ ? (event.detail as {
425
+ publicId?: string;
426
+ avatarStorageKey?: string;
427
+ avatarVersion?: string;
428
+ name?: string;
429
+ })
430
+ : null;
431
+
432
+ if (detail?.publicId && detail.publicId !== currentUserPublicId) {
433
+ return;
434
+ }
435
+
436
+ if (typeof detail?.name === "string") {
437
+ setCurrentUserName(detail.name.trim());
438
+ }
439
+
440
+ if (typeof detail?.avatarStorageKey === "string") {
441
+ setCurrentUserAvatarStorageKey(detail.avatarStorageKey.trim());
442
+ }
443
+
444
+ if (typeof detail?.avatarVersion === "string") {
445
+ setCurrentUserAvatarVersion(detail.avatarVersion.trim());
446
+ }
447
+
448
+ setProfileRefreshToken((current) => current + 1);
449
+ }
450
+
451
+ document.addEventListener("mousedown", handlePointerDown);
452
+ document.addEventListener("keydown", handleKeyDown);
453
+ window.addEventListener("seedgrid:profile-updated", handleProfileUpdated);
454
+
455
+ return () => {
456
+ document.removeEventListener("mousedown", handlePointerDown);
457
+ document.removeEventListener("keydown", handleKeyDown);
458
+ window.removeEventListener("seedgrid:profile-updated", handleProfileUpdated);
459
+ };
460
+ }, [currentUserPublicId]);
461
+
462
+ useEffect(() => {
463
+ setIsMobileSidebarOpen(false);
464
+ setIsUserMenuOpen(false);
465
+ setIsColorPickerOpen(false);
466
+ }, [pathname]);
467
+
468
+ async function handleSignOut() {
469
+ await auth.logout();
470
+ router.replace(securityConfig.routes.login);
471
+ }
472
+
473
+ function handleNavigate(href: string) {
474
+ setIsMobileSidebarOpen(false);
475
+ router.push(href);
476
+ }
477
+
478
+ function handleMenuNode(node: SgMenuNode) {
479
+ if (node.url) {
480
+ handleNavigate(node.url);
481
+ }
482
+
483
+ node.onClick?.();
484
+ }
485
+
486
+ return (
487
+ <div
488
+ className={`min-h-screen ${
489
+ isDarkMode ? "bg-slate-950 text-slate-100" : "bg-[#f4f7fb] text-slate-900"
490
+ }`}
491
+ >
492
+ <div className="lg:grid lg:min-h-screen lg:grid-cols-[18.5rem_minmax(0,1fr)]">
493
+ <div
494
+ className={`fixed inset-0 z-40 bg-slate-950/40 backdrop-blur-sm transition lg:hidden ${
495
+ isMobileSidebarOpen ? "opacity-100" : "pointer-events-none opacity-0"
496
+ }`}
497
+ onClick={() => setIsMobileSidebarOpen(false)}
498
+ aria-hidden="true"
499
+ />
500
+
501
+ {!isDesktopViewport ? (
502
+ <aside
503
+ className={`fixed inset-y-0 left-0 z-50 w-[18.5rem] max-w-[88vw] border-r shadow-2xl transition-transform lg:hidden ${
504
+ isDarkMode ? "border-slate-800 bg-slate-900" : "border-slate-200 bg-white"
505
+ } ${isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full"}`}
506
+ >
507
+ <SgMenu
508
+ id={`app-shell-mobile-menu-${pathname ?? "root"}`}
509
+ menu={menuNodes}
510
+ selection={menuSelection}
511
+ brand={menuBrand}
512
+ user={menuUser}
513
+ userMenu={shellUserMenu}
514
+ menuStyle="inline"
515
+ menuVariantStyle="tiered"
516
+ mode="multiple"
517
+ closeOnNavigate
518
+ search={menuSearch}
519
+ onNavigate={handleMenuNode}
520
+ onAction={handleMenuNode}
521
+ onItemClick={handleMenuNode}
522
+ className="h-full overflow-y-auto border-0"
523
+ />
524
+ </aside>
525
+ ) : null}
526
+
527
+ {isDesktopViewport ? (
528
+ <aside
529
+ className={`hidden border-r lg:block ${
530
+ isDarkMode ? "border-slate-800 bg-slate-900" : "border-slate-200 bg-white"
531
+ }`}
532
+ >
533
+ <SgMenu
534
+ id={`app-shell-sidebar-menu-${pathname ?? "root"}`}
535
+ menu={menuNodes}
536
+ selection={menuSelection}
537
+ brand={menuBrand}
538
+ user={menuUser}
539
+ userMenu={shellUserMenu}
540
+ menuStyle="sidebar"
541
+ menuVariantStyle="tiered"
542
+ mode="multiple"
543
+ showCollapseButton
544
+ closeOnNavigate
545
+ search={menuSearch}
546
+ onNavigate={handleMenuNode}
547
+ onAction={handleMenuNode}
548
+ onItemClick={handleMenuNode}
549
+ className="h-full border-0"
550
+ />
551
+ </aside>
552
+ ) : null}
553
+
554
+ <div className="flex min-h-screen min-w-0 flex-col">
555
+ <header
556
+ className={`sticky top-0 z-30 border-b backdrop-blur ${
557
+ isDarkMode
558
+ ? "border-slate-800 bg-slate-950/92"
559
+ : "border-slate-200 bg-white/92"
560
+ }`}
561
+ >
562
+ <div className="flex h-[4.5rem] items-center gap-3 px-4 sm:px-6 lg:px-8">
563
+ <SgButton
564
+ appearance="ghost"
565
+ severity="secondary"
566
+ iconOnly
567
+ leftIcon={<Menu size={20} />}
568
+ onClick={() => setIsMobileSidebarOpen(true)}
569
+ className={`border transition lg:hidden ${
570
+ isDarkMode
571
+ ? "border-slate-700 bg-slate-900 text-slate-300 hover:border-slate-600 hover:text-slate-50"
572
+ : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-950"
573
+ }`}
574
+ aria-label="Abrir menu"
575
+ />
576
+
577
+ <div className="ml-auto flex items-center gap-2 sm:gap-3">
578
+ <div className="hidden sm:block">
579
+ <SgCombobox
580
+ id="shell-language"
581
+ width={188}
582
+ placeholder="Idioma"
583
+ source={enabledLanguageOptions}
584
+ value={selectedLanguage?.id ?? "pt-BR"}
585
+ mapItem={(option) => ({
586
+ id: option.id,
587
+ value: option.value,
588
+ label: option.label,
589
+ data: {
590
+ flag: option.flag,
591
+ locale: option.id,
592
+ label: option.label,
593
+ },
594
+ })}
595
+ onValueChange={(value) =>
596
+ setLocale((value as LocaleOption) ?? "pt-BR")
597
+ }
598
+ renderItem={(item) => {
599
+ const itemData = item.data as
600
+ | { flag?: string; label?: string; locale?: LocaleOption }
601
+ | undefined;
602
+
603
+ return (
604
+ <div className="flex items-center gap-3">
605
+ <LocaleFlag locale={itemData?.locale ?? "pt-BR"} />
606
+ <span>{itemData?.label ?? item.label}</span>
607
+ </div>
608
+ );
609
+ }}
610
+ renderValue={(item) => {
611
+ const itemData = item?.data as
612
+ | { flag?: string; label?: string; locale?: LocaleOption }
613
+ | undefined;
614
+
615
+ if (!item) {
616
+ return null;
617
+ }
618
+
619
+ return (
620
+ <div className="flex items-center gap-2">
621
+ <LocaleFlag locale={itemData?.locale ?? "pt-BR"} />
622
+ <span className="truncate">{itemData?.label ?? item.label}</span>
623
+ </div>
624
+ );
625
+ }}
626
+ />
627
+ </div>
628
+
629
+ <div
630
+ className={`rounded-full border px-1 py-1 ${
631
+ isDarkMode
632
+ ? "border-slate-700 bg-slate-900"
633
+ : "border-slate-200 bg-slate-50"
634
+ }`}
635
+ >
636
+ <SgToggleSwitch
637
+ id="shell-theme"
638
+ checked={isDarkMode}
639
+ onChange={(checked) => setMode(checked ? "dark" : "light")}
640
+ onIcon={<Sun size={12} />}
641
+ offIcon={<Moon size={12} />}
642
+ />
643
+ </div>
644
+
645
+ <div className="relative" ref={colorPickerRef}>
646
+ <SgButton
647
+ appearance="ghost"
648
+ severity="secondary"
649
+ iconOnly
650
+ leftIcon={<Palette size={18} />}
651
+ onClick={() => setIsColorPickerOpen((current) => !current)}
652
+ className={`size-11 flex items-center justify-center rounded-full border transition ${
653
+ isDarkMode
654
+ ? "border-slate-700 bg-slate-900 text-slate-400 hover:border-slate-600 hover:text-slate-50"
655
+ : "border-slate-200 bg-white text-slate-500 hover:border-slate-300 hover:text-slate-950"
656
+ }`}
657
+ aria-label="Cor do tema"
658
+ />
659
+
660
+ {isColorPickerOpen ? (
661
+ <div
662
+ className={`absolute right-0 top-full z-20 mt-3 w-52 overflow-hidden rounded-3xl border p-3 shadow-2xl ${
663
+ isDarkMode
664
+ ? "border-slate-800 bg-slate-900 shadow-black/30"
665
+ : "border-slate-200 bg-white shadow-slate-900/10"
666
+ }`}
667
+ >
668
+ <p
669
+ className={`mb-2.5 px-1 text-xs font-semibold uppercase tracking-wide ${
670
+ isDarkMode ? "text-slate-400" : "text-slate-500"
671
+ }`}
672
+ >
673
+ Cor do tema
674
+ </p>
675
+ <div className="grid grid-cols-4 gap-2">
676
+ {COLOR_PRESETS.map((preset) => (
677
+ <SgButton
678
+ key={preset.value}
679
+ appearance="ghost"
680
+ severity="secondary"
681
+ title={preset.label}
682
+ onClick={() => {
683
+ setTheme({ seed: preset.value });
684
+ setIsColorPickerOpen(false);
685
+ }}
686
+ className="relative flex h-9 w-full items-center justify-center rounded-xl transition hover:scale-110"
687
+ style=\{{ backgroundColor: preset.value, border: "none" }}
688
+ aria-label={preset.label}
689
+ >
690
+ {primaryColor === preset.value ? (
691
+ <span className="block h-2.5 w-2.5 rounded-full bg-white shadow" />
692
+ ) : null}
693
+ </SgButton>
694
+ ))}
695
+ </div>
696
+ </div>
697
+ ) : null}
698
+ </div>
699
+
700
+ <SgButton
701
+ appearance="ghost"
702
+ severity="secondary"
703
+ iconOnly
704
+ leftIcon={<Mail size={18} />}
705
+ className={`hidden size-11 items-center justify-center rounded-full border transition sm:flex ${
706
+ isDarkMode
707
+ ? "border-slate-700 bg-slate-900 text-slate-400 hover:border-slate-600 hover:text-slate-50"
708
+ : "border-slate-200 bg-white text-slate-500 hover:border-slate-300 hover:text-slate-950"
709
+ }`}
710
+ aria-label="Mensagens"
711
+ />
712
+
713
+ <SgButton
714
+ appearance="ghost"
715
+ severity="secondary"
716
+ iconOnly
717
+ leftIcon={<Bell size={18} />}
718
+ className={`hidden size-11 items-center justify-center rounded-full border transition sm:flex ${
719
+ isDarkMode
720
+ ? "border-slate-700 bg-slate-900 text-slate-400 hover:border-slate-600 hover:text-slate-50"
721
+ : "border-slate-200 bg-white text-slate-500 hover:border-slate-300 hover:text-slate-950"
722
+ }`}
723
+ aria-label="Notificacoes"
724
+ />
725
+
726
+ <div className="relative" ref={userMenuRef}>
727
+ <SgButton
728
+ appearance="ghost"
729
+ severity="secondary"
730
+ aria-haspopup="menu"
731
+ aria-expanded={isUserMenuOpen}
732
+ onClick={() => setIsUserMenuOpen((current) => !current)}
733
+ className={`rounded-full border pl-2 pr-3 py-1.5 transition ${
734
+ isDarkMode
735
+ ? "border-slate-700 bg-slate-900 hover:border-slate-600"
736
+ : "border-slate-200 bg-white hover:border-slate-300"
737
+ }`}
738
+ >
739
+ <span className="flex items-center gap-3">
740
+ <SgAvatar
741
+ src={currentUserAvatarUrl}
742
+ alt={currentUser}
743
+ label={currentUser.slice(0, 2).toUpperCase()}
744
+ onImageError={handleCurrentUserAvatarError}
745
+ />
746
+ <span className="hidden text-left sm:block">
747
+ <span
748
+ className={`block max-w-36 truncate text-sm font-semibold ${
749
+ isDarkMode ? "text-slate-50" : "text-slate-950"
750
+ }`}
751
+ >
752
+ {currentUser}
753
+ </span>
754
+ <span
755
+ className={`block max-w-36 truncate text-xs ${
756
+ isDarkMode ? "text-slate-400" : "text-slate-500"
757
+ }`}
758
+ >
759
+ {currentUserSubtitle}
760
+ </span>
761
+ </span>
762
+ <ChevronDown
763
+ size={16}
764
+ className={`hidden sm:block ${
765
+ isDarkMode ? "text-slate-500" : "text-slate-400"
766
+ }`}
767
+ />
768
+ </span>
769
+ </SgButton>
770
+
771
+ {isUserMenuOpen ? (
772
+ <div
773
+ className={`absolute right-0 top-full z-20 mt-3 w-64 overflow-hidden rounded-3xl border p-2 shadow-2xl ${
774
+ isDarkMode
775
+ ? "border-slate-800 bg-slate-900 shadow-black/30"
776
+ : "border-slate-200 bg-white shadow-slate-900/10"
777
+ }`}
778
+ >
779
+ <div
780
+ className={`rounded-2xl px-4 py-3 ${
781
+ isDarkMode ? "bg-slate-950/80" : "bg-slate-50"
782
+ }`}
783
+ >
784
+ <div
785
+ className={`text-sm font-semibold ${
786
+ isDarkMode ? "text-slate-50" : "text-slate-950"
787
+ }`}
788
+ >
789
+ {currentUser}
790
+ </div>
791
+ <div
792
+ className={`text-xs ${
793
+ isDarkMode ? "text-slate-400" : "text-slate-500"
794
+ }`}
795
+ >
796
+ {currentUserSubtitle}
797
+ </div>
798
+ </div>
799
+
800
+ <SgStack gap={4} className="mt-2">
801
+ <SgButton
802
+ appearance="ghost"
803
+ severity="secondary"
804
+ leftIcon={<UserCog size={16} />}
805
+ onClick={() => {
806
+ setIsUserMenuOpen(false);
807
+ router.push(securityConfig.routes.profile);
808
+ }}
809
+ className={`w-full justify-start rounded-2xl px-4 py-3 text-left text-sm transition ${
810
+ isDarkMode
811
+ ? "text-slate-300 hover:bg-slate-800 hover:text-slate-50"
812
+ : "text-slate-600 hover:bg-slate-100 hover:text-slate-950"
813
+ }`}
814
+ >
815
+ {t("security.profile.edit_account")}
816
+ </SgButton>
817
+
818
+ {canChangePassword ? (
819
+ <SgButton
820
+ appearance="ghost"
821
+ severity="secondary"
822
+ leftIcon={<UserCog size={16} />}
823
+ onClick={() => {
824
+ setIsUserMenuOpen(false);
825
+ router.push(securityConfig.routes.changePassword);
826
+ }}
827
+ className={`w-full justify-start rounded-2xl px-4 py-3 text-left text-sm transition ${
828
+ isDarkMode
829
+ ? "text-slate-300 hover:bg-slate-800 hover:text-slate-50"
830
+ : "text-slate-600 hover:bg-slate-100 hover:text-slate-950"
831
+ }`}
832
+ >
833
+ {t("security.change_password.title")}
834
+ </SgButton>
835
+ ) : null}
836
+
837
+ <SgButton
838
+ appearance="ghost"
839
+ severity="secondary"
840
+ leftIcon={<ShieldCheck size={16} />}
841
+ onClick={() => {
842
+ setIsUserMenuOpen(false);
843
+ router.push(securityConfig.routes.twoFactor);
844
+ }}
845
+ className={`w-full justify-start rounded-2xl px-4 py-3 text-left text-sm transition ${
846
+ isDarkMode
847
+ ? "text-slate-300 hover:bg-slate-800 hover:text-slate-50"
848
+ : "text-slate-600 hover:bg-slate-100 hover:text-slate-950"
849
+ }`}
850
+ >
851
+ {t(
852
+ isTwoFactorEnabled
853
+ ? "security.profile.disable_two_factor"
854
+ : "security.profile.enable_two_factor"
855
+ )}
856
+ </SgButton>
857
+
858
+ <SgButton
859
+ appearance="ghost"
860
+ severity="danger"
861
+ leftIcon={<LogOut size={16} />}
862
+ onClick={() => {
863
+ setIsUserMenuOpen(false);
864
+ void handleSignOut();
865
+ }}
866
+ className={`w-full justify-start rounded-2xl px-4 py-3 text-left text-sm text-rose-600 transition ${
867
+ isDarkMode ? "hover:bg-rose-950/40" : "hover:bg-rose-50"
868
+ }`}
869
+ >
870
+ {t("security.login.signout")}
871
+ </SgButton>
872
+ </SgStack>
873
+ </div>
874
+ ) : null}
875
+ </div>
876
+ </div>
877
+ </div>
878
+ </header>
879
+
880
+ <main className="min-h-0 flex-1 overflow-x-hidden">
881
+ <div className="mx-auto flex min-h-full w-full max-w-[1600px] flex-col px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
882
+ <div className="min-h-0 flex-1">{children}</div>
883
+ </div>
884
+ </main>
885
+ </div>
886
+ </div>
887
+ </div>
888
+ );
889
+ }