larxius commited on
Commit
7e4a932
·
verified ·
1 Parent(s): 8a2c342

Update frontend/src/components/Layout.jsx

Browse files
Files changed (1) hide show
  1. frontend/src/components/Layout.jsx +496 -494
frontend/src/components/Layout.jsx CHANGED
@@ -1,494 +1,496 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
- import { Link, NavLink, useNavigate, useLocation } from 'react-router-dom';
3
- import { useAuth } from './AuthContext';
4
-
5
- export const Layout = ({ children }) => {
6
- const { user, logout } = useAuth();
7
- const navigate = useNavigate();
8
- const location = useLocation();
9
-
10
- const searchParams = new URLSearchParams(location.search);
11
- const urlQuery = searchParams.get('q') || '';
12
- const [globalSearchQuery, setGlobalSearchQuery] = useState(urlQuery);
13
-
14
- useEffect(() => {
15
- setGlobalSearchQuery(urlQuery);
16
- }, [urlQuery]);
17
-
18
- const [isDemoMode, setIsDemoMode] = useState(false);
19
- const [isProfileOpen, setIsProfileOpen] = useState(false);
20
- const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
21
- const [notifications, setNotifications] = useState([]);
22
- const [loadingNotifications, setLoadingNotifications] = useState(false);
23
- const [hasUnreadNotifications, setHasUnreadNotifications] = useState(false);
24
-
25
- const notificationsRef = useRef(null);
26
- const profileRef = useRef(null);
27
-
28
- useEffect(() => {
29
- const handleClickOutside = (event) => {
30
- if (notificationsRef.current && !notificationsRef.current.contains(event.target)) {
31
- setIsNotificationsOpen(false);
32
- }
33
- if (profileRef.current && !profileRef.current.contains(event.target)) {
34
- setIsProfileOpen(false);
35
- }
36
- };
37
- document.addEventListener("mousedown", handleClickOutside);
38
- return () => {
39
- document.removeEventListener("mousedown", handleClickOutside);
40
- };
41
- }, []);
42
-
43
- const fetchNotifications = async () => {
44
- if (!user) return;
45
- setLoadingNotifications(true);
46
- try {
47
- const token = localStorage.getItem('wss_token') || sessionStorage.getItem('wss_token');
48
- const res = await fetch('/api/auth/notifications', {
49
- headers: { 'Authorization': `Bearer ${token}` }
50
- });
51
- if (res.ok) {
52
- const data = await res.json();
53
- const fetched = data.notifications || [];
54
-
55
- const lastSeenId = localStorage.getItem('last_seen_notification_id');
56
-
57
- if (fetched.length > 0 && String(fetched[0].id) !== lastSeenId) {
58
- setHasUnreadNotifications(true);
59
- }
60
-
61
- setNotifications(fetched);
62
- }
63
- } catch (err) {
64
- console.error("Failed to fetch notifications:", err);
65
- } finally {
66
- setLoadingNotifications(false);
67
- }
68
- };
69
-
70
- useEffect(() => {
71
- if (user) {
72
- fetchNotifications();
73
- const interval = setInterval(fetchNotifications, 60000); // refresh every minute
74
- return () => clearInterval(interval);
75
- }
76
- }, [user]);
77
- useEffect(() => {
78
- setIsDemoMode(!!window.WSS_DEMO_MODE);
79
- const checkInterval = setInterval(() => {
80
- setIsDemoMode(!!window.WSS_DEMO_MODE);
81
- }, 1000);
82
- return () => clearInterval(checkInterval);
83
- }, []);
84
-
85
- // Force removal of dark theme and clear localStorage keys
86
- useEffect(() => {
87
- document.documentElement.classList.remove('dark');
88
- localStorage.removeItem('color-theme');
89
- }, []);
90
-
91
- const [impersonationToken, setImpersonationToken] = useState(null);
92
- const [organizations, setOrganizations] = useState([]);
93
-
94
- useEffect(() => {
95
- setImpersonationToken(localStorage.getItem('original_admin_token'));
96
- }, [location.pathname]);
97
-
98
- useEffect(() => {
99
- const fetchOrgs = async () => {
100
- const adminToken = localStorage.getItem('original_admin_token') || localStorage.getItem('wss_token') || sessionStorage.getItem('wss_token');
101
- // Only attempt if they might be an admin
102
- if (!adminToken) return;
103
-
104
- try {
105
- const res = await fetch('/api/auth/organizations', {
106
- headers: { 'Authorization': `Bearer ${adminToken}` }
107
- });
108
- if (res.ok) {
109
- const data = await res.json();
110
- setOrganizations(data.organizations || []);
111
- }
112
- } catch (err) {
113
- console.error("Failed to fetch organizations for dropdown", err);
114
- }
115
- };
116
-
117
- const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'admin' || localStorage.getItem('original_admin_token');
118
- if (isSuperAdmin) {
119
- fetchOrgs();
120
- }
121
- }, [user]);
122
-
123
- const handleReturnToAdmin = () => {
124
- const orig = localStorage.getItem('original_admin_token');
125
- if (orig) {
126
- localStorage.removeItem('original_admin_token');
127
- localStorage.setItem('wss_token', orig);
128
- window.location.href = location.pathname;
129
- }
130
- };
131
-
132
- const handleLogout = () => {
133
- const isSuperAdmin = user?.role === 'super_admin' || sessionStorage.getItem('superAdminAuth') === 'true';
134
- logout();
135
- sessionStorage.removeItem('superAdminAuth');
136
- localStorage.removeItem('original_admin_token');
137
- navigate(isSuperAdmin ? '/' : '/login');
138
- };
139
-
140
- let navItems = [];
141
-
142
- if (user?.role === 'executive_user') {
143
- navItems = [
144
- { to: '/scans/history', label: 'Organization Reports', icon: 'analytics' },
145
- { to: '/settings', label: 'Settings', icon: 'settings' },
146
- ];
147
- } else {
148
- navItems = [
149
- { to: '/dashboard', label: 'Dashboard', icon: 'dashboard' },
150
- { to: '/scans/new', label: 'New Scan', icon: 'security' },
151
- { to: '/scans/history', label: 'Reports', icon: 'analytics' },
152
- { to: '/scans/results', label: 'Vulnerabilities', icon: 'bug_report' },
153
- { to: '/settings', label: 'Settings', icon: 'settings' },
154
- ];
155
- }
156
-
157
- if (user?.role === 'super_admin') {
158
- navItems.push({ to: '/super-admin', label: 'Global Management', icon: 'admin_panel_settings' });
159
- }
160
-
161
- return (
162
- <div className="bg-background text-on-background font-body-md text-body-md antialiased min-h-screen flex flex-col md:flex-row w-full transition-colors duration-300">
163
-
164
- {/* SideNavBar (Stitch Layout) */}
165
- <nav className="bg-white dark:bg-inverse-surface h-screen w-64 flex-col fixed left-0 top-0 border-r border-outline-variant dark:border-outline z-40 hidden md:flex py-lg px-md gap-sm text-left">
166
-
167
- {/* Header Branding */}
168
- <div className="mb-xl flex flex-col gap-sm">
169
- <div className="flex items-center gap-sm">
170
- <div className="h-14 flex items-center justify-center">
171
- <img src="/logo.png" alt="LarShield Logo" className="h-full object-contain" />
172
- </div>
173
- <div>
174
- <h1 className="font-bold brand-gradient tracking-tight m-0 text-[15px] leading-snug">LarShield</h1>
175
- <p className="font-label-sm text-[12px] text-on-surface-variant uppercase tracking-wider m-0">
176
- {(user?.role === 'super_admin' || user?.role === 'admin') ? 'Admin' :
177
- user?.role === 'org_admin' ? 'Organization' :
178
- user?.subscription_tier || 'Free Tier'}
179
- </p>
180
- </div>
181
- </div>
182
-
183
- {user?.role !== 'executive_user' && (
184
- <NavLink
185
- to="/scans/new"
186
- className="mt-md w-full bg-primary text-on-primary rounded-lg py-sm px-md font-label-md text-label-md flex items-center justify-center gap-xs hover:opacity-90 transition-opacity border-0 cursor-pointer"
187
- style={{ textDecoration: 'none' }}
188
- >
189
- <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>add</span>
190
- Quick Scan
191
- </NavLink>
192
- )}
193
- </div>
194
-
195
- {/* Navigation Links */}
196
- <div className="flex flex-col gap-base flex-1">
197
- {navItems.map((item) => (
198
- <NavLink
199
- key={item.to}
200
- to={item.to}
201
- className={({ isActive }) =>
202
- `flex items-center gap-sm px-sm py-sm rounded-lg transition-colors cursor-pointer transition-all duration-200 border-0 ${
203
- isActive
204
- ? 'text-primary dark:text-primary-fixed-dim font-bold bg-surface-container-high dark:bg-on-secondary-fixed-variant'
205
- : 'text-on-surface-variant dark:text-surface-variant hover:bg-surface-container-high dark:hover:bg-on-secondary-fixed-variant'
206
- }`
207
- }
208
- style={{ textDecoration: 'none' }}
209
- >
210
- <span className="material-symbols-outlined">{item.icon}</span>
211
- <span className="font-label-md text-label-md">{item.label}</span>
212
- </NavLink>
213
- ))}
214
-
215
- {/* Help Page Link */}
216
- <NavLink
217
- to="/help"
218
- className={({ isActive }) =>
219
- `flex items-center gap-sm px-sm py-sm rounded-lg transition-colors cursor-pointer transition-all duration-200 mt-auto border-0 ${
220
- isActive
221
- ? 'text-primary dark:text-primary-fixed-dim font-bold bg-surface-container-high dark:bg-on-secondary-fixed-variant'
222
- : 'text-on-surface-variant dark:text-surface-variant hover:bg-surface-container-high dark:hover:bg-on-secondary-fixed-variant'
223
- }`
224
- }
225
- style={{ textDecoration: 'none' }}
226
- >
227
- <span className="material-symbols-outlined">help</span>
228
- <span className="font-label-md text-label-md">Help</span>
229
- </NavLink>
230
- </div>
231
-
232
-
233
- {/* Sidebar Status / Log Out */}
234
- <div className="mt-md pt-md border-t border-outline-variant">
235
- <button
236
- onClick={handleLogout}
237
- className="w-full flex items-center gap-sm px-sm py-sm rounded-lg text-on-surface-variant dark:text-surface-variant hover:bg-surface-container-high dark:hover:bg-on-secondary-fixed-variant transition-colors cursor-pointer transition-all duration-200 border-0 bg-transparent text-left"
238
- >
239
- <span className="material-symbols-outlined">logout</span>
240
- <span className="font-label-md text-label-md">Log Out</span>
241
- </button>
242
- </div>
243
- </nav>
244
-
245
- {/* TopNavBar (Stitch Layout) */}
246
- <header className="bg-surface/80 dark:bg-inverse-surface/80 backdrop-blur-md fixed top-0 w-full z-50 border-b border-outline-variant dark:border-outline shadow-sm flex justify-between items-center h-16 px-gutter max-w-container-max mx-auto md:w-[calc(100%-16rem)] md:left-64 md:px-lg">
247
-
248
- {/* Mobile Hamburger menu */}
249
- <div className="md:hidden flex items-center gap-sm">
250
- <span className="material-symbols-outlined text-primary cursor-pointer">menu</span>
251
- <span className="font-bold brand-gradient text-[15px] leading-snug">LarShield</span>
252
- </div>
253
-
254
- {/* Search Bar Utility */}
255
- <div className="hidden md:flex flex-1 max-w-md ml-xl relative">
256
- <span className="material-symbols-outlined absolute left-sm top-1/2 -translate-y-1/2 text-outline" style={{ fontSize: '20px' }}>search</span>
257
- <input
258
- className="w-full bg-surface-container-lowest border border-outline-variant rounded-lg pl-xl pr-sm py-xs font-body-sm text-body-sm text-on-surface focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all placeholder:text-outline"
259
- placeholder="Search vulnerabilities, reports, assets..."
260
- type="text"
261
- value={globalSearchQuery}
262
- onChange={(e) => setGlobalSearchQuery(e.target.value)}
263
- onKeyDown={(e) => {
264
- if (e.key === 'Enter') {
265
- if (globalSearchQuery.trim()) {
266
- navigate(`/scans/history?q=${encodeURIComponent(globalSearchQuery.trim())}`);
267
- } else {
268
- navigate(`/scans/history`);
269
- }
270
- }
271
- }}
272
- />
273
- </div>
274
-
275
- {/* Right Nav Icons / Mode Selector */}
276
- <div className="flex items-center gap-gutter ml-auto">
277
-
278
- {/* Organization Display / Admin Dropdown */}
279
- {(user?.role === 'admin' || user?.role === 'super_admin' || impersonationToken) && organizations.length > 0 ? (
280
- <div className="flex items-center">
281
- <select
282
- className="bg-surface-container border border-outline-variant rounded-md px-sm py-xs font-label-sm text-on-surface focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary max-w-[200px] truncate"
283
- value={impersonationToken ? (user?.org_id || '') : ''}
284
- onChange={async (e) => {
285
- const targetOrgId = e.target.value;
286
- if (!targetOrgId) {
287
- // Return to Admin
288
- handleReturnToAdmin();
289
- return;
290
- }
291
-
292
- try {
293
- const activeToken = localStorage.getItem('original_admin_token') || localStorage.getItem('wss_token');
294
- const res = await fetch(`/api/auth/impersonate/${targetOrgId}`, {
295
- method: 'POST',
296
- headers: { 'Authorization': `Bearer ${activeToken}` }
297
- });
298
-
299
- if (res.ok) {
300
- const data = await res.json();
301
- localStorage.setItem('original_admin_token', activeToken);
302
- localStorage.setItem('wss_token', data.access_token);
303
- window.location.href = location.pathname; // Reload current page with new token
304
- }
305
- } catch (err) {
306
- console.error("Failed to impersonate from dropdown:", err);
307
- }
308
- }}
309
- >
310
- <option value="">-- Return to Admin --</option>
311
- {organizations.map(org => (
312
- <option key={org.id} value={org.id}>{org.name}</option>
313
- ))}
314
- </select>
315
- </div>
316
- ) : user?.org_name ? (
317
- <div className="hidden md:flex items-center gap-xs font-label-md text-label-md text-on-surface bg-surface-container-low px-sm py-xs rounded-md border border-outline-variant">
318
- <span className="material-symbols-outlined text-[18px] text-primary">domain</span>
319
- <span className="font-bold">{user.org_name}</span>
320
- </div>
321
- ) : null}
322
-
323
- {/* Active Status Badge */}
324
- {isDemoMode ? (
325
- <div className="hidden md:inline-flex items-center gap-xs bg-yellow-500/10 border border-yellow-500/30 rounded-full px-sm py-[2px] font-label-sm text-label-sm text-yellow-600 dark:text-yellow-500 font-bold uppercase tracking-wider">
326
- <span className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span> Sandbox Mode
327
- </div>
328
- ) : (
329
- <div className="hidden md:inline-flex items-center gap-xs bg-green-500/10 border border-green-500/30 rounded-full px-sm py-[2px] font-label-sm text-label-sm text-green-600 dark:text-green-500 font-bold uppercase tracking-wider">
330
- <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span> Connected
331
- </div>
332
- )}
333
-
334
-
335
-
336
- {/* Profile & Controls */}
337
- <div className="flex items-center gap-sm">
338
- <div className="relative" ref={notificationsRef}>
339
- <button
340
- onClick={() => {
341
- const willOpen = !isNotificationsOpen;
342
- setIsNotificationsOpen(willOpen);
343
- setIsProfileOpen(false);
344
- if (willOpen) {
345
- setHasUnreadNotifications(false);
346
- if (notifications.length > 0) {
347
- localStorage.setItem('last_seen_notification_id', String(notifications[0].id));
348
- }
349
- }
350
- }}
351
- className="text-on-surface-variant dark:text-surface-variant hover:text-primary transition-colors duration-200 active:opacity-80 transition-all flex items-center justify-center relative border-0 bg-transparent cursor-pointer"
352
- >
353
- <span className="material-symbols-outlined text-[26px]">notifications</span>
354
- {hasUnreadNotifications && (
355
- <span className="absolute top-0 right-0 flex h-2.5 w-2.5">
356
- <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-error opacity-75"></span>
357
- <span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-error border-[1.5px] border-white dark:border-inverse-surface"></span>
358
- </span>
359
- )}
360
- </button>
361
-
362
- {isNotificationsOpen && (
363
- <div className="absolute right-0 mt-sm w-80 bg-surface border border-outline-variant rounded-xl shadow-xl z-50 flex flex-col overflow-hidden">
364
- <div className="px-md py-md border-b border-outline-variant bg-surface/95 backdrop-blur-sm flex justify-between items-center z-10 shrink-0">
365
- <h3 className="font-label-lg text-label-lg font-bold text-on-surface m-0 flex items-center gap-xs">
366
- <span className="material-symbols-outlined text-primary text-xl">notifications</span>
367
- Notifications
368
- </h3>
369
- <button
370
- onClick={fetchNotifications}
371
- className="border border-outline-variant/50 bg-surface-container-lowest hover:bg-surface-container rounded-full w-8 h-8 flex items-center justify-center cursor-pointer transition-all text-on-surface-variant hover:text-primary shadow-sm"
372
- title="Refresh Notifications"
373
- >
374
- <span className={"material-symbols-outlined text-base" + (loadingNotifications ? " animate-spin" : "")}>sync</span>
375
- </button>
376
- </div>
377
-
378
- <div className="flex flex-col bg-surface-container-lowest overflow-hidden">
379
- {loadingNotifications && notifications.length === 0 ? (
380
- <div className="p-xl flex-1 flex flex-col justify-center items-center text-on-surface-variant font-body-sm gap-sm">
381
- <span className="material-symbols-outlined animate-spin text-3xl text-primary/60">sync</span>
382
- <span>Loading notifications...</span>
383
- </div>
384
- ) : notifications.length === 0 ? (
385
- <div className="p-xl flex-1 flex flex-col justify-center items-center text-on-surface-variant font-body-sm gap-xs text-center">
386
- <div className="w-12 h-12 rounded-full bg-surface-container flex items-center justify-center mb-sm shadow-inner border border-outline-variant/30">
387
- <span className="material-symbols-outlined text-3xl text-on-surface-variant/60">notifications_off</span>
388
- </div>
389
- <span className="font-bold text-on-surface font-label-md">All Caught Up!</span>
390
- <span className="text-xs opacity-80 mt-1">You have no new notifications right now.</span>
391
- </div>
392
- ) : (
393
- notifications.slice(0, 3).map(n => (
394
- <div key={n.id} className="flex gap-md p-md border-b border-outline-variant/40 hover:bg-surface-container-low transition-colors cursor-default text-left group">
395
- <div className={`w-10 h-10 rounded-full flex items-center justify-center shrink-0 shadow-sm ${n.bg || 'bg-primary/10'}`}>
396
- <span className={`material-symbols-outlined text-[20px] ${n.color || 'text-primary'}`}>{n.icon}</span>
397
- </div>
398
- <div className="flex flex-col flex-1 justify-center">
399
- <span className="font-label-md text-label-md font-bold text-on-surface group-hover:text-primary transition-colors">{n.title}</span>
400
- <span className="font-body-sm text-[13px] text-on-surface-variant leading-snug mt-[2px]">{n.message}</span>
401
- <span className="font-label-sm text-[11px] text-on-surface-variant/60 mt-xs uppercase tracking-wider">{new Date(n.timestamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
402
- </div>
403
- </div>
404
- ))
405
- )}
406
- </div>
407
- </div>
408
- )}
409
- </div>
410
-
411
- {!user && (
412
- <button className="flex items-center gap-xs text-primary font-label-md text-label-md hover:bg-primary/5 px-sm py-xs rounded-lg transition-colors border-0 bg-transparent cursor-pointer">
413
- <span className="font-bold">Book Demo</span>
414
- <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>calendar_today</span>
415
- </button>
416
- )}
417
-
418
- {user && (
419
- <div className="relative ml-sm" ref={profileRef}>
420
- <button
421
- onClick={() => {
422
- setIsProfileOpen(!isProfileOpen);
423
- setIsNotificationsOpen(false);
424
- }}
425
- className="flex items-center gap-xs border-0 bg-transparent cursor-pointer hover:opacity-80 transition-opacity"
426
- >
427
- <span className="material-symbols-outlined text-on-surface-variant dark:text-surface-variant" style={{ fontSize: '28px', fontVariationSettings: "'FILL' 1" }}>account_circle</span>
428
- <span className="hidden lg:inline text-xs font-semibold text-on-surface-variant dark:text-surface-variant">
429
- {user.email.split('@')[0]}
430
- </span>
431
- </button>
432
-
433
- {isProfileOpen && (
434
- <div className="absolute right-0 mt-xs w-48 bg-surface border border-outline-variant rounded-lg shadow-lg overflow-hidden z-50">
435
- <div className="px-md py-sm border-b border-outline-variant bg-surface-container-low">
436
- <p className="font-label-md text-label-md font-bold text-on-surface truncate">{user.email}</p>
437
- <p className="font-body-sm text-body-sm text-on-surface-variant capitalize">{(user.role || 'User').replace(/_/g, ' ')}</p>
438
- </div>
439
- <div className="p-xs">
440
- <button
441
- onClick={() => {
442
- setIsProfileOpen(false);
443
- navigate('/settings');
444
- }}
445
- className="w-full text-left px-sm py-xs font-label-md text-label-md text-on-surface hover:bg-surface-container rounded transition-colors flex items-center gap-xs border-0 bg-transparent cursor-pointer"
446
- >
447
- <span className="material-symbols-outlined text-[18px]">person</span>
448
- My Profile
449
- </button>
450
-
451
- <button
452
- onClick={handleLogout}
453
- className="w-full text-left px-sm py-xs font-label-md text-label-md text-error hover:bg-error/10 rounded transition-colors flex items-center gap-xs border-0 bg-transparent cursor-pointer mt-xs"
454
- >
455
- <span className="material-symbols-outlined text-[18px]">logout</span>
456
- Logout
457
- </button>
458
- </div>
459
- </div>
460
- )}
461
- </div>
462
- )}
463
- </div>
464
- </div>
465
- </header>
466
-
467
- {/* Main Content Render Area */}
468
- <main className="flex-grow pt-[64px] md:pl-64 min-h-screen bg-background pb-xl w-full text-left">
469
- {impersonationToken && (
470
- <div className="bg-primary text-white px-md py-sm flex justify-between items-center shadow-md mx-md md:mx-xl mt-md md:mt-0 mb-sm rounded-lg animate-fade-in">
471
- <div className="flex items-center gap-sm">
472
- <span className="material-symbols-outlined text-[20px]">vpn_key</span>
473
- <span className="font-bold text-[14px]">Impersonation Mode Active</span>
474
- <span className="hidden md:inline text-[13px] opacity-90 border-l border-white/30 pl-sm ml-sm">
475
- You are currently viewing data for <strong>{organizations.find(o => o.id === user?.org_id)?.name || 'this organization'}</strong>.
476
- </span>
477
- </div>
478
- <button
479
- onClick={handleReturnToAdmin}
480
- className="bg-white text-primary hover:bg-surface-container transition-colors px-3 py-1.5 rounded-md font-bold text-[12px] border-0 cursor-pointer shadow-sm flex items-center gap-xs"
481
- >
482
- <span className="material-symbols-outlined text-[16px]">exit_to_app</span>
483
- Return
484
- </button>
485
- </div>
486
- )}
487
- <div className="px-md py-sm md:px-xl md:py-md max-w-container-max mx-auto flex flex-col gap-gutter">
488
- {children}
489
- </div>
490
- </main>
491
-
492
- </div>
493
- );
494
- };
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Link, NavLink, useNavigate, useLocation } from 'react-router-dom';
3
+ import { useAuth } from './AuthContext';
4
+
5
+ export const Layout = ({ children }) => {
6
+ const { user, logout } = useAuth();
7
+ const navigate = useNavigate();
8
+ const location = useLocation();
9
+
10
+ const searchParams = new URLSearchParams(location.search);
11
+ const urlQuery = searchParams.get('q') || '';
12
+ const [globalSearchQuery, setGlobalSearchQuery] = useState(urlQuery);
13
+
14
+ useEffect(() => {
15
+ setGlobalSearchQuery(urlQuery);
16
+ }, [urlQuery]);
17
+
18
+ const [isDemoMode, setIsDemoMode] = useState(false);
19
+ const [isProfileOpen, setIsProfileOpen] = useState(false);
20
+ const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
21
+ const [notifications, setNotifications] = useState([]);
22
+ const [loadingNotifications, setLoadingNotifications] = useState(false);
23
+ const [hasUnreadNotifications, setHasUnreadNotifications] = useState(false);
24
+
25
+ const notificationsRef = useRef(null);
26
+ const profileRef = useRef(null);
27
+
28
+ useEffect(() => {
29
+ const handleClickOutside = (event) => {
30
+ if (notificationsRef.current && !notificationsRef.current.contains(event.target)) {
31
+ setIsNotificationsOpen(false);
32
+ }
33
+ if (profileRef.current && !profileRef.current.contains(event.target)) {
34
+ setIsProfileOpen(false);
35
+ }
36
+ };
37
+ document.addEventListener("mousedown", handleClickOutside);
38
+ return () => {
39
+ document.removeEventListener("mousedown", handleClickOutside);
40
+ };
41
+ }, []);
42
+
43
+ const fetchNotifications = async () => {
44
+ if (!user) return;
45
+ setLoadingNotifications(true);
46
+ try {
47
+ const token = localStorage.getItem('wss_token') || sessionStorage.getItem('wss_token');
48
+ const res = await fetch('/api/auth/notifications', {
49
+ headers: { 'Authorization': `Bearer ${token}` }
50
+ });
51
+ if (res.ok) {
52
+ const data = await res.json();
53
+ const fetched = data.notifications || [];
54
+
55
+ const lastSeenId = localStorage.getItem('last_seen_notification_id');
56
+
57
+ if (fetched.length > 0 && String(fetched[0].id) !== lastSeenId) {
58
+ setHasUnreadNotifications(true);
59
+ }
60
+
61
+ setNotifications(fetched);
62
+ }
63
+ } catch (err) {
64
+ console.error("Failed to fetch notifications:", err);
65
+ } finally {
66
+ setLoadingNotifications(false);
67
+ }
68
+ };
69
+
70
+ useEffect(() => {
71
+ if (user) {
72
+ fetchNotifications();
73
+ const interval = setInterval(fetchNotifications, 60000); // refresh every minute
74
+ return () => clearInterval(interval);
75
+ }
76
+ }, [user]);
77
+ useEffect(() => {
78
+ setIsDemoMode(!!window.WSS_DEMO_MODE);
79
+ const checkInterval = setInterval(() => {
80
+ setIsDemoMode(!!window.WSS_DEMO_MODE);
81
+ }, 1000);
82
+ return () => clearInterval(checkInterval);
83
+ }, []);
84
+
85
+ // Force removal of dark theme and clear localStorage keys
86
+ useEffect(() => {
87
+ document.documentElement.classList.remove('dark');
88
+ localStorage.removeItem('color-theme');
89
+ }, []);
90
+
91
+ const [impersonationToken, setImpersonationToken] = useState(null);
92
+ const [organizations, setOrganizations] = useState([]);
93
+
94
+ useEffect(() => {
95
+ setImpersonationToken(localStorage.getItem('original_admin_token'));
96
+ }, [location.pathname]);
97
+
98
+ useEffect(() => {
99
+ const fetchOrgs = async () => {
100
+ const adminToken = localStorage.getItem('original_admin_token') || localStorage.getItem('wss_token') || sessionStorage.getItem('wss_token');
101
+ // Only attempt if they might be an admin
102
+ if (!adminToken) return;
103
+
104
+ try {
105
+ const res = await fetch('/api/auth/organizations', {
106
+ headers: { 'Authorization': `Bearer ${adminToken}` }
107
+ });
108
+ if (res.ok) {
109
+ const data = await res.json();
110
+ setOrganizations(data.organizations || []);
111
+ }
112
+ } catch (err) {
113
+ console.error("Failed to fetch organizations for dropdown", err);
114
+ }
115
+ };
116
+
117
+ const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'admin' || localStorage.getItem('original_admin_token');
118
+ if (isSuperAdmin) {
119
+ fetchOrgs();
120
+ }
121
+ }, [user]);
122
+
123
+ const handleReturnToAdmin = () => {
124
+ const orig = localStorage.getItem('original_admin_token');
125
+ if (orig) {
126
+ localStorage.removeItem('original_admin_token');
127
+ localStorage.setItem('wss_token', orig);
128
+ window.location.href = location.pathname;
129
+ }
130
+ };
131
+
132
+ const handleLogout = () => {
133
+ const isSuperAdmin = user?.role === 'super_admin' || sessionStorage.getItem('superAdminAuth') === 'true';
134
+ logout();
135
+ sessionStorage.removeItem('superAdminAuth');
136
+ localStorage.removeItem('original_admin_token');
137
+ navigate(isSuperAdmin ? '/' : '/login');
138
+ };
139
+
140
+ let navItems = [];
141
+
142
+ if (user?.role === 'executive_user') {
143
+ navItems = [
144
+ { to: '/scans/history', label: 'Organization Reports', icon: 'analytics' },
145
+ { to: '/settings', label: 'Settings', icon: 'settings' },
146
+ ];
147
+ } else {
148
+ navItems = [
149
+ { to: '/dashboard', label: 'Dashboard', icon: 'dashboard' },
150
+ { to: '/scans/new', label: 'New Scan', icon: 'security' },
151
+ { to: '/scans/history', label: 'Reports', icon: 'analytics' },
152
+ { to: '/scans/results', label: 'Vulnerabilities', icon: 'bug_report' },
153
+ { to: '/settings', label: 'Settings', icon: 'settings' },
154
+ ];
155
+ }
156
+
157
+ if (user?.role === 'super_admin') {
158
+ navItems.push({ to: '/super-admin', label: 'Global Management', icon: 'admin_panel_settings' });
159
+ }
160
+
161
+ return (
162
+ <div className="bg-background text-on-background font-body-md text-body-md antialiased min-h-screen flex flex-col md:flex-row w-full transition-colors duration-300">
163
+
164
+ {/* SideNavBar (Stitch Layout) */}
165
+ <nav className="bg-white dark:bg-inverse-surface h-screen w-64 flex-col fixed left-0 top-0 border-r border-outline-variant dark:border-outline z-40 hidden md:flex py-lg px-md gap-sm text-left">
166
+
167
+ {/* Header Branding */}
168
+ <div className="mb-xl flex flex-col gap-sm">
169
+ <div className="flex items-center gap-sm">
170
+ <div className="h-14 flex items-center justify-center">
171
+ <img src="/logo.png" alt="LarShield Logo" className="h-full object-contain" />
172
+ </div>
173
+ <div>
174
+ <h1 className="font-bold brand-gradient tracking-tight m-0 text-[15px] leading-snug">LarShield</h1>
175
+ <p className="font-label-sm text-[12px] text-on-surface-variant uppercase tracking-wider m-0 font-semibold">
176
+ {user?.role === 'super_admin' ? 'Super Admin' :
177
+ user?.role === 'admin' ? 'Admin' :
178
+ user?.role === 'support_engineer' ? 'Support Engineer' :
179
+ user?.role === 'org_admin' ? 'Organization Admin' :
180
+ user?.subscription_tier || 'Free Tier'}
181
+ </p>
182
+ </div>
183
+ </div>
184
+
185
+ {user?.role !== 'executive_user' && (
186
+ <NavLink
187
+ to="/scans/new"
188
+ className="mt-md w-full bg-primary text-on-primary rounded-lg py-sm px-md font-label-md text-label-md flex items-center justify-center gap-xs hover:opacity-90 transition-opacity border-0 cursor-pointer"
189
+ style={{ textDecoration: 'none' }}
190
+ >
191
+ <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>add</span>
192
+ Quick Scan
193
+ </NavLink>
194
+ )}
195
+ </div>
196
+
197
+ {/* Navigation Links */}
198
+ <div className="flex flex-col gap-base flex-1">
199
+ {navItems.map((item) => (
200
+ <NavLink
201
+ key={item.to}
202
+ to={item.to}
203
+ className={({ isActive }) =>
204
+ `flex items-center gap-sm px-sm py-sm rounded-lg transition-colors cursor-pointer transition-all duration-200 border-0 ${
205
+ isActive
206
+ ? 'text-primary dark:text-primary-fixed-dim font-bold bg-surface-container-high dark:bg-on-secondary-fixed-variant'
207
+ : 'text-on-surface-variant dark:text-surface-variant hover:bg-surface-container-high dark:hover:bg-on-secondary-fixed-variant'
208
+ }`
209
+ }
210
+ style={{ textDecoration: 'none' }}
211
+ >
212
+ <span className="material-symbols-outlined">{item.icon}</span>
213
+ <span className="font-label-md text-label-md">{item.label}</span>
214
+ </NavLink>
215
+ ))}
216
+
217
+ {/* Help Page Link */}
218
+ <NavLink
219
+ to="/help"
220
+ className={({ isActive }) =>
221
+ `flex items-center gap-sm px-sm py-sm rounded-lg transition-colors cursor-pointer transition-all duration-200 mt-auto border-0 ${
222
+ isActive
223
+ ? 'text-primary dark:text-primary-fixed-dim font-bold bg-surface-container-high dark:bg-on-secondary-fixed-variant'
224
+ : 'text-on-surface-variant dark:text-surface-variant hover:bg-surface-container-high dark:hover:bg-on-secondary-fixed-variant'
225
+ }`
226
+ }
227
+ style={{ textDecoration: 'none' }}
228
+ >
229
+ <span className="material-symbols-outlined">help</span>
230
+ <span className="font-label-md text-label-md">Help</span>
231
+ </NavLink>
232
+ </div>
233
+
234
+
235
+ {/* Sidebar Status / Log Out */}
236
+ <div className="mt-md pt-md border-t border-outline-variant">
237
+ <button
238
+ onClick={handleLogout}
239
+ className="w-full flex items-center gap-sm px-sm py-sm rounded-lg text-on-surface-variant dark:text-surface-variant hover:bg-surface-container-high dark:hover:bg-on-secondary-fixed-variant transition-colors cursor-pointer transition-all duration-200 border-0 bg-transparent text-left"
240
+ >
241
+ <span className="material-symbols-outlined">logout</span>
242
+ <span className="font-label-md text-label-md">Log Out</span>
243
+ </button>
244
+ </div>
245
+ </nav>
246
+
247
+ {/* TopNavBar (Stitch Layout) */}
248
+ <header className="bg-surface/80 dark:bg-inverse-surface/80 backdrop-blur-md fixed top-0 w-full z-50 border-b border-outline-variant dark:border-outline shadow-sm flex justify-between items-center h-16 px-gutter max-w-container-max mx-auto md:w-[calc(100%-16rem)] md:left-64 md:px-lg">
249
+
250
+ {/* Mobile Hamburger menu */}
251
+ <div className="md:hidden flex items-center gap-sm">
252
+ <span className="material-symbols-outlined text-primary cursor-pointer">menu</span>
253
+ <span className="font-bold brand-gradient text-[15px] leading-snug">LarShield</span>
254
+ </div>
255
+
256
+ {/* Search Bar Utility */}
257
+ <div className="hidden md:flex flex-1 max-w-md ml-xl relative">
258
+ <span className="material-symbols-outlined absolute left-sm top-1/2 -translate-y-1/2 text-outline" style={{ fontSize: '20px' }}>search</span>
259
+ <input
260
+ className="w-full bg-surface-container-lowest border border-outline-variant rounded-lg pl-xl pr-sm py-xs font-body-sm text-body-sm text-on-surface focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all placeholder:text-outline"
261
+ placeholder="Search vulnerabilities, reports, assets..."
262
+ type="text"
263
+ value={globalSearchQuery}
264
+ onChange={(e) => setGlobalSearchQuery(e.target.value)}
265
+ onKeyDown={(e) => {
266
+ if (e.key === 'Enter') {
267
+ if (globalSearchQuery.trim()) {
268
+ navigate(`/scans/history?q=${encodeURIComponent(globalSearchQuery.trim())}`);
269
+ } else {
270
+ navigate(`/scans/history`);
271
+ }
272
+ }
273
+ }}
274
+ />
275
+ </div>
276
+
277
+ {/* Right Nav Icons / Mode Selector */}
278
+ <div className="flex items-center gap-gutter ml-auto">
279
+
280
+ {/* Organization Display / Admin Dropdown */}
281
+ {(user?.role === 'admin' || user?.role === 'super_admin' || impersonationToken) && organizations.length > 0 ? (
282
+ <div className="flex items-center">
283
+ <select
284
+ className="bg-surface-container border border-outline-variant rounded-md px-sm py-xs font-label-sm text-on-surface focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary max-w-[200px] truncate"
285
+ value={impersonationToken ? (user?.org_id || '') : ''}
286
+ onChange={async (e) => {
287
+ const targetOrgId = e.target.value;
288
+ if (!targetOrgId) {
289
+ // Return to Admin
290
+ handleReturnToAdmin();
291
+ return;
292
+ }
293
+
294
+ try {
295
+ const activeToken = localStorage.getItem('original_admin_token') || localStorage.getItem('wss_token');
296
+ const res = await fetch(`/api/auth/impersonate/${targetOrgId}`, {
297
+ method: 'POST',
298
+ headers: { 'Authorization': `Bearer ${activeToken}` }
299
+ });
300
+
301
+ if (res.ok) {
302
+ const data = await res.json();
303
+ localStorage.setItem('original_admin_token', activeToken);
304
+ localStorage.setItem('wss_token', data.access_token);
305
+ window.location.href = location.pathname; // Reload current page with new token
306
+ }
307
+ } catch (err) {
308
+ console.error("Failed to impersonate from dropdown:", err);
309
+ }
310
+ }}
311
+ >
312
+ <option value="">-- Return to Admin --</option>
313
+ {organizations.map(org => (
314
+ <option key={org.id} value={org.id}>{org.name}</option>
315
+ ))}
316
+ </select>
317
+ </div>
318
+ ) : user?.org_name ? (
319
+ <div className="hidden md:flex items-center gap-xs font-label-md text-label-md text-on-surface bg-surface-container-low px-sm py-xs rounded-md border border-outline-variant">
320
+ <span className="material-symbols-outlined text-[18px] text-primary">domain</span>
321
+ <span className="font-bold">{user.org_name}</span>
322
+ </div>
323
+ ) : null}
324
+
325
+ {/* Active Status Badge */}
326
+ {isDemoMode ? (
327
+ <div className="hidden md:inline-flex items-center gap-xs bg-yellow-500/10 border border-yellow-500/30 rounded-full px-sm py-[2px] font-label-sm text-label-sm text-yellow-600 dark:text-yellow-500 font-bold uppercase tracking-wider">
328
+ <span className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span> Sandbox Mode
329
+ </div>
330
+ ) : (
331
+ <div className="hidden md:inline-flex items-center gap-xs bg-green-500/10 border border-green-500/30 rounded-full px-sm py-[2px] font-label-sm text-label-sm text-green-600 dark:text-green-500 font-bold uppercase tracking-wider">
332
+ <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span> Connected
333
+ </div>
334
+ )}
335
+
336
+
337
+
338
+ {/* Profile & Controls */}
339
+ <div className="flex items-center gap-sm">
340
+ <div className="relative" ref={notificationsRef}>
341
+ <button
342
+ onClick={() => {
343
+ const willOpen = !isNotificationsOpen;
344
+ setIsNotificationsOpen(willOpen);
345
+ setIsProfileOpen(false);
346
+ if (willOpen) {
347
+ setHasUnreadNotifications(false);
348
+ if (notifications.length > 0) {
349
+ localStorage.setItem('last_seen_notification_id', String(notifications[0].id));
350
+ }
351
+ }
352
+ }}
353
+ className="text-on-surface-variant dark:text-surface-variant hover:text-primary transition-colors duration-200 active:opacity-80 transition-all flex items-center justify-center relative border-0 bg-transparent cursor-pointer"
354
+ >
355
+ <span className="material-symbols-outlined text-[26px]">notifications</span>
356
+ {hasUnreadNotifications && (
357
+ <span className="absolute top-0 right-0 flex h-2.5 w-2.5">
358
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-error opacity-75"></span>
359
+ <span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-error border-[1.5px] border-white dark:border-inverse-surface"></span>
360
+ </span>
361
+ )}
362
+ </button>
363
+
364
+ {isNotificationsOpen && (
365
+ <div className="absolute right-0 mt-sm w-80 bg-surface border border-outline-variant rounded-xl shadow-xl z-50 flex flex-col overflow-hidden">
366
+ <div className="px-md py-md border-b border-outline-variant bg-surface/95 backdrop-blur-sm flex justify-between items-center z-10 shrink-0">
367
+ <h3 className="font-label-lg text-label-lg font-bold text-on-surface m-0 flex items-center gap-xs">
368
+ <span className="material-symbols-outlined text-primary text-xl">notifications</span>
369
+ Notifications
370
+ </h3>
371
+ <button
372
+ onClick={fetchNotifications}
373
+ className="border border-outline-variant/50 bg-surface-container-lowest hover:bg-surface-container rounded-full w-8 h-8 flex items-center justify-center cursor-pointer transition-all text-on-surface-variant hover:text-primary shadow-sm"
374
+ title="Refresh Notifications"
375
+ >
376
+ <span className={"material-symbols-outlined text-base" + (loadingNotifications ? " animate-spin" : "")}>sync</span>
377
+ </button>
378
+ </div>
379
+
380
+ <div className="flex flex-col bg-surface-container-lowest overflow-hidden">
381
+ {loadingNotifications && notifications.length === 0 ? (
382
+ <div className="p-xl flex-1 flex flex-col justify-center items-center text-on-surface-variant font-body-sm gap-sm">
383
+ <span className="material-symbols-outlined animate-spin text-3xl text-primary/60">sync</span>
384
+ <span>Loading notifications...</span>
385
+ </div>
386
+ ) : notifications.length === 0 ? (
387
+ <div className="p-xl flex-1 flex flex-col justify-center items-center text-on-surface-variant font-body-sm gap-xs text-center">
388
+ <div className="w-12 h-12 rounded-full bg-surface-container flex items-center justify-center mb-sm shadow-inner border border-outline-variant/30">
389
+ <span className="material-symbols-outlined text-3xl text-on-surface-variant/60">notifications_off</span>
390
+ </div>
391
+ <span className="font-bold text-on-surface font-label-md">All Caught Up!</span>
392
+ <span className="text-xs opacity-80 mt-1">You have no new notifications right now.</span>
393
+ </div>
394
+ ) : (
395
+ notifications.slice(0, 3).map(n => (
396
+ <div key={n.id} className="flex gap-md p-md border-b border-outline-variant/40 hover:bg-surface-container-low transition-colors cursor-default text-left group">
397
+ <div className={`w-10 h-10 rounded-full flex items-center justify-center shrink-0 shadow-sm ${n.bg || 'bg-primary/10'}`}>
398
+ <span className={`material-symbols-outlined text-[20px] ${n.color || 'text-primary'}`}>{n.icon}</span>
399
+ </div>
400
+ <div className="flex flex-col flex-1 justify-center">
401
+ <span className="font-label-md text-label-md font-bold text-on-surface group-hover:text-primary transition-colors">{n.title}</span>
402
+ <span className="font-body-sm text-[13px] text-on-surface-variant leading-snug mt-[2px]">{n.message}</span>
403
+ <span className="font-label-sm text-[11px] text-on-surface-variant/60 mt-xs uppercase tracking-wider">{new Date(n.timestamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
404
+ </div>
405
+ </div>
406
+ ))
407
+ )}
408
+ </div>
409
+ </div>
410
+ )}
411
+ </div>
412
+
413
+ {!user && (
414
+ <button className="flex items-center gap-xs text-primary font-label-md text-label-md hover:bg-primary/5 px-sm py-xs rounded-lg transition-colors border-0 bg-transparent cursor-pointer">
415
+ <span className="font-bold">Book Demo</span>
416
+ <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>calendar_today</span>
417
+ </button>
418
+ )}
419
+
420
+ {user && (
421
+ <div className="relative ml-sm" ref={profileRef}>
422
+ <button
423
+ onClick={() => {
424
+ setIsProfileOpen(!isProfileOpen);
425
+ setIsNotificationsOpen(false);
426
+ }}
427
+ className="flex items-center gap-xs border-0 bg-transparent cursor-pointer hover:opacity-80 transition-opacity"
428
+ >
429
+ <span className="material-symbols-outlined text-on-surface-variant dark:text-surface-variant" style={{ fontSize: '28px', fontVariationSettings: "'FILL' 1" }}>account_circle</span>
430
+ <span className="hidden lg:inline text-xs font-semibold text-on-surface-variant dark:text-surface-variant">
431
+ {user.email.split('@')[0]}
432
+ </span>
433
+ </button>
434
+
435
+ {isProfileOpen && (
436
+ <div className="absolute right-0 mt-xs w-48 bg-surface border border-outline-variant rounded-lg shadow-lg overflow-hidden z-50">
437
+ <div className="px-md py-sm border-b border-outline-variant bg-surface-container-low">
438
+ <p className="font-label-md text-label-md font-bold text-on-surface truncate">{user.email}</p>
439
+ <p className="font-body-sm text-body-sm text-on-surface-variant capitalize">{(user.role || 'User').replace(/_/g, ' ')}</p>
440
+ </div>
441
+ <div className="p-xs">
442
+ <button
443
+ onClick={() => {
444
+ setIsProfileOpen(false);
445
+ navigate('/settings');
446
+ }}
447
+ className="w-full text-left px-sm py-xs font-label-md text-label-md text-on-surface hover:bg-surface-container rounded transition-colors flex items-center gap-xs border-0 bg-transparent cursor-pointer"
448
+ >
449
+ <span className="material-symbols-outlined text-[18px]">person</span>
450
+ My Profile
451
+ </button>
452
+
453
+ <button
454
+ onClick={handleLogout}
455
+ className="w-full text-left px-sm py-xs font-label-md text-label-md text-error hover:bg-error/10 rounded transition-colors flex items-center gap-xs border-0 bg-transparent cursor-pointer mt-xs"
456
+ >
457
+ <span className="material-symbols-outlined text-[18px]">logout</span>
458
+ Logout
459
+ </button>
460
+ </div>
461
+ </div>
462
+ )}
463
+ </div>
464
+ )}
465
+ </div>
466
+ </div>
467
+ </header>
468
+
469
+ {/* Main Content Render Area */}
470
+ <main className="flex-grow pt-[64px] md:pl-64 min-h-screen bg-background pb-xl w-full text-left">
471
+ {impersonationToken && (
472
+ <div className="bg-primary text-white px-md py-sm flex justify-between items-center shadow-md mx-md md:mx-xl mt-md md:mt-0 mb-sm rounded-lg animate-fade-in">
473
+ <div className="flex items-center gap-sm">
474
+ <span className="material-symbols-outlined text-[20px]">vpn_key</span>
475
+ <span className="font-bold text-[14px]">Impersonation Mode Active</span>
476
+ <span className="hidden md:inline text-[13px] opacity-90 border-l border-white/30 pl-sm ml-sm">
477
+ You are currently viewing data for <strong>{organizations.find(o => o.id === user?.org_id)?.name || 'this organization'}</strong>.
478
+ </span>
479
+ </div>
480
+ <button
481
+ onClick={handleReturnToAdmin}
482
+ className="bg-white text-primary hover:bg-surface-container transition-colors px-3 py-1.5 rounded-md font-bold text-[12px] border-0 cursor-pointer shadow-sm flex items-center gap-xs"
483
+ >
484
+ <span className="material-symbols-outlined text-[16px]">exit_to_app</span>
485
+ Return
486
+ </button>
487
+ </div>
488
+ )}
489
+ <div className="px-md py-sm md:px-xl md:py-md max-w-container-max mx-auto flex flex-col gap-gutter">
490
+ {children}
491
+ </div>
492
+ </main>
493
+
494
+ </div>
495
+ );
496
+ };