@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/package.json
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-account",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Account UI: auth, profile, RBAC admin, tenant management — React controller hooks and designs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
|
-
"files": [
|
|
8
|
-
|
|
7
|
+
"files": [
|
|
8
|
+
"src/"
|
|
9
|
+
],
|
|
10
|
+
"keywords": [
|
|
11
|
+
"auth",
|
|
12
|
+
"account",
|
|
13
|
+
"profile",
|
|
14
|
+
"react",
|
|
15
|
+
"hooks",
|
|
16
|
+
"login",
|
|
17
|
+
"register",
|
|
18
|
+
"rbac",
|
|
19
|
+
"multi-tenancy"
|
|
20
|
+
],
|
|
9
21
|
"author": "xeplr",
|
|
10
22
|
"license": "MIT",
|
|
11
|
-
"repository": {
|
|
12
|
-
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/Xeplr/xeplr-ui-account"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@xeplr/ui-utils": "^1.0.0"
|
|
32
|
+
},
|
|
13
33
|
"peerDependencies": {
|
|
14
34
|
"react": "^18.0.0 || ^19.0.0",
|
|
15
35
|
"react-router-dom": "^6.0.0 || ^7.0.0"
|
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
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const STORAGE_PREFIX = 'xeplr:activeScope:';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The app's active value at a given MT level (l1, l2, ...) — e.g. the current
|
|
5
|
+
* company at l1, workspace at l2. Auth doesn't know or care what it means:
|
|
6
|
+
* it's a bare value per level, mirroring how userTenantsMapping stores a bare
|
|
7
|
+
* {level, value} row server-side. api.js's authFetch reads this (per the
|
|
8
|
+
* level's header, from registerMTs() — see mt.js) and attaches it to every
|
|
9
|
+
* request.
|
|
10
|
+
*
|
|
11
|
+
* setActiveScope('l1', { id: 'acme-co', name: 'Acme Co' });
|
|
12
|
+
* getActiveScope('l1'); // => { id: 'acme-co', name: 'Acme Co' }
|
|
13
|
+
*/
|
|
14
|
+
export function getActiveScope(level) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = localStorage.getItem(STORAGE_PREFIX + level);
|
|
17
|
+
return raw ? JSON.parse(raw) : null;
|
|
18
|
+
} catch (e) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function setActiveScope(level, scope) {
|
|
24
|
+
try {
|
|
25
|
+
if (scope) localStorage.setItem(STORAGE_PREFIX + level, JSON.stringify(scope));
|
|
26
|
+
else localStorage.removeItem(STORAGE_PREFIX + level);
|
|
27
|
+
} catch (e) {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
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
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Clear one level's scope, or every level if omitted (e.g. on logout).
|
|
57
|
+
*/
|
|
58
|
+
export function clearActiveScope(level) {
|
|
59
|
+
if (level) {
|
|
60
|
+
setActiveScope(level, null);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
var keys = [];
|
|
65
|
+
for (var i = 0; i < localStorage.length; i++) {
|
|
66
|
+
var key = localStorage.key(i);
|
|
67
|
+
if (key && key.indexOf(STORAGE_PREFIX) === 0) keys.push(key);
|
|
68
|
+
}
|
|
69
|
+
keys.forEach(function(key) { localStorage.removeItem(key); });
|
|
70
|
+
} catch (e) {}
|
|
71
|
+
}
|
package/src/adminApi.js
CHANGED
|
@@ -37,32 +37,3 @@ export function toggleModuleRole({ module, action, roleId, assign }) {
|
|
|
37
37
|
body: JSON.stringify({ module, action, roleId, assign }),
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
|
-
|
|
41
|
-
// ─── Tenant management (Super Admin) ───
|
|
42
|
-
|
|
43
|
-
export function getTenants(level) {
|
|
44
|
-
var url = '/auth/api/admin/tenants';
|
|
45
|
-
if (level) url += '?level=' + level;
|
|
46
|
-
return authFetch(url);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function saveTenant(data) {
|
|
50
|
-
return authFetch('/auth/api/admin/tenants', {
|
|
51
|
-
method: 'POST',
|
|
52
|
-
body: JSON.stringify(data),
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function deleteTenant(id) {
|
|
57
|
-
return authFetch('/auth/api/admin/tenants/delete', {
|
|
58
|
-
method: 'POST',
|
|
59
|
-
body: JSON.stringify({ id }),
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function assignUserTenant({ userId, tenantId, level }) {
|
|
64
|
-
return authFetch('/auth/api/admin/tenants/assign-user', {
|
|
65
|
-
method: 'POST',
|
|
66
|
-
body: JSON.stringify({ userId, tenantId, level }),
|
|
67
|
-
});
|
|
68
|
-
}
|