@xeplr/ui-account 1.0.1 → 1.0.2

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-account",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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",
@@ -1,14 +1,16 @@
1
- import { createContext, useContext, useState, useEffect } from 'react';
1
+ import { createContext, useContext, useState, useEffect, useRef } from 'react';
2
+ import { raiseSnackbar } from '@xeplr/ui-utils';
2
3
  import { getToken, getUser } from './token.js';
3
- import { logoutUser } from './api.js';
4
+ import { logoutUser, getMe, setSessionExpiredHandler } from './api.js';
4
5
 
5
6
  const AccessContext = createContext(null);
6
7
 
7
8
  /**
8
9
  * AccessProvider — wraps your app to provide access state.
9
10
  *
10
- * On mount, loads access from localStorage (set during login).
11
- * Provides helpers: hasPage, hasApi, hasMenu, hasElement, hasRole.
11
+ * Seeds from localStorage (written at login) and then RE-READS IT FROM THE
12
+ * SERVER on mount. Provides helpers: hasPage, hasApi, hasMenu, hasElement,
13
+ * hasRole.
12
14
  *
13
15
  * Usage:
14
16
  * <AccessProvider>
@@ -57,6 +59,85 @@ export function AccessProvider({ children }) {
57
59
  setAccess(result.access);
58
60
  }
59
61
 
62
+ // ── re-read access from the server ──────────────────────────────────
63
+ //
64
+ // WHAT THIS FIXES. `access` was written to localStorage at login and read
65
+ // once, here, forever after. So it was a snapshot of what the user could see
66
+ // at the moment they signed in — and nothing on the server could change it.
67
+ // Seed a menu, grant a role, take a page away: none of it reached a
68
+ // signed-in browser until that person happened to log out, which could be
69
+ // weeks. Restarting the API did not help, because the API was never the
70
+ // thing holding the stale copy.
71
+ //
72
+ // It went unnoticed because the failure is silent in exactly the wrong
73
+ // direction: useNavController drops a drawer item whose name is not in
74
+ // `access.menus` without an error, so a new page simply is not in the rail
75
+ // and nothing anywhere says why.
76
+ //
77
+ // ON MOUNT, which on a SPA means once per full page load. That is the
78
+ // shortest honest promise: a change lands on next reload rather than next
79
+ // login.
80
+ /**
81
+ * Ask the server again. Also exposed on the context, so a screen that CHANGES
82
+ * access can show its own effect — the access matrix is the obvious one: an
83
+ * admin who grants a role and sees nothing happen has no way to tell a saved
84
+ * change from a broken one.
85
+ *
86
+ * Resolves either way. A caller awaiting it is waiting for "we tried", not
87
+ * for "it worked" — there is nothing useful for a nav bar to do about a
88
+ * failed refresh except carry on with what it had.
89
+ */
90
+ async function refreshAccess() {
91
+ try {
92
+ const result = await getMe();
93
+ if (!result) return null;
94
+ // REPLACED, not merged. Access is the whole answer to "what may this
95
+ // person see", and merging would keep a page that has just been revoked
96
+ // — the direction you least want to be wrong in.
97
+ if (result.access) setAccess(result.access);
98
+ if (result.user) setUser(result.user);
99
+ return result.access || null;
100
+ } catch (err) {
101
+ // KEEP WHAT WE HAVE. A network blip, a restarting API, a laptop that woke
102
+ // up on a train — none of those mean the user lost their permissions, and
103
+ // blanking `access` would empty the nav and bounce them out of the page
104
+ // they were reading. A genuinely dead session is handled separately, by
105
+ // the setSessionExpiredHandler(logout) effect below.
106
+ //
107
+ // But silently keeping stale state used to mean silently keeping the
108
+ // user in the dark too — this used to fail with zero indication
109
+ // anything was wrong. Still don't blank anything; just say so.
110
+ raiseSnackbar(err.message || 'Could not reach the server to refresh your access.', { design: 'error' });
111
+ return null;
112
+ }
113
+ }
114
+
115
+ // A dead session (refresh token missing, or rejected) is discovered inside
116
+ // authFetch — outside React entirely — which can wipe localStorage but has
117
+ // no way to flip THIS component's `authenticated` state on its own. Without
118
+ // this, clearAuth() runs and nothing downstream ever notices: ProtectedRoute
119
+ // keeps reading a stale `authenticated: true` and the app just sits there
120
+ // throwing errors on every call instead of bouncing to login.
121
+ useEffect(() => {
122
+ setSessionExpiredHandler(logout);
123
+ return () => setSessionExpiredHandler(null);
124
+ // eslint-disable-next-line react-hooks/exhaustive-deps
125
+ }, []);
126
+
127
+ const refreshedRef = useRef(false);
128
+ useEffect(() => {
129
+ // Nothing to refresh for a signed-out visitor, and asking would be a 401 on
130
+ // every login screen.
131
+ if (!authenticated) return;
132
+ // StrictMode double-invokes effects in development. One request, not two.
133
+ // Not reset on logout either: signing back in goes through onLogin, which
134
+ // already carries fresh access from the login response.
135
+ if (refreshedRef.current) return;
136
+ refreshedRef.current = true;
137
+ refreshAccess();
138
+ // eslint-disable-next-line react-hooks/exhaustive-deps
139
+ }, [authenticated]);
140
+
60
141
  // Access checkers
61
142
  function hasPage(pageName) {
62
143
  if (!access) return false;
@@ -90,6 +171,7 @@ export function AccessProvider({ children }) {
90
171
  onLogin,
91
172
  logout,
92
173
  setAccess,
174
+ refreshAccess,
93
175
  hasPage,
94
176
  hasApi,
95
177
  hasMenu,
@@ -1,5 +1,6 @@
1
- import { Navigate } from 'react-router-dom';
1
+ import { Navigate, useLocation } from 'react-router-dom';
2
2
  import { useAccessStrict } from './AccessContext.jsx';
3
+ import { saveReturnTo } from './returnTo.js';
3
4
 
4
5
  /**
5
6
  * ProtectedRoute — guards a route based on auth and access.
@@ -35,8 +36,10 @@ export function ProtectedRoute({
35
36
  deniedPath = '/auth/login'
36
37
  }) {
37
38
  const { authenticated, hasPage, hasRole } = useAccessStrict();
39
+ const location = useLocation();
38
40
 
39
41
  if (!authenticated) {
42
+ saveReturnTo(location);
40
43
  return <Navigate to={loginPath} replace />;
41
44
  }
42
45
 
@@ -27,6 +27,31 @@ export function setActiveScope(level, scope) {
27
27
  } catch (e) {}
28
28
  }
29
29
 
30
+ const LAST_STORAGE_PREFIX = 'xeplr:lastScope:';
31
+
32
+ /**
33
+ * The last value set at a given level, independent of the ACTIVE scope above
34
+ * and deliberately untouched by clearActiveScope() — so an app can offer to
35
+ * resume the same company/workspace after a logout without silently
36
+ * bypassing whatever cleared the active scope. A caller decides whether and
37
+ * how to re-verify eligibility before trusting this; auth doesn't.
38
+ */
39
+ export function getLastScope(level) {
40
+ try {
41
+ const raw = localStorage.getItem(LAST_STORAGE_PREFIX + level);
42
+ return raw ? JSON.parse(raw) : null;
43
+ } catch (e) {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ export function setLastScope(level, scope) {
49
+ try {
50
+ if (scope) localStorage.setItem(LAST_STORAGE_PREFIX + level, JSON.stringify(scope));
51
+ else localStorage.removeItem(LAST_STORAGE_PREFIX + level);
52
+ } catch (e) {}
53
+ }
54
+
30
55
  /**
31
56
  * Clear one level's scope, or every level if omitted (e.g. on logout).
32
57
  */
package/src/api.js CHANGED
@@ -14,13 +14,238 @@ let _refreshPromise = null;
14
14
  */
15
15
  export function configure(baseUrl, options = {}) {
16
16
  _baseUrl = baseUrl;
17
- _onSessionExpired = options.onSessionExpired || null;
17
+ if (options.onSessionExpired) _onSessionExpired = options.onSessionExpired;
18
+ }
19
+
20
+ /**
21
+ * Register the session-expired handler on its own, separate from configure().
22
+ *
23
+ * configure(baseUrl) runs at module load, before React mounts — AccessProvider
24
+ * (the thing that actually owns `authenticated` state and a real logout()) does
25
+ * not exist yet at that point. AccessProvider calls this itself, once, from a
26
+ * mount effect — so every consumer of this package gets a session-expired
27
+ * handler wired to its own auth state automatically, without the app having to
28
+ * remember to pass onSessionExpired through configure() by hand.
29
+ */
30
+ export function setSessionExpiredHandler(fn) {
31
+ _onSessionExpired = fn || null;
18
32
  }
19
33
 
20
34
  function getBaseUrl() {
21
35
  return _baseUrl || '';
22
36
  }
23
37
 
38
+ /**
39
+ * How much of an unreadable body to keep. Enough to recognise a login page, a
40
+ * proxy's 502 template or a stack trace; not so much that one bad response
41
+ * fills the console and buries the request that caused it.
42
+ */
43
+ const BODY_SNIPPET_LIMIT = 600;
44
+
45
+ /**
46
+ * What the body looks like, said in one word.
47
+ *
48
+ * Nine times in ten the answer is already here — "it's an HTML page" means a
49
+ * proxy or a dev server answered instead of the API, and "it's empty" means
50
+ * the connection closed before anything was written. Naming the shape saves
51
+ * reading the snippet at all.
52
+ */
53
+ function describeBody(text, contentType) {
54
+ const body = (text || '').trim();
55
+ if (!body) return 'the body was empty';
56
+ const head = body.slice(0, 200).toLowerCase();
57
+ if (head.startsWith('<!doctype html') || head.startsWith('<html')) {
58
+ return 'the body is an HTML page, not JSON — something other than the API answered (a proxy, a dev server, or a login redirect)';
59
+ }
60
+ if (head.startsWith('<')) return 'the body is markup, not JSON';
61
+ if (head.startsWith('<?xml')) return 'the body is XML, not JSON';
62
+ if (contentType && contentType.indexOf('json') === -1) {
63
+ return `the body is ${contentType}, not JSON`;
64
+ }
65
+ return 'the body starts like JSON but does not parse — it is probably truncated';
66
+ }
67
+
68
+ /**
69
+ * res.json() throws a raw, user-facing-unfriendly SyntaxError ("Unexpected
70
+ * end of JSON input") when the body isn't valid JSON — a restarting API, a
71
+ * proxy timeout, or an unhandled exception upstream (e.g. a downstream DB
72
+ * dependency being down) all produce exactly this. Every caller of authFetch/
73
+ * request needs a real Error with a message worth showing, not a parser
74
+ * crash — this is the one place that guarantees it.
75
+ *
76
+ * ── and it has to say WHY, not only that ────────────────────────────────
77
+ *
78
+ * "The server sent back something we could not read" is a fine sentence for
79
+ * the person using the app and useless to the person fixing it. It is the same
80
+ * sentence whether the API was restarting, a proxy returned its own HTML error
81
+ * page, the response was truncated mid-flight, or an auth redirect landed the
82
+ * login page on a JSON endpoint — four different problems with four different
83
+ * fixes, reported identically. Somebody then has to reproduce it with devtools
84
+ * open to learn what a failed request already knew.
85
+ *
86
+ * So the console gets everything the moment it happens: which request, the
87
+ * status, the content type, how many bytes arrived, what the body appears to
88
+ * be, and the first {BODY_SNIPPET_LIMIT} characters of it verbatim. Same
89
+ * bargain the movement log makes — the sentence is for a human, the facts are
90
+ * for whoever has to answer for it, and neither replaces the other.
91
+ *
92
+ * Read via text() rather than json() on purpose: json() consumes the stream,
93
+ * so by the time it throws the body cannot be read back to find out what it
94
+ * was. Parsing the text ourselves is the same result on the happy path and
95
+ * leaves the evidence intact on the unhappy one.
96
+ */
97
+ async function parseJsonResponse(res, sent) {
98
+ const contentType = (res.headers && res.headers.get('content-type')) || '';
99
+ const label = sent ? `${sent.method || 'GET'} ${sent.url || ''}` : (res.url || 'request');
100
+
101
+ let text;
102
+ try {
103
+ text = await res.text();
104
+ } catch (e) {
105
+ // The stream itself failed — a connection dropped mid-body. There is no
106
+ // snippet to show, and saying so is better than an empty one implying the
107
+ // server sent nothing.
108
+ console.error(
109
+ `[api] ${label} — the response body could not be read (status ${res.status}): ${e.message}`
110
+ );
111
+ const err = new Error('The connection dropped while the server was replying. Please try again.');
112
+ err.status = res.status;
113
+ return Promise.reject(err);
114
+ }
115
+
116
+ try {
117
+ // '' is not valid JSON, which is the behaviour we want for a 204 or an
118
+ // empty error body — it lands in the catch below with "the body was empty"
119
+ // rather than silently becoming null.
120
+ return JSON.parse(text);
121
+ } catch (e) {
122
+ const shape = describeBody(text, contentType);
123
+ const snippet = (text || '').slice(0, BODY_SNIPPET_LIMIT);
124
+
125
+ // console.error, not warn: nothing downstream can recover from this, and a
126
+ // warning is what people filter out.
127
+ console.error(
128
+ `[api] ${label} — could not read the reply as JSON.\n` +
129
+ ` status : ${res.status}${res.statusText ? ' ' + res.statusText : ''}\n` +
130
+ ` content-type : ${contentType || '(none sent)'}\n` +
131
+ ` body length : ${(text || '').length} character(s)\n` +
132
+ ` looks like : ${shape}\n` +
133
+ ` parser said : ${e.message}\n` +
134
+ ` body starts : ${snippet || '(nothing)'}` +
135
+ ((text || '').length > BODY_SNIPPET_LIMIT ? `\n (truncated — ${(text || '').length - BODY_SNIPPET_LIMIT} more character(s))` : '')
136
+ );
137
+
138
+ const err = new Error(
139
+ res.ok
140
+ ? `The server sent back something we could not read (${shape}). Please try again.`
141
+ : `The server is not responding correctly (status ${res.status}). It may be restarting — please try again in a moment.`
142
+ );
143
+ // Carried on the error as well as logged: a caller that wants to show the
144
+ // detail in a snackbar, or attach it to a bug report, should not have to
145
+ // ask the user to open devtools and copy a console line.
146
+ err.status = res.status;
147
+ err.contentType = contentType || null;
148
+ err.rawBody = snippet;
149
+ err.bodyLength = (text || '').length;
150
+ err.parseError = e.message;
151
+ err.request = label;
152
+ throw err;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * The most useful sentence in a failed response.
158
+ *
159
+ * `error` is a STRING on a handled refusal ("Not authorized for companyId …")
160
+ * and an OBJECT on an unhandled one — the framework's error handler sends
161
+ * { code, message, error: { name, message, nativeError } }. This used to be
162
+ * `data.error || 'Something went wrong'`, which for the object case built
163
+ * `new Error({...})` and produced the message "[object Object]".
164
+ *
165
+ * That is the worst possible outcome for the one place every request funnels
166
+ * through: the server said "column reportTimezone of relation cubes does not
167
+ * exist" and the screen said nothing at all. Every caller shows err.message,
168
+ * so what this picks is what the user is told.
169
+ *
170
+ * Order matters. The nested `error.message` is the specific one; `message` at
171
+ * the top level is usually the generic "Internal server error" that would tell
172
+ * somebody nothing.
173
+ */
174
+ function errorMessage(data) {
175
+ if (!data) return 'Something went wrong';
176
+ if (typeof data.error === 'string' && data.error) return data.error;
177
+ if (data.error && typeof data.error.message === 'string' && data.error.message) {
178
+ return data.error.message;
179
+ }
180
+ if (typeof data.message === 'string' && data.message) return data.message;
181
+ return 'Something went wrong';
182
+ }
183
+
184
+ /**
185
+ * The Error every failed request throws — carrying WHY, not just what.
186
+ *
187
+ * `new Error(message)` discards the status code and the response body, so
188
+ * every caller could see the sentence and nothing else. That flattens two very
189
+ * different things into one: "you did something wrong" and "this is not ready
190
+ * yet, ask again shortly".
191
+ *
192
+ * It cost a real bug. A cube profile answers 409 with `busy: true` while a
193
+ * table is still being copied — a WAIT — and the screen rendered it as a red
194
+ * error banner, so a table doing exactly what it should looked like a failure
195
+ * and stopped the user.
196
+ *
197
+ * So the status and the body ride along. Existing callers read `err.message`
198
+ * and are unaffected; callers that care can now tell a 409 from a 400.
199
+ */
200
+ function httpError(data, res, sent) {
201
+ const err = new Error(errorMessage(data));
202
+ err.status = res ? res.status : 0;
203
+ err.body = data || null;
204
+ // Hoisted for the flags a server sets deliberately, so a caller writes
205
+ // `err.busy` rather than `err.body && err.body.busy`.
206
+ if (data && typeof data === 'object') {
207
+ if (data.busy !== undefined) err.busy = data.busy;
208
+ if (data.code !== undefined) err.code = data.code;
209
+ }
210
+
211
+ // ── WHICH REQUEST FAILED, said out loud, every time ────────────────────
212
+ //
213
+ // The thrown Error carries a sentence, and a sentence is all the snackbar
214
+ // shows: "Not authorized for companyId", "column reportTimezone does not
215
+ // exist". Neither says which URL produced it — and the same sentence can
216
+ // come from several endpoints, so the first question after seeing one is
217
+ // always "coming from where?", answered today by opening the network tab
218
+ // and reproducing it.
219
+ //
220
+ // Every failure funnels through here, so this is the one place that can
221
+ // answer it for free. A failed request is rare by definition; there is no
222
+ // volume argument for staying quiet about it.
223
+ //
224
+ // `res.url` is the resolved, final URL — after redirects — which is
225
+ // occasionally the actual answer (a request that ended up somewhere other
226
+ // than the API is exactly why the reply did not parse).
227
+ const label = sent
228
+ ? `${sent.method || 'GET'} ${sent.url || ''}`
229
+ : ((res && res.url) || 'request');
230
+ err.request = label;
231
+
232
+ const detail = data && typeof data === 'object' && data.error && typeof data.error === 'object'
233
+ ? data.error
234
+ : null;
235
+
236
+ console.error(
237
+ `[api] ${label} failed — ${res ? res.status : 0}${res && res.statusText ? ' ' + res.statusText : ''}: ${err.message}` +
238
+ (err.code !== undefined ? `\n code : ${err.code}` : '') +
239
+ (err.busy !== undefined ? `\n busy : ${err.busy}` : '') +
240
+ // The server's own error object when it sent one — the name and the
241
+ // native database/driver message under the friendly sentence.
242
+ (detail && detail.name ? `\n name : ${detail.name}` : '') +
243
+ (detail && detail.nativeError ? `\n native : ${typeof detail.nativeError === 'string' ? detail.nativeError : JSON.stringify(detail.nativeError)}` : '')
244
+ );
245
+
246
+ return err;
247
+ }
248
+
24
249
  /**
25
250
  * Refresh the access token using the stored refresh token.
26
251
  * Returns true if refresh succeeded, false if session is dead.
@@ -32,7 +257,14 @@ async function refreshAccessToken() {
32
257
 
33
258
  _refreshPromise = (async () => {
34
259
  const refreshToken = getRefreshToken();
35
- if (!refreshToken) return false;
260
+ if (!refreshToken) {
261
+ // No refresh token to even try — this session is just as dead as one
262
+ // where the refresh call gets rejected below, and needs the same
263
+ // cleanup + notification, not a silent no-op.
264
+ clearAuth();
265
+ if (_onSessionExpired) _onSessionExpired();
266
+ return false;
267
+ }
36
268
 
37
269
  try {
38
270
  const res = await fetch(`${getBaseUrl()}/auth/api/refresh`, {
@@ -121,9 +353,9 @@ export async function authFetch(endpoint, options = {}) {
121
353
  }
122
354
  }
123
355
 
124
- const data = await res.json();
356
+ const data = await parseJsonResponse(res, { method: options.method || 'GET', url: url });
125
357
  if (!res.ok) {
126
- throw new Error(data.error || 'Something went wrong');
358
+ throw httpError(data, res, { method: options.method || 'GET', url: url });
127
359
  }
128
360
  return data;
129
361
  }
@@ -136,9 +368,10 @@ async function request(endpoint, options = {}) {
136
368
  headers: { 'Content-Type': 'application/json' },
137
369
  ...options,
138
370
  });
139
- const data = await res.json();
371
+ const requested = { method: options.method || 'GET', url: `${getBaseUrl()}${endpoint}` };
372
+ const data = await parseJsonResponse(res, requested);
140
373
  if (!res.ok) {
141
- throw new Error(data.error || 'Something went wrong');
374
+ throw httpError(data, res, requested);
142
375
  }
143
376
  return data;
144
377
  }
@@ -150,8 +383,14 @@ export function registerUser({ email, password, name, phoneNumber }) {
150
383
  });
151
384
  }
152
385
 
153
- export function activateAccount(token) {
154
- return request('/auth/api/activate?token=' + encodeURIComponent(token));
386
+ export function activateAccount(token, workflowKey) {
387
+ // workflowKey travels in the activation link and is handed straight back —
388
+ // it releases a workflow step that was waiting for this person to activate
389
+ // (see @xeplr/auth's configureWorkflowResume). Absent for an ordinary
390
+ // registration, and the server treats it as optional.
391
+ var url = '/auth/api/activate?token=' + encodeURIComponent(token);
392
+ if (workflowKey) url += '&workflowKey=' + encodeURIComponent(workflowKey);
393
+ return request(url);
155
394
  }
156
395
 
157
396
  export function loginUser({ email, password }) {
@@ -182,6 +421,22 @@ export function changePassword({ currentPassword, newPassword }) {
182
421
  });
183
422
  }
184
423
 
424
+ /**
425
+ * WHO AM I AND WHAT MAY I SEE — answered fresh by the server.
426
+ *
427
+ * Returns { user, access }. The same object login hands back, which is the
428
+ * point: it is the one shape AccessProvider stores, so re-reading it is a
429
+ * replacement rather than a merge.
430
+ *
431
+ * Exists because access was written to localStorage at login and never read
432
+ * again from anywhere else. Grant somebody a role, seed a menu, take a page
433
+ * away — none of it reached a signed-in browser until that person happened to
434
+ * log out, which could be weeks. See AccessProvider.
435
+ */
436
+ export function getMe() {
437
+ return authFetch('/auth/api/me');
438
+ }
439
+
185
440
  export function getProfile() {
186
441
  return authFetch('/auth/api/profile');
187
442
  }
@@ -211,9 +466,10 @@ export async function uploadAvatar(file) {
211
466
  headers,
212
467
  body: formData,
213
468
  });
214
- const data = await res.json();
469
+ const avatarRequest = { method: 'POST', url: `${getBaseUrl()}/auth/api/profile/avatar` };
470
+ const data = await parseJsonResponse(res, avatarRequest);
215
471
  if (!res.ok) {
216
- throw new Error(data.error || 'Something went wrong');
472
+ throw httpError(data, res, avatarRequest);
217
473
  }
218
474
  return data;
219
475
  }
@@ -34,10 +34,24 @@ function bucketItems(items) {
34
34
 
35
35
  function NavDrawer({
36
36
  drawerOpen, toggleDrawer, drawerItems, expandedLogo, logo, drawerPromo,
37
- user, accountItems, notifications, accountOpen, toggleAccount, closeAccount, accountRef, logout
37
+ user, accountItems, notifications, accountOpen, toggleAccount, closeAccount, accountRef, logout,
38
+ hideFooterIcons,
39
+ drawerWidth, drawerResizing, startDrawerResize, resetDrawerWidth, nudgeDrawerWidth, drawerWidthBounds
38
40
  }) {
39
41
  var [query, setQuery] = useState('');
40
42
 
43
+ var bounds = drawerWidthBounds || { min: 180, max: 480, step: 16 };
44
+
45
+ // Arrow keys resize, Home/End jump to the stops — the handle is focusable,
46
+ // so everything the drag does has to be reachable from the keyboard too.
47
+ function onResizeKeyDown(e) {
48
+ if (e.key === 'ArrowLeft') { e.preventDefault(); nudgeDrawerWidth(-bounds.step); }
49
+ else if (e.key === 'ArrowRight') { e.preventDefault(); nudgeDrawerWidth(bounds.step); }
50
+ else if (e.key === 'Home') { e.preventDefault(); nudgeDrawerWidth(-Infinity); }
51
+ else if (e.key === 'End') { e.preventDefault(); nudgeDrawerWidth(Infinity); }
52
+ else if (e.key === 'Enter') { e.preventDefault(); resetDrawerWidth(); }
53
+ }
54
+
41
55
  var visibleItems = useMemo(function() {
42
56
  if (!drawerOpen || !query.trim()) return drawerItems;
43
57
  var q = query.trim().toLowerCase();
@@ -47,22 +61,51 @@ function NavDrawer({
47
61
  var buckets = useMemo(function() { return bucketItems(visibleItems); }, [visibleItems]);
48
62
 
49
63
  function renderItem(item) {
64
+ // A COUNT THAT HAS TO BE SEEN FROM WHEREVER YOU ARE.
65
+ //
66
+ // Optional `item.badge` — a number or a short string. It renders as a pill
67
+ // beside the label when the drawer is expanded and as a dot on the icon
68
+ // when it is collapsed, because the collapsed rail is the state most
69
+ // people leave it in and a badge only visible when expanded is a badge
70
+ // that does not do its job.
71
+ //
72
+ // 0, null and undefined all render NOTHING. A pill reading "0" is noise
73
+ // that trains people to stop looking at the pill.
74
+ var badge = item.badge === 0 || item.badge == null || item.badge === '' ? null : item.badge;
50
75
  return (
51
76
  <button
52
77
  key={item.name}
53
78
  type="button"
54
- className="xeplr-nav-drawer-link"
79
+ className={'xeplr-nav-drawer-link' + (badge ? ' xeplr-nav-drawer-link-badged' : '')}
55
80
  onClick={item.clickHandler}
56
- title={item.name}
81
+ // The count belongs in the tooltip too — the collapsed dot says
82
+ // "something", and the hover has to say how many.
83
+ title={badge ? item.name + ' (' + badge + ')' : item.name}
57
84
  >
58
- {item.icon && <span className="xeplr-nav-drawer-icon">{item.icon}</span>}
85
+ {item.icon && (
86
+ <span className="xeplr-nav-drawer-icon">
87
+ {item.icon}
88
+ {badge && !drawerOpen && <span className="xeplr-nav-drawer-dot" aria-hidden="true" />}
89
+ </span>
90
+ )}
59
91
  {drawerOpen && <span className="xeplr-nav-drawer-label">{item.name}</span>}
92
+ {drawerOpen && badge && <span className="xeplr-nav-drawer-badge">{badge}</span>}
60
93
  </button>
61
94
  );
62
95
  }
63
96
 
64
97
  return (
65
- <aside className={'xeplr-nav-drawer' + (drawerOpen ? ' xeplr-nav-drawer-expanded' : ' xeplr-nav-drawer-collapsed')}>
98
+ <aside
99
+ className={
100
+ 'xeplr-nav-drawer'
101
+ + (drawerOpen ? ' xeplr-nav-drawer-expanded' : ' xeplr-nav-drawer-collapsed')
102
+ + (drawerResizing ? ' xeplr-nav-drawer-resizing' : '')
103
+ }
104
+ /* Inline width ONLY when expanded — collapsed is the fixed icon rail and
105
+ nav.css keeps owning that number. Inline because it changes per
106
+ pointermove; a stylesheet cannot express a live drag. */
107
+ style={drawerOpen && drawerWidth ? { width: drawerWidth + 'px' } : undefined}
108
+ >
66
109
  <button
67
110
  type="button"
68
111
  className="xeplr-nav-drawer-toggle"
@@ -70,12 +113,19 @@ function NavDrawer({
70
113
  aria-label={drawerOpen ? 'Collapse menu' : 'Expand menu'}
71
114
  aria-expanded={drawerOpen}
72
115
  >
73
- {logo && <img src={logo} alt="" className="xeplr-nav-drawer-logo" />}
116
+ {/* ONE MARK AT A TIME.
117
+ expandedLogo is a stacked icon+wordmark lockup — it CONTAINS this
118
+ icon — so rendering both put the same mark on screen twice, one
119
+ above the other. Collapsed, the icon is the brand and the thing you
120
+ click to expand; open, the lockup is the brand and this is only the
121
+ collapse control, which the chevron says better than a second copy
122
+ of the logo. */}
123
+ {drawerOpen && expandedLogo
124
+ ? <span className="xeplr-nav-drawer-collapse" aria-hidden="true">«</span>
125
+ : (logo && <img src={logo} alt="" className="xeplr-nav-drawer-logo" />)}
74
126
  </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. */}
127
+ {/* Own row below the toggle, not squeezed inline beside it — a stacked
128
+ lockup needs real height to stay legible, not the icon's 22px. */}
79
129
  {drawerOpen && expandedLogo && (
80
130
  <img src={expandedLogo} alt="" className="xeplr-nav-drawer-expanded-logo" />
81
131
  )}
@@ -105,15 +155,38 @@ function NavDrawer({
105
155
 
106
156
  {drawerOpen && drawerPromo && <div className="xeplr-nav-drawer-promo">{drawerPromo}</div>}
107
157
 
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}
158
+ {!hideFooterIcons && (
159
+ <div className="xeplr-nav-drawer-footer">
160
+ <NotificationsBell notifications={notifications} label={drawerOpen ? 'Notifications' : undefined} />
161
+ <AccountMenu
162
+ placement="top" triggerLabel={drawerOpen ? 'Settings' : undefined}
163
+ user={user} accountItems={accountItems}
164
+ accountOpen={accountOpen} toggleAccount={toggleAccount} closeAccount={closeAccount}
165
+ accountRef={accountRef} logout={logout}
166
+ />
167
+ </div>
168
+ )}
169
+
170
+ {/* LAST child and absolutely positioned, so it sits over the drawer's
171
+ right border at any height without taking part in the column layout
172
+ above it. Only when expanded: there is nothing to widen on a 60px
173
+ icon rail, and a resize handle there would just fight the toggle. */}
174
+ {drawerOpen && (
175
+ <div
176
+ className="xeplr-nav-drawer-resize"
177
+ role="separator"
178
+ aria-orientation="vertical"
179
+ aria-label="Resize menu"
180
+ aria-valuenow={drawerWidth}
181
+ aria-valuemin={bounds.min}
182
+ aria-valuemax={bounds.max}
183
+ tabIndex={0}
184
+ onPointerDown={startDrawerResize}
185
+ onDoubleClick={resetDrawerWidth}
186
+ onKeyDown={onResizeKeyDown}
187
+ title="Drag to resize — double-click to reset"
115
188
  />
116
- </div>
189
+ )}
117
190
  </aside>
118
191
  );
119
192
  }
@@ -0,0 +1,25 @@
1
+ import { memo } from 'react';
2
+ import AccountMenu from './AccountMenu.jsx';
3
+ import NotificationsBell from './NotificationsBell.jsx';
4
+ import './nav.css';
5
+
6
+ // A minimal top-right overlay — just the notifications bell + account/settings
7
+ // trigger, no logo/middle slot/header bar around them. Opt-in via NavPage's
8
+ // `floatingSettings` prop, for layouts (e.g. an icon drawer) that want these
9
+ // two floating over the page instead of pinned to the drawer's own footer.
10
+ function NavFloatingSettings({
11
+ user, accountItems, notifications, accountOpen, toggleAccount, closeAccount, accountRef, logout
12
+ }) {
13
+ return (
14
+ <div className="xeplr-nav-floating">
15
+ <NotificationsBell notifications={notifications} />
16
+ <AccountMenu
17
+ user={user} accountItems={accountItems}
18
+ accountOpen={accountOpen} toggleAccount={toggleAccount} closeAccount={closeAccount}
19
+ accountRef={accountRef} logout={logout}
20
+ />
21
+ </div>
22
+ );
23
+ }
24
+
25
+ export default memo(NavFloatingSettings);
@@ -12,4 +12,5 @@ export { default as MasterSettingsSample } from './MasterSettingsSample.jsx';
12
12
  export { default as NavTopSample } from './NavTopSample.jsx';
13
13
  export { default as AccountMenu } from './AccountMenu.jsx';
14
14
  export { default as NavDrawer } from './NavDrawer.jsx';
15
+ export { default as NavFloatingSettings } from './NavFloatingSettings.jsx';
15
16
  export { default as NotificationsBell } from './NotificationsBell.jsx';
@@ -197,7 +197,18 @@
197
197
  position: fixed;
198
198
  top: 0;
199
199
  left: 0;
200
- z-index: 90;
200
+ /* ABOVE .xeplr-nav-floating (400). Both are global chrome, so the tie goes
201
+ to the one being operated: widen the drawer and it reaches under the
202
+ floating bell/settings in the top-right corner, where those two icons
203
+ swallow clicks meant for the drawer — the resize handle's top edge
204
+ included. The floating cluster keeps 400 and still outranks page-level
205
+ chrome, which is what that value is actually for.
206
+
207
+ THE CEILING THIS SETS: a real full-screen modal must out-stack this, or
208
+ the rail punches through it. Consumers with one are expected to sit above
209
+ 500 — xeplr-bi's .dsh-expanded-backdrop was moved to 600 when this
210
+ changed. */
211
+ z-index: 500;
201
212
  display: flex;
202
213
  flex-direction: column;
203
214
  height: 100vh;
@@ -210,8 +221,52 @@
210
221
  }
211
222
 
212
223
  .xeplr-nav-drawer-collapsed { width: 60px; }
224
+ /* The 240px is the DEFAULT only — NavDrawer sets an inline width from the
225
+ remembered drag, which outranks this. Kept here so the drawer still has a
226
+ sane width if the controller's stored value is unreadable. */
213
227
  .xeplr-nav-drawer-expanded { width: 240px; padding: 16px; }
214
228
 
229
+ /* Mid-drag the width changes every pointermove, and a 0.15s transition on it
230
+ means the edge chases the cursor instead of tracking it. Kill it for the
231
+ duration; the toggle animation is unaffected because it never coincides
232
+ with a drag. */
233
+ .xeplr-nav-drawer-resizing { transition: none; }
234
+
235
+ /* ── the resize handle ─────────────────────────────────────────────────
236
+ Straddles the right border (right: -3px on a 6px strip) rather than sitting
237
+ inside it, so the grab target covers the edge the user is actually aiming
238
+ at — an inside-only strip makes you undershoot into the nav. */
239
+ .xeplr-nav-drawer-resize {
240
+ position: absolute;
241
+ top: 0;
242
+ right: -3px;
243
+ width: 6px;
244
+ height: 100%;
245
+ cursor: col-resize;
246
+ /* Invisible until wanted: an always-on divider line reads as a border and
247
+ invites clicks that do nothing. */
248
+ background: transparent;
249
+ transition: background 0.12s;
250
+ /* Above the drawer's own content so the grab always lands on the handle.
251
+ Local to the drawer's stacking context, so it stays under the footer's
252
+ account popup rather than competing with the page. */
253
+ z-index: 1;
254
+ }
255
+ .xeplr-nav-drawer-resize:hover,
256
+ .xeplr-nav-drawer-resize:focus-visible,
257
+ .xeplr-nav-drawer-resizing .xeplr-nav-drawer-resize {
258
+ background: var(--xeplr-accent, #2563eb);
259
+ }
260
+ /* The focus ring would be a 6px sliver and easy to miss; the accent fill above
261
+ is the visible focus state, so the default outline only gets in its way. */
262
+ .xeplr-nav-drawer-resize:focus-visible { outline: none; }
263
+
264
+ /* Set on <body> for the length of the drag (see useNavController). A drag
265
+ routinely outruns the 6px handle, and without this the cursor flips back to
266
+ a caret and the page starts blue-selecting the moment it does. */
267
+ body.xeplr-nav-resizing { user-select: none; }
268
+ body.xeplr-nav-resizing * { cursor: col-resize !important; }
269
+
215
270
  .xeplr-nav-drawer-toggle {
216
271
  display: block;
217
272
  flex-shrink: 0;
@@ -222,6 +277,23 @@
222
277
  cursor: pointer;
223
278
  align-self: flex-start;
224
279
  }
280
+ /* CENTRED WHEN COLLAPSED, like everything under it.
281
+ The rail's links get `justify-content: center`, so their icons sit in the
282
+ middle of the 40px of content width. The toggle had `padding: 0` and
283
+ `align-self: flex-start`, which pinned the logo hard against the left edge —
284
+ so the mark and the first menu icon were visibly on two different vertical
285
+ lines, which is the one thing a rail of icons cannot afford. */
286
+ .xeplr-nav-drawer-collapsed .xeplr-nav-drawer-toggle { align-self: center; }
287
+
288
+ /* The collapse affordance, when the lockup below is carrying the brand. */
289
+ .xeplr-nav-drawer-collapse {
290
+ display: block;
291
+ width: 22px;
292
+ line-height: 22px;
293
+ text-align: center;
294
+ font-size: 15px;
295
+ color: var(--xeplr-text-secondary, #8b8f9a);
296
+ }
225
297
 
226
298
  /* Fixed, small, and never swapped for a different image (see NavDrawer.jsx) —
227
299
  deliberately smaller than the collapsed rail's content width (60px rail -
@@ -289,6 +361,42 @@
289
361
  .xeplr-nav-drawer-icon { display: grid; place-items: center; flex-shrink: 0; }
290
362
  .xeplr-nav-drawer-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
291
363
 
364
+ /* ── item badges ─────────────────────────────────────────────────────────
365
+ Optional `item.badge`. The pill takes the remaining width (margin-left
366
+ auto) so counts line up down the rail instead of tracking label lengths.
367
+ The label keeps flex:1 so a long name still ellipsises rather than pushing
368
+ the pill off the edge. */
369
+ .xeplr-nav-drawer-link-badged .xeplr-nav-drawer-label { flex: 1; }
370
+ .xeplr-nav-drawer-badge {
371
+ margin-left: auto;
372
+ flex-shrink: 0;
373
+ min-width: 18px;
374
+ padding: 1px 6px;
375
+ border-radius: 9px;
376
+ background: var(--xeplr-danger, #c0392b);
377
+ color: #fff;
378
+ font-size: 11px;
379
+ font-weight: 600;
380
+ line-height: 16px;
381
+ text-align: center;
382
+ }
383
+ /* Collapsed: the rail is icons only, so the count becomes a dot pinned to the
384
+ icon's corner. `position: relative` on the icon rather than the button
385
+ because the button is centred and the icon is what the eye is on. */
386
+ .xeplr-nav-drawer-collapsed .xeplr-nav-drawer-icon { position: relative; }
387
+ .xeplr-nav-drawer-dot {
388
+ position: absolute;
389
+ top: -2px;
390
+ right: -3px;
391
+ width: 8px;
392
+ height: 8px;
393
+ border-radius: 50%;
394
+ background: var(--xeplr-danger, #c0392b);
395
+ /* Against the rail's own background, so the dot reads as ON the icon rather
396
+ than as part of it. */
397
+ box-shadow: 0 0 0 2px var(--xeplr-bg-elevated, #fff);
398
+ }
399
+
292
400
  .xeplr-nav-drawer-empty { padding: 10px 12px; color: var(--xeplr-text-muted); font-size: 13px; }
293
401
 
294
402
  .xeplr-nav-drawer-promo { padding-top: 12px; }
@@ -309,3 +417,23 @@
309
417
  margin-top: 12px;
310
418
  border-top: 1px solid var(--xeplr-border-secondary);
311
419
  }
420
+
421
+ /* Opt-in alternative to the drawer footer above (see NavPage's `floatingSettings`
422
+ prop) — same bell + account trigger, floating over the page instead of
423
+ pinned to the drawer. Just the two icons themselves, no card/box around
424
+ them — no background, border, or shadow.
425
+
426
+ z-index sits above page-level floating chrome, not below it. The icons
427
+ themselves never overlap anything, but the account menu drops DOWN over the
428
+ page — and a settings menu that opens behind a page toolbar is unusable.
429
+ Global chrome wins over page tools; a consumer's own floating toolbar
430
+ should stay below this and position itself clear of the icons. */
431
+ .xeplr-nav-floating {
432
+ position: fixed;
433
+ top: 16px;
434
+ right: 16px;
435
+ z-index: 400;
436
+ display: flex;
437
+ align-items: center;
438
+ gap: 8px;
439
+ }
package/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // API client
2
- export { configure, registerUser, loginUser, activateAccount, forgotPassword, resetPassword, changePassword, getProfile, updateProfile, uploadAvatar, logoutUser, authFetch } from './api.js';
2
+ export { configure, setSessionExpiredHandler, registerUser, loginUser, activateAccount, forgotPassword, resetPassword, changePassword, getMe, getProfile, updateProfile, uploadAvatar, logoutUser, authFetch } from './api.js';
3
3
 
4
4
  // Token helpers
5
5
  export { getToken, setToken, getRefreshToken, setRefreshToken, getUser, setUser, clearAuth, isAuthenticated } from './token.js';
@@ -12,7 +12,12 @@ export { registerMTs, getMtConfig } from './mt.js';
12
12
  // Active scope (e.g. company/workspace), keyed by MT level (l1, l2, ...) — a
13
13
  // bare, app-interpreted value per level, attached to every authFetch call as
14
14
  // that level's configured header. See activeScope.js.
15
- export { getActiveScope, setActiveScope, clearActiveScope } from './activeScope.js';
15
+ export { getActiveScope, setActiveScope, clearActiveScope, getLastScope, setLastScope } from './activeScope.js';
16
+
17
+ // Where to send the user once a redirect-driven gate (auth, or an app's own
18
+ // gate) resolves — e.g. back to the exact dashboard a deep link pointed at,
19
+ // rather than always home. See returnTo.js.
20
+ export { saveReturnTo, consumeReturnTo } from './returnTo.js';
16
21
 
17
22
  // Controller hooks
18
23
  export { useLoginController } from './useLoginController.js';
@@ -43,7 +48,7 @@ export { getMasterItems, saveMasterItem, deleteMasterItem, MASTER_TYPES } from '
43
48
  export { useDesignValidator, LOGIN_RULES, REGISTER_RULES, FORGOT_PASSWORD_RULES, RESET_PASSWORD_RULES, CHANGE_PASSWORD_RULES, PROFILE_RULES, USER_ROLES_MATRIX_RULES, ACCESS_MATRIX_RULES, MASTER_SETTINGS_RULES, NAV_RULES } from './validateDesign.js';
44
49
 
45
50
  // Sample designs (use as reference or starting point)
46
- export { LoginSample, RegisterSample, ForgotPasswordSample, ResetPasswordSample, ActivateSample, NotActivatedSample, ChangePasswordSample, ProfileSample, UserRolesMatrixSample, AccessMatrixSample, MasterSettingsSample, NavTopSample, AccountMenu, NavDrawer, NotificationsBell } from './designs/index.js';
51
+ export { LoginSample, RegisterSample, ForgotPasswordSample, ResetPasswordSample, ActivateSample, NotActivatedSample, ChangePasswordSample, ProfileSample, UserRolesMatrixSample, AccessMatrixSample, MasterSettingsSample, NavTopSample, AccountMenu, NavDrawer, NavFloatingSettings, NotificationsBell } from './designs/index.js';
47
52
 
48
53
  // Ready-made pages (controller + sample design wired together; each takes an optional `design` prop)
49
54
  export { LoginPage, RegisterPage, ForgotPasswordPage, ResetPasswordPage, ActivatePage, NotActivatedPage, ChangePasswordPage, ProfilePage, UserRolesPage, AccessMatrixPage, MasterSettingsPage, NavPage } from './pages.jsx';
package/src/pages.jsx CHANGED
@@ -10,7 +10,7 @@ import { useUserRolesController } from './useUserRolesController.js';
10
10
  import { useAccessMatrixController } from './useAccessMatrixController.js';
11
11
  import { useMasterSettingsController } from './useMasterSettingsController.js';
12
12
  import { useNavController } from './useNavController.js';
13
- import { LoginSample, RegisterSample, ForgotPasswordSample, ResetPasswordSample, ActivateSample, NotActivatedSample, ChangePasswordSample, ProfileSample, UserRolesMatrixSample, AccessMatrixSample, MasterSettingsSample, NavTopSample, NavDrawer } from './designs/index.js';
13
+ import { LoginSample, RegisterSample, ForgotPasswordSample, ResetPasswordSample, ActivateSample, NotActivatedSample, ChangePasswordSample, ProfileSample, UserRolesMatrixSample, AccessMatrixSample, MasterSettingsSample, NavTopSample, NavDrawer, NavFloatingSettings } from './designs/index.js';
14
14
  import { useDesignValidator, LOGIN_RULES, REGISTER_RULES, FORGOT_PASSWORD_RULES, RESET_PASSWORD_RULES, CHANGE_PASSWORD_RULES, PROFILE_RULES, USER_ROLES_MATRIX_RULES, ACCESS_MATRIX_RULES, MASTER_SETTINGS_RULES, NAV_RULES } from './validateDesign.js';
15
15
 
16
16
  /**
@@ -111,11 +111,19 @@ export function MasterSettingsPage({ design, ...props }) {
111
111
  // vertical rail) — it's simply unused whenever a drawer is present.
112
112
  // See useNavController.js for the drawerItems/settingsOverrides/notifications
113
113
  // props this accepts.
114
- function NavPageImpl({ design, logo, expandedLogo, navMiddle, drawerPromo, ...props }) {
114
+ //
115
+ // `floatingSettings` (optional, default false): when true AND a drawer is in
116
+ // use, the drawer's own bottom-pinned bell/settings are suppressed and
117
+ // replaced by NavFloatingSettings — the same two controls floating in the
118
+ // page's top-right corner instead. A no-op without a drawer: the plain
119
+ // top-bar path already renders them inline at top-right (NavTopSample's
120
+ // `.xeplr-nav-right`), so there's nothing to move there.
121
+ function NavPageImpl({ design, logo, expandedLogo, navMiddle, drawerPromo, floatingSettings, ...props }) {
115
122
  var controller = useNavController(props);
116
123
  var ref = useDesignValidator('NavPage', NAV_RULES);
117
124
  var TopBar = design || NavTopSample;
118
125
  var hasDrawer = controller.drawerItems.length > 0;
126
+ var floating = floatingSettings && hasDrawer;
119
127
  return (
120
128
  <div ref={ref}>
121
129
  {hasDrawer ? (
@@ -124,6 +132,7 @@ function NavPageImpl({ design, logo, expandedLogo, navMiddle, drawerPromo, ...pr
124
132
  logo={logo}
125
133
  expandedLogo={expandedLogo}
126
134
  drawerPromo={drawerPromo}
135
+ hideFooterIcons={floating}
127
136
  />
128
137
  ) : (
129
138
  <TopBar
@@ -132,6 +141,7 @@ function NavPageImpl({ design, logo, expandedLogo, navMiddle, drawerPromo, ...pr
132
141
  navMiddle={navMiddle}
133
142
  />
134
143
  )}
144
+ {floating && <NavFloatingSettings {...controller} />}
135
145
  </div>
136
146
  );
137
147
  }
@@ -0,0 +1,24 @@
1
+ const KEY = 'xeplr:returnTo';
2
+
3
+ /**
4
+ * Where a redirect-driven gate (auth, or an app's own e.g. company gate)
5
+ * sends the user next, once whatever it was waiting for resolves. Tab-scoped
6
+ * (sessionStorage) — this is for "the token expired mid-session, send me
7
+ * back to what I was doing", not a link resurrected days later.
8
+ */
9
+ export function saveReturnTo(location) {
10
+ try {
11
+ sessionStorage.setItem(KEY, location.pathname + location.search);
12
+ } catch (e) {}
13
+ }
14
+
15
+ /** Reads and clears in one step — call only at the point the user actually lands. */
16
+ export function consumeReturnTo() {
17
+ try {
18
+ const value = sessionStorage.getItem(KEY);
19
+ sessionStorage.removeItem(KEY);
20
+ return value;
21
+ } catch (e) {
22
+ return null;
23
+ }
24
+ }
@@ -6,6 +6,10 @@ import { activateAccount } from './api.js';
6
6
  export function useActivateController() {
7
7
  var [searchParams] = useSearchParams();
8
8
  var token = searchParams.get('token');
9
+ // Put there by @xeplr/auth's register() when the registration was one step
10
+ // of a workflow. Handed back untouched so the server can release the step
11
+ // that was waiting — this page never interprets it.
12
+ var workflowKey = searchParams.get('workflowKey');
9
13
  var [error, setError] = useState('');
10
14
  var [success, setSuccess] = useState('');
11
15
  var [loading, setLoading] = useState(false);
@@ -24,7 +28,7 @@ export function useActivateController() {
24
28
  setLoading(true);
25
29
  setError('');
26
30
  setSuccess('');
27
- activateAccount(token)
31
+ activateAccount(token, workflowKey)
28
32
  .then(function(result) {
29
33
  var message = result.message || 'Account activated successfully';
30
34
  setSuccess(message);
@@ -37,7 +41,7 @@ export function useActivateController() {
37
41
  .finally(function() {
38
42
  setLoading(false);
39
43
  });
40
- }, [token]);
44
+ }, [token, workflowKey]);
41
45
 
42
46
  return {
43
47
  token,
@@ -4,6 +4,7 @@ import { raiseSnackbar } from '@xeplr/ui-utils';
4
4
  import { loginUser } from './api.js';
5
5
  import { setToken, setRefreshToken } from './token.js';
6
6
  import { useAccess } from './AccessContext.jsx';
7
+ import { consumeReturnTo } from './returnTo.js';
7
8
 
8
9
  export function useLoginController(options = {}) {
9
10
  const [email, setEmail] = useState('');
@@ -15,7 +16,11 @@ export function useLoginController(options = {}) {
15
16
  // Safe — returns null if no AccessProvider wraps the app
16
17
  const accessCtx = useAccess();
17
18
 
18
- const onSuccess = options.onSuccess || (() => navigate('/'));
19
+ // Default: back to wherever a gate (ProtectedRoute, or an app's own gate)
20
+ // sent the user here from, not always home — see returnTo.js. A caller
21
+ // that supplies its own onSuccess owns navigation entirely; this default
22
+ // only applies when they don't.
23
+ const onSuccess = options.onSuccess || (() => navigate(consumeReturnTo() || '/'));
19
24
  const notActivatedPath = options.notActivatedPath || '/auth/not-activated';
20
25
 
21
26
  async function handleSubmit(e) {
@@ -14,6 +14,56 @@ var BUILTIN_SETTINGS_ITEMS = [
14
14
  ];
15
15
  var NOTIFICATIONS_MENU_NAME = 'Notifications';
16
16
 
17
+ // ── drawer width ────────────────────────────────────────────────────────
18
+ // Only the EXPANDED drawer is resizable. Collapsed is a 60px icon rail whose
19
+ // width is the icon's, not a preference — there is nothing in it to give room
20
+ // to, so nav.css keeps owning that number.
21
+ //
22
+ // The width is remembered because re-dragging it on every page load is the
23
+ // whole reason a fixed width was annoying enough to change. Kept per browser
24
+ // (localStorage) rather than on the user record: it is a property of the
25
+ // screen you are sitting at, and the same account on a laptop and a wide
26
+ // monitor wants two different answers.
27
+ var DRAWER_WIDTH_KEY = 'xeplr-nav-drawer-width';
28
+ var DRAWER_WIDTH_DEFAULT = 240;
29
+ // Floors and ceilings, not taste. Below MIN the labels this width exists to
30
+ // show start truncating, so the expanded state stops being different from the
31
+ // collapsed one; past MAX the nav is competing with the page for the screen.
32
+ var DRAWER_WIDTH_MIN = 180;
33
+ var DRAWER_WIDTH_MAX = 480;
34
+ // What an arrow key moves. Big enough to get somewhere, small enough to land.
35
+ var DRAWER_WIDTH_STEP = 16;
36
+
37
+ // Frozen module constant, so the identity is stable across renders and a
38
+ // memoized View is not re-rendered by a fresh object every time.
39
+ var DRAWER_WIDTH_BOUNDS = {
40
+ min: DRAWER_WIDTH_MIN,
41
+ max: DRAWER_WIDTH_MAX,
42
+ step: DRAWER_WIDTH_STEP,
43
+ default: DRAWER_WIDTH_DEFAULT
44
+ };
45
+
46
+ // Only NaN falls back to the default — ±Infinity is deliberately allowed
47
+ // through, because Math.min/max turn it into exactly the near/far stop, which
48
+ // is what the Home/End keys pass in.
49
+ function clampDrawerWidth(px) {
50
+ if (typeof px !== 'number' || isNaN(px)) return DRAWER_WIDTH_DEFAULT;
51
+ return Math.min(DRAWER_WIDTH_MAX, Math.max(DRAWER_WIDTH_MIN, Math.round(px)));
52
+ }
53
+
54
+ // Wrapped because localStorage THROWS rather than returning null in Safari
55
+ // private mode and under a blocked-cookies policy — an unguarded read here
56
+ // would take the whole nav down at first render.
57
+ function readStoredDrawerWidth() {
58
+ try {
59
+ var raw = window.localStorage.getItem(DRAWER_WIDTH_KEY);
60
+ if (!raw) return DRAWER_WIDTH_DEFAULT;
61
+ return clampDrawerWidth(parseInt(raw, 10));
62
+ } catch (err) {
63
+ return DRAWER_WIDTH_DEFAULT;
64
+ }
65
+ }
66
+
17
67
  /**
18
68
  * Nav is self-contained by design: it reads access/user from context (which only
19
69
  * changes on login/logout/access updates) and owns its OWN open/closed UI state.
@@ -47,6 +97,11 @@ export function useNavController(props) {
47
97
  var [accountOpen, setAccountOpen] = useState(false);
48
98
  var [drawerOpen, setDrawerOpen] = useState(false);
49
99
 
100
+ // Lazy initialiser (function, not value) so localStorage is read ONCE on
101
+ // mount instead of on every render.
102
+ var [drawerWidth, setDrawerWidth] = useState(readStoredDrawerWidth);
103
+ var [drawerResizing, setDrawerResizing] = useState(false);
104
+
50
105
  var accountRef = useRef(null);
51
106
 
52
107
  var toggleAccount = useCallback(function() { setAccountOpen(function(v) { return !v; }); }, []);
@@ -54,6 +109,64 @@ export function useNavController(props) {
54
109
  var toggleDrawer = useCallback(function() { setDrawerOpen(function(v) { return !v; }); }, []);
55
110
  var closeDrawer = useCallback(function() { setDrawerOpen(false); }, []);
56
111
 
112
+ // ── resizing ──────────────────────────────────────────────────────────
113
+ // The handle only starts the gesture; the move/end listeners go on the
114
+ // DOCUMENT (below), not the handle, because a drag routinely outruns a 6px
115
+ // strip — listening on the handle alone would drop the gesture the moment
116
+ // the pointer got ahead of the edge, which is most of the time.
117
+ var startDrawerResize = useCallback(function(e) {
118
+ // Stops the browser starting a text/image selection drag instead, which
119
+ // would leave the page blue-highlighted for the whole gesture.
120
+ if (e && e.preventDefault) e.preventDefault();
121
+ setDrawerResizing(true);
122
+ }, []);
123
+
124
+ var resetDrawerWidth = useCallback(function() {
125
+ setDrawerWidth(DRAWER_WIDTH_DEFAULT);
126
+ }, []);
127
+
128
+ // Keyboard equivalent of the drag — the handle is focusable, so a resize
129
+ // must be reachable without a pointer at all.
130
+ var nudgeDrawerWidth = useCallback(function(delta) {
131
+ setDrawerWidth(function(w) { return clampDrawerWidth(w + delta); });
132
+ }, []);
133
+
134
+ useEffect(function() {
135
+ if (!drawerResizing) return;
136
+ // The drawer is `position: fixed; left: 0`, so the pointer's viewport x IS
137
+ // the width being dragged to — no offset bookkeeping needed.
138
+ function onMove(e) { setDrawerWidth(clampDrawerWidth(e.clientX)); }
139
+ function onUp() { setDrawerResizing(false); }
140
+ document.addEventListener('pointermove', onMove);
141
+ document.addEventListener('pointerup', onUp);
142
+ // pointercancel fires when the browser takes the gesture away (touch
143
+ // scroll taking over, window losing focus). Without it the drag would
144
+ // never end and every later mouse move would keep resizing.
145
+ document.addEventListener('pointercancel', onUp);
146
+ // Suppresses selection + forces the col-resize cursor everywhere for the
147
+ // duration, so the cursor doesn't flicker back to a caret whenever the
148
+ // pointer outruns the handle.
149
+ document.body.classList.add('xeplr-nav-resizing');
150
+ return function() {
151
+ document.removeEventListener('pointermove', onMove);
152
+ document.removeEventListener('pointerup', onUp);
153
+ document.removeEventListener('pointercancel', onUp);
154
+ document.body.classList.remove('xeplr-nav-resizing');
155
+ };
156
+ }, [drawerResizing]);
157
+
158
+ // Written once the gesture SETTLES, not on every pointermove — a drag fires
159
+ // these by the hundred, and localStorage is synchronous.
160
+ useEffect(function() {
161
+ if (drawerResizing) return;
162
+ try {
163
+ window.localStorage.setItem(DRAWER_WIDTH_KEY, String(drawerWidth));
164
+ } catch (err) {
165
+ // Storage unavailable (see readStoredDrawerWidth) — the width still
166
+ // works for this session, it just won't be remembered.
167
+ }
168
+ }, [drawerWidth, drawerResizing]);
169
+
57
170
  // Click-outside for the settings menu only — the drawer closes via its own overlay.
58
171
  useEffect(function() {
59
172
  if (!accountOpen) return;
@@ -97,6 +210,15 @@ export function useNavController(props) {
97
210
  drawerOpen: drawerOpen,
98
211
  toggleDrawer: toggleDrawer,
99
212
  closeDrawer: closeDrawer,
100
- drawerItems: drawerItems
213
+ drawerItems: drawerItems,
214
+
215
+ drawerWidth: drawerWidth,
216
+ drawerResizing: drawerResizing,
217
+ startDrawerResize: startDrawerResize,
218
+ resetDrawerWidth: resetDrawerWidth,
219
+ nudgeDrawerWidth: nudgeDrawerWidth,
220
+ // Handed out so the View can fill in aria-valuemin/max and its own key
221
+ // handling without re-declaring the numbers and drifting from them.
222
+ drawerWidthBounds: DRAWER_WIDTH_BOUNDS
101
223
  };
102
224
  }
@@ -4,8 +4,13 @@ import { useEffect, useRef } from 'react';
4
4
  * Validates that required elements exist in the rendered design.
5
5
  * Throws a visible error if any required element is missing.
6
6
  *
7
+ * A rule matches by id, role or selector. `anyOf` takes a list of selectors
8
+ * and passes when ANY one is present — which is what a design with tabs or
9
+ * steps needs, since only the current one is mounted and demanding all of
10
+ * them at once can never pass.
11
+ *
7
12
  * @param {string} componentName - Name of the page (for error messages)
8
- * @param {Array<{id?: string, role?: string, selector?: string, label: string}>} requiredElements
13
+ * @param {Array<{id?: string, role?: string, selector?: string, anyOf?: string[], label: string}>} requiredElements
9
14
  */
10
15
  export function useDesignValidator(componentName, requiredElements) {
11
16
  var containerRef = useRef(null);
@@ -26,12 +31,19 @@ export function useDesignValidator(componentName, requiredElements) {
26
31
  found = !!containerRef.current.querySelector('#' + rule.id);
27
32
  } else if (rule.role) {
28
33
  found = !!containerRef.current.querySelector('[role="' + rule.role + '"]');
34
+ } else if (rule.anyOf) {
35
+ for (var j = 0; j < rule.anyOf.length && !found; j++) {
36
+ found = !!containerRef.current.querySelector(rule.anyOf[j]);
37
+ }
29
38
  } else if (rule.selector) {
30
39
  found = !!containerRef.current.querySelector(rule.selector);
31
40
  }
32
41
 
33
42
  if (!found) {
34
- missing.push(rule.label + (rule.id ? ' (id="' + rule.id + '")' : '') + (rule.selector ? ' (' + rule.selector + ')' : ''));
43
+ var where = rule.id ? ' (id="' + rule.id + '")'
44
+ : rule.anyOf ? ' (one of: ' + rule.anyOf.join(', ') + ')'
45
+ : rule.selector ? ' (' + rule.selector + ')' : '';
46
+ missing.push(rule.label + where);
35
47
  }
36
48
  }
37
49