@xeplr/ui-account 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,15 +1,35 @@
1
1
  {
2
2
  "name": "@xeplr/ui-account",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Account UI: auth, profile, RBAC admin, tenant management — React controller hooks and designs",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
- "files": ["src/"],
8
- "keywords": ["auth", "account", "profile", "react", "hooks", "login", "register", "rbac", "multi-tenancy"],
7
+ "files": [
8
+ "src/"
9
+ ],
10
+ "keywords": [
11
+ "auth",
12
+ "account",
13
+ "profile",
14
+ "react",
15
+ "hooks",
16
+ "login",
17
+ "register",
18
+ "rbac",
19
+ "multi-tenancy"
20
+ ],
9
21
  "author": "xeplr",
10
22
  "license": "MIT",
11
- "repository": { "type": "git", "url": "https://github.com/Xeplr/xeplr-ui-account" },
12
- "publishConfig": { "access": "public" },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/Xeplr/xeplr-ui-account"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "dependencies": {
31
+ "@xeplr/ui-utils": "^1.0.0"
32
+ },
13
33
  "peerDependencies": {
14
34
  "react": "^18.0.0 || ^19.0.0",
15
35
  "react-router-dom": "^6.0.0 || ^7.0.0"
@@ -0,0 +1,46 @@
1
+ const STORAGE_PREFIX = 'xeplr:activeScope:';
2
+
3
+ /**
4
+ * The app's active value at a given MT level (l1, l2, ...) — e.g. the current
5
+ * company at l1, workspace at l2. Auth doesn't know or care what it means:
6
+ * it's a bare value per level, mirroring how userTenantsMapping stores a bare
7
+ * {level, value} row server-side. api.js's authFetch reads this (per the
8
+ * level's header, from registerMTs() — see mt.js) and attaches it to every
9
+ * request.
10
+ *
11
+ * setActiveScope('l1', { id: 'acme-co', name: 'Acme Co' });
12
+ * getActiveScope('l1'); // => { id: 'acme-co', name: 'Acme Co' }
13
+ */
14
+ export function getActiveScope(level) {
15
+ try {
16
+ const raw = localStorage.getItem(STORAGE_PREFIX + level);
17
+ return raw ? JSON.parse(raw) : null;
18
+ } catch (e) {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export function setActiveScope(level, scope) {
24
+ try {
25
+ if (scope) localStorage.setItem(STORAGE_PREFIX + level, JSON.stringify(scope));
26
+ else localStorage.removeItem(STORAGE_PREFIX + level);
27
+ } catch (e) {}
28
+ }
29
+
30
+ /**
31
+ * Clear one level's scope, or every level if omitted (e.g. on logout).
32
+ */
33
+ export function clearActiveScope(level) {
34
+ if (level) {
35
+ setActiveScope(level, null);
36
+ return;
37
+ }
38
+ try {
39
+ var keys = [];
40
+ for (var i = 0; i < localStorage.length; i++) {
41
+ var key = localStorage.key(i);
42
+ if (key && key.indexOf(STORAGE_PREFIX) === 0) keys.push(key);
43
+ }
44
+ keys.forEach(function(key) { localStorage.removeItem(key); });
45
+ } catch (e) {}
46
+ }
package/src/adminApi.js CHANGED
@@ -37,32 +37,3 @@ export function toggleModuleRole({ module, action, roleId, assign }) {
37
37
  body: JSON.stringify({ module, action, roleId, assign }),
38
38
  });
39
39
  }
40
-
41
- // ─── Tenant management (Super Admin) ───
42
-
43
- export function getTenants(level) {
44
- var url = '/auth/api/admin/tenants';
45
- if (level) url += '?level=' + level;
46
- return authFetch(url);
47
- }
48
-
49
- export function saveTenant(data) {
50
- return authFetch('/auth/api/admin/tenants', {
51
- method: 'POST',
52
- body: JSON.stringify(data),
53
- });
54
- }
55
-
56
- export function deleteTenant(id) {
57
- return authFetch('/auth/api/admin/tenants/delete', {
58
- method: 'POST',
59
- body: JSON.stringify({ id }),
60
- });
61
- }
62
-
63
- export function assignUserTenant({ userId, tenantId, level }) {
64
- return authFetch('/auth/api/admin/tenants/assign-user', {
65
- method: 'POST',
66
- body: JSON.stringify({ userId, tenantId, level }),
67
- });
68
- }
package/src/api.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { getToken, setToken, getRefreshToken, setRefreshToken, clearAuth } from './token.js';
2
+ import { getActiveScope, clearActiveScope } from './activeScope.js';
3
+ import { getMtConfig } from './mt.js';
2
4
 
3
5
  let _baseUrl = '';
4
6
  let _onSessionExpired = null;
@@ -64,8 +66,23 @@ async function refreshAccessToken() {
64
66
  }
65
67
 
66
68
  /**
67
- * Authenticated fetch with auto-refresh.
68
- * Attaches Bearer token, retries once on 401 after refreshing.
69
+ * Absorb a server-issued sliding-refresh token. When the access token was
70
+ * expired-but-within-tolerance, the API serves the request normally and hands
71
+ * back a fresh token via the `X-New-Token` header (see @xeplr/auth
72
+ * authMiddleware). We just swap it into storage — no gating, no retry. This is
73
+ * the happy path; it means most expiries never produce a 401 at all.
74
+ */
75
+ function absorbNewToken(res) {
76
+ try {
77
+ const fresh = res.headers.get('X-New-Token');
78
+ if (fresh) setToken(fresh);
79
+ } catch (e) {}
80
+ }
81
+
82
+ /**
83
+ * Authenticated fetch with sliding refresh.
84
+ * Attaches Bearer token; absorbs X-New-Token off every response. The 401→refresh
85
+ * path below is now only a fallback for a token past the whole tolerance window.
69
86
  */
70
87
  export async function authFetch(endpoint, options = {}) {
71
88
  const url = endpoint.startsWith('http') ? endpoint : `${getBaseUrl()}${endpoint}`;
@@ -80,23 +97,27 @@ export async function authFetch(endpoint, options = {}) {
80
97
  headers['Authorization'] = `Bearer ${token}`;
81
98
  }
82
99
 
83
- // Attach active tenant from session
84
- try {
85
- var tenantRaw = localStorage.getItem('xeplr:activeTenant');
86
- if (tenantRaw) {
87
- var tenant = JSON.parse(tenantRaw);
88
- if (tenant && tenant.id) headers['X-Tenant-Id'] = tenant.id;
89
- }
90
- } catch (e) {}
100
+ // Attach the app's active scope (e.g. company/workspace) per configured MT
101
+ // level — see mt.js's registerMTs() and activeScope.js.
102
+ const mtConfig = getMtConfig();
103
+ Object.keys(mtConfig.slots).forEach((level) => {
104
+ const slot = mtConfig.slots[level];
105
+ const scope = getActiveScope(level);
106
+ if (scope && scope.id) headers[slot.header] = scope.id;
107
+ });
91
108
 
92
109
  let res = await fetch(url, { ...options, headers });
110
+ absorbNewToken(res);
93
111
 
94
- // If 401, try refreshing the token and retry once
112
+ // Fallback: token is past the tolerance window (truly dead) → rotate via the
113
+ // refresh token and retry once. With server-side sliding refresh this rarely
114
+ // fires; the tolerance window absorbs ordinary expiries above.
95
115
  if (res.status === 401) {
96
116
  const refreshed = await refreshAccessToken();
97
117
  if (refreshed) {
98
118
  headers['Authorization'] = `Bearer ${getToken()}`;
99
119
  res = await fetch(url, { ...options, headers });
120
+ absorbNewToken(res);
100
121
  }
101
122
  }
102
123
 
@@ -157,7 +178,7 @@ export function resetPassword({ token, newPassword }) {
157
178
  export function changePassword({ currentPassword, newPassword }) {
158
179
  return authFetch('/auth/api/change-password', {
159
180
  method: 'POST',
160
- body: JSON.stringify({ currentPassword, newPassword }),
181
+ body: JSON.stringify({ oldPassword: currentPassword, newPassword }),
161
182
  });
162
183
  }
163
184
 
@@ -172,8 +193,29 @@ export function updateProfile(fields) {
172
193
  });
173
194
  }
174
195
 
175
- export function getMyTenants() {
176
- return authFetch('/auth/api/my-tenants');
196
+ /**
197
+ * Upload a new profile picture. Bypasses authFetch's JSON Content-Type (the
198
+ * browser sets multipart/form-data with the right boundary itself when the
199
+ * body is a FormData — setting it manually breaks the boundary).
200
+ */
201
+ export async function uploadAvatar(file) {
202
+ const formData = new FormData();
203
+ formData.append('avatar', file);
204
+
205
+ const headers = {};
206
+ const token = getToken();
207
+ if (token) headers['Authorization'] = `Bearer ${token}`;
208
+
209
+ const res = await fetch(`${getBaseUrl()}/auth/api/profile/avatar`, {
210
+ method: 'POST',
211
+ headers,
212
+ body: formData,
213
+ });
214
+ const data = await res.json();
215
+ if (!res.ok) {
216
+ throw new Error(data.error || 'Something went wrong');
217
+ }
218
+ return data;
177
219
  }
178
220
 
179
221
  export function logoutUser() {
@@ -181,6 +223,7 @@ export function logoutUser() {
181
223
  const accessToken = getToken();
182
224
 
183
225
  clearAuth();
226
+ clearActiveScope();
184
227
 
185
228
  // Best-effort server-side cleanup
186
229
  if (refreshToken) {
@@ -0,0 +1,125 @@
1
+ import { isValidElement } from 'react';
2
+ import { Route } from 'react-router-dom';
3
+ import { ProtectedRoute } from './ProtectedRoute.jsx';
4
+ import {
5
+ LoginPage, RegisterPage, ForgotPasswordPage, ResetPasswordPage, ActivatePage,
6
+ NotActivatedPage, ProfilePage, ChangePasswordPage,
7
+ UserRolesPage, AccessMatrixPage, MasterSettingsPage
8
+ } from './pages.jsx';
9
+
10
+ /**
11
+ * The auth-UI route table — the single source of truth for auth page paths.
12
+ *
13
+ * group 'public' → open (login, register, activate, …)
14
+ * group 'account' → login required (profile, change-password, …)
15
+ * group 'admin' → login required (RBAC/master screens; add page/roles to gate further)
16
+ */
17
+ var MANIFEST = [
18
+ { key: 'login', path: '/auth/login', Page: LoginPage, group: 'public' },
19
+ { key: 'register', path: '/auth/register', Page: RegisterPage, group: 'public' },
20
+ { key: 'forgotPassword', path: '/auth/forgot-password', Page: ForgotPasswordPage, group: 'public' },
21
+ { key: 'resetPassword', path: '/auth/reset-password', Page: ResetPasswordPage, group: 'public' },
22
+ { key: 'activate', path: '/auth/activate', Page: ActivatePage, group: 'public' },
23
+ { key: 'notActivated', path: '/auth/not-activated', Page: NotActivatedPage, group: 'public' },
24
+ { key: 'profile', path: '/auth/profile', Page: ProfilePage, group: 'account' },
25
+ { key: 'changePassword', path: '/auth/change-password', Page: ChangePasswordPage, group: 'account' },
26
+ { key: 'userRoles', path: '/auth/admin/user-roles', Page: UserRolesPage, group: 'admin' },
27
+ { key: 'accessMatrix', path: '/auth/admin/access-matrix', Page: AccessMatrixPage, group: 'admin' },
28
+ { key: 'masterSettings', path: '/auth/admin/master-settings', Page: MasterSettingsPage, group: 'admin' }
29
+ ];
30
+
31
+ var _pathByKey = {};
32
+ MANIFEST.forEach(function (e) { _pathByKey[e.key] = e.path; });
33
+
34
+ /**
35
+ * Resolve a manifest key to its canonical path — use it for cross-links so nothing
36
+ * hardcodes a URL: <Link to={authPath('register')}>Register</Link>
37
+ */
38
+ export function authPath(key) {
39
+ return _pathByKey[key] || null;
40
+ }
41
+
42
+ // /auth/login → /_login, /auth/admin/user-roles → /_admin/user-roles.
43
+ // A stable "always the pristine framework page" escape hatch that ignores overrides.
44
+ function rawPathOf(path) {
45
+ return path.replace(/^\/auth\//, '/_');
46
+ }
47
+
48
+ // Turn an override entry into the element to render at the canonical path.
49
+ function resolveElement(entry, ov) {
50
+ var Page = entry.Page;
51
+ if (!ov) return <Page />; // framework default
52
+ if (isValidElement(ov)) return ov; // bare element → full replace
53
+ if (ov.element) return ov.element; // { element } → full replace
54
+ if (ov.design) return <Page design={ov.design} />; // { design } → re-skin (keeps controller)
55
+ return <Page />; // e.g. only { path } supplied
56
+ }
57
+
58
+ /**
59
+ * Emit every auth UI route in one call — the app writes no route boilerplate.
60
+ *
61
+ * <Routes>
62
+ * {authRoutes()} // all framework defaults
63
+ * {authRoutes({ login: { design: MyLogin } })} // custom login, everything else default
64
+ * {authRoutes({}, { layout: <Nav/> })} // account/admin pages inside your shell
65
+ * </Routes>
66
+ *
67
+ * @param {object} [overrides] map of manifest key → how to render it:
68
+ * - { design: MyDesign } re-skin: framework controller + validation, your look
69
+ * (design receives the controller's props)
70
+ * - { element: <X/> } full replace: your element, framework logic ignored
71
+ * - a React element shorthand for { element }
72
+ * - false | null drop this route (app doesn't want it)
73
+ * - { path: '/x', ... } remount at a different path (combine with design/element)
74
+ * @param {object} [opts]
75
+ * - layout: element wrap account+admin pages in a parent layout route
76
+ * (your <Nav/> with an <Outlet/>); public pages stay bare
77
+ * - raw: false disable the /_<name> escape-hatch routes (on by default)
78
+ * - loginPath: string ProtectedRoute redirect target (default: manifest login path)
79
+ * @returns {Array} an array of <Route> — spread it inside <Routes>
80
+ */
81
+ export function authRoutes(overrides, opts) {
82
+ overrides = overrides || {};
83
+ opts = opts || {};
84
+ var loginPath = opts.loginPath || _pathByKey.login;
85
+ var routes = [];
86
+ var layoutChildren = []; // account+admin canonical routes, nested under opts.layout
87
+
88
+ function protect(el) {
89
+ return <ProtectedRoute loginPath={loginPath}>{el}</ProtectedRoute>;
90
+ }
91
+
92
+ MANIFEST.forEach(function (entry) {
93
+ var ov = overrides[entry.key];
94
+ if (ov === false || ov === null) return; // opted out
95
+
96
+ var path = (ov && ov.path) || entry.path;
97
+ var el = resolveElement(entry, ov);
98
+
99
+ // Canonical route (override-aware).
100
+ if (entry.group === 'public') {
101
+ routes.push(<Route key={entry.key} path={path} element={el} />);
102
+ } else if (opts.layout) {
103
+ layoutChildren.push(<Route key={entry.key} path={path} element={el} />);
104
+ } else {
105
+ routes.push(<Route key={entry.key} path={path} element={protect(el)} />);
106
+ }
107
+
108
+ // Escape-hatch route: always the pristine framework page, ignores overrides.
109
+ if (opts.raw !== false) {
110
+ var Page = entry.Page;
111
+ var rawEl = entry.group === 'public' ? <Page /> : protect(<Page />);
112
+ routes.push(<Route key={'_' + entry.key} path={rawPathOf(entry.path)} element={rawEl} />);
113
+ }
114
+ });
115
+
116
+ if (opts.layout && layoutChildren.length) {
117
+ routes.push(
118
+ <Route key="__auth_layout" element={protect(opts.layout)}>
119
+ {layoutChildren}
120
+ </Route>
121
+ );
122
+ }
123
+
124
+ return routes;
125
+ }
@@ -0,0 +1,71 @@
1
+ import { memo } from 'react';
2
+ import { Link } from 'react-router-dom';
3
+
4
+ // The settings trigger — a gear icon, parallel to NotificationsBell's bell
5
+ // icon — and its dropdown. Used two places: top-right of the top bar
6
+ // (`placement="bottom"`, the default — trigger is near the top of the
7
+ // viewport, so the menu opens downward) and the bottom of the drawer rail
8
+ // (`placement="top"` — trigger is near the bottom of the viewport, so the
9
+ // menu opens upward instead of running off-screen). Memoized and given only
10
+ // its own slice of props — it does not receive drawerOpen/drawerItems, so
11
+ // drawer interactions never touch it. accountItems is already resolved +
12
+ // role-filtered + ordered (overrides above builtin) by useNavController —
13
+ // this component just renders whatever it's handed. `triggerLabel` is
14
+ // optional text next to the gear icon — the drawer passes "Settings" when
15
+ // expanded (matching how its other items show a label), omits it when
16
+ // collapsed; the top bar never passes it (icon-only, no room).
17
+ function AccountMenu({
18
+ user, accountItems, placement, triggerLabel,
19
+ accountOpen, toggleAccount, closeAccount, accountRef, logout
20
+ }) {
21
+ var label = (user && (user.name || user.email)) || 'Account';
22
+ var initial = label.charAt(0).toUpperCase();
23
+
24
+ return (
25
+ <div ref={accountRef} className={'xeplr-nav-account' + (accountOpen ? ' xeplr-nav-account-open' : '')}>
26
+ <button
27
+ type="button"
28
+ className={'xeplr-nav-settings-trigger' + (triggerLabel ? ' xeplr-nav-settings-trigger-labeled' : '')}
29
+ onClick={toggleAccount}
30
+ aria-haspopup="menu"
31
+ aria-expanded={accountOpen}
32
+ aria-label="Settings"
33
+ >
34
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
35
+ <circle cx="12" cy="12" r="3" />
36
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
37
+ </svg>
38
+ {triggerLabel && <span className="xeplr-nav-settings-trigger-label">{triggerLabel}</span>}
39
+ </button>
40
+
41
+ {accountOpen && (
42
+ <div className={'xeplr-nav-account-menu' + (placement === 'top' ? ' xeplr-nav-account-menu-top' : '')} role="menu">
43
+ <div className="xeplr-nav-account-header">
44
+ {user && user.profilePicUrl
45
+ ? <img src={user.profilePicUrl} alt="" className="xeplr-nav-avatar xeplr-nav-avatar-img" />
46
+ : <span className="xeplr-nav-avatar">{initial}</span>}
47
+ <div className="xeplr-nav-account-header-text">
48
+ <div className="xeplr-nav-account-name">{label}</div>
49
+ {user && user.email && user.name && <div className="xeplr-nav-account-email">{user.email}</div>}
50
+ </div>
51
+ </div>
52
+
53
+ {(accountItems || []).map(function(item) {
54
+ return (
55
+ <Link key={item.name} to={item.path} className="xeplr-nav-account-item" role="menuitem" onClick={closeAccount}>
56
+ {item.name}
57
+ </Link>
58
+ );
59
+ })}
60
+
61
+ <div className="xeplr-nav-account-divider" />
62
+ <button type="button" className="xeplr-nav-account-item xeplr-nav-account-logout" role="menuitem" onClick={logout}>
63
+ Logout
64
+ </button>
65
+ </div>
66
+ )}
67
+ </div>
68
+ );
69
+ }
70
+
71
+ export default memo(AccountMenu);
@@ -1,11 +1,14 @@
1
1
  import { Link } from 'react-router-dom';
2
2
  import './auth.css';
3
3
 
4
+ // error/success feedback fires as a snackbar (see useActivateController.js,
5
+ // including the "invalid link" case) — this design doesn't render it inline,
6
+ // it just switches which block is visible. `loading`'s in-progress status
7
+ // text stays inline (it's ongoing state, not a transient notification).
4
8
  export default function ActivateSample({ token, error, success, loading }) {
5
9
  if (!token) {
6
10
  return (
7
11
  <div className="xeplr-auth-container">
8
- <div className="xeplr-auth-alert xeplr-auth-alert-error">Invalid activation link</div>
9
12
  <div className="xeplr-auth-links">
10
13
  <p><Link to="/auth/register">Register a new account</Link></p>
11
14
  </div>
@@ -17,21 +20,10 @@ export default function ActivateSample({ token, error, success, loading }) {
17
20
  <div className="xeplr-auth-container">
18
21
  <h1>Account Activation</h1>
19
22
  {loading && <div className="xeplr-auth-alert">Activating your account...</div>}
20
- {error && (
21
- <>
22
- <div className="xeplr-auth-alert xeplr-auth-alert-error">{error}</div>
23
- <div className="xeplr-auth-links">
24
- <p><Link to="/auth/login">Go to Login</Link></p>
25
- </div>
26
- </>
27
- )}
28
- {success && (
29
- <>
30
- <div className="xeplr-auth-alert xeplr-auth-alert-success">{success}</div>
31
- <div className="xeplr-auth-links">
32
- <p><Link to="/auth/login">Go to Login</Link></p>
33
- </div>
34
- </>
23
+ {(error || success) && (
24
+ <div className="xeplr-auth-links">
25
+ <p><Link to="/auth/login">Go to Login</Link></p>
26
+ </div>
35
27
  )}
36
28
  </div>
37
29
  );
@@ -1,16 +1,16 @@
1
1
  import './auth.css';
2
2
 
3
+ // error/success feedback fires as a snackbar (see useChangePasswordController.js)
4
+ // — this design doesn't render it inline.
3
5
  export default function ChangePasswordSample({
4
6
  currentPassword, setCurrentPassword,
5
7
  newPassword, setNewPassword,
6
8
  confirmPassword, setConfirmPassword,
7
- error, success, loading, handleSubmit
9
+ loading, handleSubmit
8
10
  }) {
9
11
  return (
10
12
  <div className="xeplr-auth-container">
11
13
  <h1>Change Password</h1>
12
- {error && <div className="xeplr-auth-alert xeplr-auth-alert-error">{error}</div>}
13
- {success && <div className="xeplr-auth-alert xeplr-auth-alert-success">{success}</div>}
14
14
  <form onSubmit={handleSubmit}>
15
15
  <div className="xeplr-auth-form-group">
16
16
  <label htmlFor="xeplr-current-password">Current Password</label>
@@ -1,12 +1,12 @@
1
1
  import { Link } from 'react-router-dom';
2
2
  import './auth.css';
3
3
 
4
- export default function ForgotPasswordSample({ email, setEmail, error, success, loading, handleSubmit }) {
4
+ // error/success feedback fires as a snackbar (see useForgotPasswordController.js)
5
+ // — this design doesn't render it inline.
6
+ export default function ForgotPasswordSample({ email, setEmail, loading, handleSubmit }) {
5
7
  return (
6
8
  <div className="xeplr-auth-container">
7
9
  <h1>Forgot Password</h1>
8
- {error && <div className="xeplr-auth-alert xeplr-auth-alert-error">{error}</div>}
9
- {success && <div className="xeplr-auth-alert xeplr-auth-alert-success">{success}</div>}
10
10
  <form onSubmit={handleSubmit}>
11
11
  <div className="xeplr-auth-form-group">
12
12
  <label htmlFor="xeplr-email">Email</label>
@@ -1,11 +1,12 @@
1
1
  import { Link } from 'react-router-dom';
2
2
  import './auth.css';
3
3
 
4
- export default function LoginSample({ email, setEmail, password, setPassword, error, loading, handleSubmit }) {
4
+ // error/success feedback fires as a snackbar (see useLoginController.js) —
5
+ // this design doesn't render it inline.
6
+ export default function LoginSample({ email, setEmail, password, setPassword, loading, handleSubmit }) {
5
7
  return (
6
8
  <div className="xeplr-auth-container">
7
9
  <h1>Login</h1>
8
- {error && <div className="xeplr-auth-alert xeplr-auth-alert-error">{error}</div>}
9
10
  <form onSubmit={handleSubmit}>
10
11
  <div className="xeplr-auth-form-group">
11
12
  <label htmlFor="xeplr-email">Email</label>
@@ -0,0 +1,121 @@
1
+ import { memo, useState, useMemo } from 'react';
2
+ import AccountMenu from './AccountMenu.jsx';
3
+ import NotificationsBell from './NotificationsBell.jsx';
4
+
5
+ // The ENTIRE nav when drawerItems is non-empty — NavPage renders this instead
6
+ // of (never alongside) the top bar, see pages.jsx. A pure overlay: `position:
7
+ // fixed` (nav.css), pinned to (0,0), full height — it never pushes or resizes
8
+ // anything else on the page, completely decoupled from the app's own content.
9
+ // Two states: icon-only (collapsed, `drawerOpen=false`) and
10
+ // icon+label+groups+search+promo (expanded). Toggled by clicking its own
11
+ // logo. Settings + notifications live in its footer (bottom-pinned) instead
12
+ // of a top bar, since there isn't one — same accountItems/notifications the
13
+ // top bar would have gotten, just rendered here instead.
14
+ //
15
+ // Items are bucketed by their optional `group` — ungrouped items render first
16
+ // (no header), grouped ones under a section header, groups in first-seen order.
17
+ function bucketItems(items) {
18
+ var ungrouped = [];
19
+ var groupOrder = [];
20
+ var groups = {};
21
+ items.forEach(function(item) {
22
+ if (!item.group) {
23
+ ungrouped.push(item);
24
+ return;
25
+ }
26
+ if (!groups[item.group]) {
27
+ groups[item.group] = [];
28
+ groupOrder.push(item.group);
29
+ }
30
+ groups[item.group].push(item);
31
+ });
32
+ return { ungrouped: ungrouped, groupOrder: groupOrder, groups: groups };
33
+ }
34
+
35
+ function NavDrawer({
36
+ drawerOpen, toggleDrawer, drawerItems, expandedLogo, logo, drawerPromo,
37
+ user, accountItems, notifications, accountOpen, toggleAccount, closeAccount, accountRef, logout
38
+ }) {
39
+ var [query, setQuery] = useState('');
40
+
41
+ var visibleItems = useMemo(function() {
42
+ if (!drawerOpen || !query.trim()) return drawerItems;
43
+ var q = query.trim().toLowerCase();
44
+ return drawerItems.filter(function(item) { return item.name.toLowerCase().indexOf(q) !== -1; });
45
+ }, [drawerItems, query, drawerOpen]);
46
+
47
+ var buckets = useMemo(function() { return bucketItems(visibleItems); }, [visibleItems]);
48
+
49
+ function renderItem(item) {
50
+ return (
51
+ <button
52
+ key={item.name}
53
+ type="button"
54
+ className="xeplr-nav-drawer-link"
55
+ onClick={item.clickHandler}
56
+ title={item.name}
57
+ >
58
+ {item.icon && <span className="xeplr-nav-drawer-icon">{item.icon}</span>}
59
+ {drawerOpen && <span className="xeplr-nav-drawer-label">{item.name}</span>}
60
+ </button>
61
+ );
62
+ }
63
+
64
+ return (
65
+ <aside className={'xeplr-nav-drawer' + (drawerOpen ? ' xeplr-nav-drawer-expanded' : ' xeplr-nav-drawer-collapsed')}>
66
+ <button
67
+ type="button"
68
+ className="xeplr-nav-drawer-toggle"
69
+ onClick={toggleDrawer}
70
+ aria-label={drawerOpen ? 'Collapse menu' : 'Expand menu'}
71
+ aria-expanded={drawerOpen}
72
+ >
73
+ {logo && <img src={logo} alt="" className="xeplr-nav-drawer-logo" />}
74
+ </button>
75
+ {/* Own row below the toggle, not squeezed inline beside it — expandedLogo
76
+ is typically a stacked icon+wordmark lockup (near-square, not a wide
77
+ banner), so it needs real height to stay legible, not the icon's 22px.
78
+ Additive, not a swap of the toggle's own image — no flicker either way. */}
79
+ {drawerOpen && expandedLogo && (
80
+ <img src={expandedLogo} alt="" className="xeplr-nav-drawer-expanded-logo" />
81
+ )}
82
+
83
+ {drawerOpen && (
84
+ <input
85
+ type="search"
86
+ className="xeplr-nav-drawer-search"
87
+ placeholder="Search"
88
+ value={query}
89
+ onChange={function(e) { setQuery(e.target.value); }}
90
+ />
91
+ )}
92
+
93
+ <nav className="xeplr-nav-drawer-links">
94
+ {buckets.ungrouped.map(renderItem)}
95
+ {buckets.groupOrder.map(function(groupName) {
96
+ return (
97
+ <div key={groupName} className="xeplr-nav-drawer-group">
98
+ {drawerOpen && <div className="xeplr-nav-drawer-group-label">{groupName}</div>}
99
+ {buckets.groups[groupName].map(renderItem)}
100
+ </div>
101
+ );
102
+ })}
103
+ {visibleItems.length === 0 && drawerOpen && <div className="xeplr-nav-drawer-empty">No matches</div>}
104
+ </nav>
105
+
106
+ {drawerOpen && drawerPromo && <div className="xeplr-nav-drawer-promo">{drawerPromo}</div>}
107
+
108
+ <div className="xeplr-nav-drawer-footer">
109
+ <NotificationsBell notifications={notifications} label={drawerOpen ? 'Notifications' : undefined} />
110
+ <AccountMenu
111
+ placement="top" triggerLabel={drawerOpen ? 'Settings' : undefined}
112
+ user={user} accountItems={accountItems}
113
+ accountOpen={accountOpen} toggleAccount={toggleAccount} closeAccount={closeAccount}
114
+ accountRef={accountRef} logout={logout}
115
+ />
116
+ </div>
117
+ </aside>
118
+ );
119
+ }
120
+
121
+ export default memo(NavDrawer);