@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 +1 -1
- package/src/AccessContext.jsx +86 -4
- package/src/ProtectedRoute.jsx +4 -1
- package/src/activeScope.js +25 -0
- package/src/api.js +266 -10
- package/src/designs/NavDrawer.jsx +91 -18
- package/src/designs/NavFloatingSettings.jsx +25 -0
- package/src/designs/index.js +1 -0
- package/src/designs/nav.css +129 -1
- package/src/index.js +8 -3
- package/src/pages.jsx +12 -2
- package/src/returnTo.js +24 -0
- package/src/useActivateController.js +6 -2
- package/src/useLoginController.js +6 -1
- package/src/useNavController.js +123 -1
- package/src/validateDesign.js +14 -2
package/package.json
CHANGED
package/src/AccessContext.jsx
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
* Provides helpers: hasPage, hasApi, hasMenu, hasElement,
|
|
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,
|
package/src/ProtectedRoute.jsx
CHANGED
|
@@ -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
|
|
package/src/activeScope.js
CHANGED
|
@@ -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
|
|
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)
|
|
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.
|
|
356
|
+
const data = await parseJsonResponse(res, { method: options.method || 'GET', url: url });
|
|
125
357
|
if (!res.ok) {
|
|
126
|
-
throw
|
|
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
|
|
371
|
+
const requested = { method: options.method || 'GET', url: `${getBaseUrl()}${endpoint}` };
|
|
372
|
+
const data = await parseJsonResponse(res, requested);
|
|
140
373
|
if (!res.ok) {
|
|
141
|
-
throw
|
|
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
|
-
|
|
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
|
|
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
|
|
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=
|
|
79
|
+
className={'xeplr-nav-drawer-link' + (badge ? ' xeplr-nav-drawer-link-badged' : '')}
|
|
55
80
|
onClick={item.clickHandler}
|
|
56
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
{
|
|
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 —
|
|
76
|
-
|
|
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
|
-
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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);
|
package/src/designs/index.js
CHANGED
|
@@ -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';
|
package/src/designs/nav.css
CHANGED
|
@@ -197,7 +197,18 @@
|
|
|
197
197
|
position: fixed;
|
|
198
198
|
top: 0;
|
|
199
199
|
left: 0;
|
|
200
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/returnTo.js
ADDED
|
@@ -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
|
-
|
|
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) {
|
package/src/useNavController.js
CHANGED
|
@@ -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
|
}
|
package/src/validateDesign.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|