@xeplr/ui-account 1.0.0 → 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.
Files changed (40) hide show
  1. package/package.json +25 -5
  2. package/src/AccessContext.jsx +86 -4
  3. package/src/ProtectedRoute.jsx +4 -1
  4. package/src/activeScope.js +71 -0
  5. package/src/adminApi.js +0 -29
  6. package/src/api.js +321 -22
  7. package/src/authRoutes.jsx +125 -0
  8. package/src/designs/AccountMenu.jsx +71 -0
  9. package/src/designs/ActivateSample.jsx +8 -16
  10. package/src/designs/ChangePasswordSample.jsx +3 -3
  11. package/src/designs/ForgotPasswordSample.jsx +3 -3
  12. package/src/designs/LoginSample.jsx +3 -2
  13. package/src/designs/NavDrawer.jsx +194 -0
  14. package/src/designs/NavFloatingSettings.jsx +25 -0
  15. package/src/designs/NavTopSample.jsx +34 -0
  16. package/src/designs/NotificationsBell.jsx +31 -0
  17. package/src/designs/ProfileSample.jsx +3 -3
  18. package/src/designs/RegisterSample.jsx +3 -3
  19. package/src/designs/ResetPasswordSample.jsx +7 -9
  20. package/src/designs/admin.css +13 -3
  21. package/src/designs/auth.css +6 -60
  22. package/src/designs/index.js +5 -2
  23. package/src/designs/nav.css +439 -0
  24. package/src/index.js +24 -8
  25. package/src/mt.js +31 -0
  26. package/src/pages.jsx +95 -38
  27. package/src/returnTo.js +24 -0
  28. package/src/useActivateController.js +19 -4
  29. package/src/useChangePasswordController.js +8 -2
  30. package/src/useForgotPasswordController.js +3 -0
  31. package/src/useLoginController.js +8 -1
  32. package/src/useNavController.js +224 -0
  33. package/src/useProfileController.js +6 -1
  34. package/src/useRegisterController.js +5 -1
  35. package/src/useResetPasswordController.js +12 -1
  36. package/src/validateDesign.js +20 -2
  37. package/src/designs/TenantPickerSample.jsx +0 -39
  38. package/src/designs/TenantSample.jsx +0 -182
  39. package/src/useTenantController.js +0 -146
  40. package/src/useTenantPickerController.js +0 -97
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;
@@ -12,13 +14,238 @@ let _refreshPromise = null;
12
14
  */
13
15
  export function configure(baseUrl, options = {}) {
14
16
  _baseUrl = baseUrl;
15
- _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;
16
32
  }
17
33
 
18
34
  function getBaseUrl() {
19
35
  return _baseUrl || '';
20
36
  }
21
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
+
22
249
  /**
23
250
  * Refresh the access token using the stored refresh token.
24
251
  * Returns true if refresh succeeded, false if session is dead.
@@ -30,7 +257,14 @@ async function refreshAccessToken() {
30
257
 
31
258
  _refreshPromise = (async () => {
32
259
  const refreshToken = getRefreshToken();
33
- 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
+ }
34
268
 
35
269
  try {
36
270
  const res = await fetch(`${getBaseUrl()}/auth/api/refresh`, {
@@ -64,8 +298,23 @@ async function refreshAccessToken() {
64
298
  }
65
299
 
66
300
  /**
67
- * Authenticated fetch with auto-refresh.
68
- * Attaches Bearer token, retries once on 401 after refreshing.
301
+ * Absorb a server-issued sliding-refresh token. When the access token was
302
+ * expired-but-within-tolerance, the API serves the request normally and hands
303
+ * back a fresh token via the `X-New-Token` header (see @xeplr/auth
304
+ * authMiddleware). We just swap it into storage — no gating, no retry. This is
305
+ * the happy path; it means most expiries never produce a 401 at all.
306
+ */
307
+ function absorbNewToken(res) {
308
+ try {
309
+ const fresh = res.headers.get('X-New-Token');
310
+ if (fresh) setToken(fresh);
311
+ } catch (e) {}
312
+ }
313
+
314
+ /**
315
+ * Authenticated fetch with sliding refresh.
316
+ * Attaches Bearer token; absorbs X-New-Token off every response. The 401→refresh
317
+ * path below is now only a fallback for a token past the whole tolerance window.
69
318
  */
70
319
  export async function authFetch(endpoint, options = {}) {
71
320
  const url = endpoint.startsWith('http') ? endpoint : `${getBaseUrl()}${endpoint}`;
@@ -80,29 +329,33 @@ export async function authFetch(endpoint, options = {}) {
80
329
  headers['Authorization'] = `Bearer ${token}`;
81
330
  }
82
331
 
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) {}
332
+ // Attach the app's active scope (e.g. company/workspace) per configured MT
333
+ // level — see mt.js's registerMTs() and activeScope.js.
334
+ const mtConfig = getMtConfig();
335
+ Object.keys(mtConfig.slots).forEach((level) => {
336
+ const slot = mtConfig.slots[level];
337
+ const scope = getActiveScope(level);
338
+ if (scope && scope.id) headers[slot.header] = scope.id;
339
+ });
91
340
 
92
341
  let res = await fetch(url, { ...options, headers });
342
+ absorbNewToken(res);
93
343
 
94
- // If 401, try refreshing the token and retry once
344
+ // Fallback: token is past the tolerance window (truly dead) → rotate via the
345
+ // refresh token and retry once. With server-side sliding refresh this rarely
346
+ // fires; the tolerance window absorbs ordinary expiries above.
95
347
  if (res.status === 401) {
96
348
  const refreshed = await refreshAccessToken();
97
349
  if (refreshed) {
98
350
  headers['Authorization'] = `Bearer ${getToken()}`;
99
351
  res = await fetch(url, { ...options, headers });
352
+ absorbNewToken(res);
100
353
  }
101
354
  }
102
355
 
103
- const data = await res.json();
356
+ const data = await parseJsonResponse(res, { method: options.method || 'GET', url: url });
104
357
  if (!res.ok) {
105
- throw new Error(data.error || 'Something went wrong');
358
+ throw httpError(data, res, { method: options.method || 'GET', url: url });
106
359
  }
107
360
  return data;
108
361
  }
@@ -115,9 +368,10 @@ async function request(endpoint, options = {}) {
115
368
  headers: { 'Content-Type': 'application/json' },
116
369
  ...options,
117
370
  });
118
- const data = await res.json();
371
+ const requested = { method: options.method || 'GET', url: `${getBaseUrl()}${endpoint}` };
372
+ const data = await parseJsonResponse(res, requested);
119
373
  if (!res.ok) {
120
- throw new Error(data.error || 'Something went wrong');
374
+ throw httpError(data, res, requested);
121
375
  }
122
376
  return data;
123
377
  }
@@ -129,8 +383,14 @@ export function registerUser({ email, password, name, phoneNumber }) {
129
383
  });
130
384
  }
131
385
 
132
- export function activateAccount(token) {
133
- 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);
134
394
  }
135
395
 
136
396
  export function loginUser({ email, password }) {
@@ -157,10 +417,26 @@ export function resetPassword({ token, newPassword }) {
157
417
  export function changePassword({ currentPassword, newPassword }) {
158
418
  return authFetch('/auth/api/change-password', {
159
419
  method: 'POST',
160
- body: JSON.stringify({ currentPassword, newPassword }),
420
+ body: JSON.stringify({ oldPassword: currentPassword, newPassword }),
161
421
  });
162
422
  }
163
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
+
164
440
  export function getProfile() {
165
441
  return authFetch('/auth/api/profile');
166
442
  }
@@ -172,8 +448,30 @@ export function updateProfile(fields) {
172
448
  });
173
449
  }
174
450
 
175
- export function getMyTenants() {
176
- return authFetch('/auth/api/my-tenants');
451
+ /**
452
+ * Upload a new profile picture. Bypasses authFetch's JSON Content-Type (the
453
+ * browser sets multipart/form-data with the right boundary itself when the
454
+ * body is a FormData — setting it manually breaks the boundary).
455
+ */
456
+ export async function uploadAvatar(file) {
457
+ const formData = new FormData();
458
+ formData.append('avatar', file);
459
+
460
+ const headers = {};
461
+ const token = getToken();
462
+ if (token) headers['Authorization'] = `Bearer ${token}`;
463
+
464
+ const res = await fetch(`${getBaseUrl()}/auth/api/profile/avatar`, {
465
+ method: 'POST',
466
+ headers,
467
+ body: formData,
468
+ });
469
+ const avatarRequest = { method: 'POST', url: `${getBaseUrl()}/auth/api/profile/avatar` };
470
+ const data = await parseJsonResponse(res, avatarRequest);
471
+ if (!res.ok) {
472
+ throw httpError(data, res, avatarRequest);
473
+ }
474
+ return data;
177
475
  }
178
476
 
179
477
  export function logoutUser() {
@@ -181,6 +479,7 @@ export function logoutUser() {
181
479
  const accessToken = getToken();
182
480
 
183
481
  clearAuth();
482
+ clearActiveScope();
184
483
 
185
484
  // Best-effort server-side cleanup
186
485
  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
  );