@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.
- package/package.json +25 -5
- package/src/AccessContext.jsx +86 -4
- package/src/ProtectedRoute.jsx +4 -1
- package/src/activeScope.js +71 -0
- package/src/adminApi.js +0 -29
- package/src/api.js +321 -22
- package/src/authRoutes.jsx +125 -0
- package/src/designs/AccountMenu.jsx +71 -0
- package/src/designs/ActivateSample.jsx +8 -16
- package/src/designs/ChangePasswordSample.jsx +3 -3
- package/src/designs/ForgotPasswordSample.jsx +3 -3
- package/src/designs/LoginSample.jsx +3 -2
- package/src/designs/NavDrawer.jsx +194 -0
- package/src/designs/NavFloatingSettings.jsx +25 -0
- package/src/designs/NavTopSample.jsx +34 -0
- package/src/designs/NotificationsBell.jsx +31 -0
- package/src/designs/ProfileSample.jsx +3 -3
- package/src/designs/RegisterSample.jsx +3 -3
- package/src/designs/ResetPasswordSample.jsx +7 -9
- package/src/designs/admin.css +13 -3
- package/src/designs/auth.css +6 -60
- package/src/designs/index.js +5 -2
- package/src/designs/nav.css +439 -0
- package/src/index.js +24 -8
- package/src/mt.js +31 -0
- package/src/pages.jsx +95 -38
- package/src/returnTo.js +24 -0
- package/src/useActivateController.js +19 -4
- package/src/useChangePasswordController.js +8 -2
- package/src/useForgotPasswordController.js +3 -0
- package/src/useLoginController.js +8 -1
- package/src/useNavController.js +224 -0
- package/src/useProfileController.js +6 -1
- package/src/useRegisterController.js +5 -1
- package/src/useResetPasswordController.js +12 -1
- package/src/validateDesign.js +20 -2
- package/src/designs/TenantPickerSample.jsx +0 -39
- package/src/designs/TenantSample.jsx +0 -182
- package/src/useTenantController.js +0 -146
- 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
|
|
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)
|
|
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
|
-
*
|
|
68
|
-
*
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
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
|
-
//
|
|
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.
|
|
356
|
+
const data = await parseJsonResponse(res, { method: options.method || 'GET', url: url });
|
|
104
357
|
if (!res.ok) {
|
|
105
|
-
throw
|
|
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
|
|
371
|
+
const requested = { method: options.method || 'GET', url: `${getBaseUrl()}${endpoint}` };
|
|
372
|
+
const data = await parseJsonResponse(res, requested);
|
|
119
373
|
if (!res.ok) {
|
|
120
|
-
throw
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
<
|
|
23
|
-
|
|
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
|
);
|