@xeplr/ui-account 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +25 -5
  2. package/src/AccessContext.jsx +86 -4
  3. package/src/ProtectedRoute.jsx +4 -1
  4. package/src/activeScope.js +71 -0
  5. package/src/adminApi.js +0 -29
  6. package/src/api.js +321 -22
  7. package/src/authRoutes.jsx +125 -0
  8. package/src/designs/AccountMenu.jsx +71 -0
  9. package/src/designs/ActivateSample.jsx +8 -16
  10. package/src/designs/ChangePasswordSample.jsx +3 -3
  11. package/src/designs/ForgotPasswordSample.jsx +3 -3
  12. package/src/designs/LoginSample.jsx +3 -2
  13. package/src/designs/NavDrawer.jsx +194 -0
  14. package/src/designs/NavFloatingSettings.jsx +25 -0
  15. package/src/designs/NavTopSample.jsx +34 -0
  16. package/src/designs/NotificationsBell.jsx +31 -0
  17. package/src/designs/ProfileSample.jsx +3 -3
  18. package/src/designs/RegisterSample.jsx +3 -3
  19. package/src/designs/ResetPasswordSample.jsx +7 -9
  20. package/src/designs/admin.css +13 -3
  21. package/src/designs/auth.css +6 -60
  22. package/src/designs/index.js +5 -2
  23. package/src/designs/nav.css +439 -0
  24. package/src/index.js +24 -8
  25. package/src/mt.js +31 -0
  26. package/src/pages.jsx +95 -38
  27. package/src/returnTo.js +24 -0
  28. package/src/useActivateController.js +19 -4
  29. package/src/useChangePasswordController.js +8 -2
  30. package/src/useForgotPasswordController.js +3 -0
  31. package/src/useLoginController.js +8 -1
  32. package/src/useNavController.js +224 -0
  33. package/src/useProfileController.js +6 -1
  34. package/src/useRegisterController.js +5 -1
  35. package/src/useResetPasswordController.js +12 -1
  36. package/src/validateDesign.js +20 -2
  37. package/src/designs/TenantPickerSample.jsx +0 -39
  38. package/src/designs/TenantSample.jsx +0 -182
  39. package/src/useTenantController.js +0 -146
  40. package/src/useTenantPickerController.js +0 -97
package/package.json CHANGED
@@ -1,15 +1,35 @@
1
1
  {
2
2
  "name": "@xeplr/ui-account",
3
- "version": "1.0.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": ["src/"],
8
- "keywords": ["auth", "account", "profile", "react", "hooks", "login", "register", "rbac", "multi-tenancy"],
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": { "type": "git", "url": "https://github.com/Xeplr/xeplr-ui-account" },
12
- "publishConfig": { "access": "public" },
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"
@@ -1,14 +1,16 @@
1
- import { createContext, useContext, useState, useEffect } from 'react';
1
+ import { createContext, useContext, useState, useEffect, useRef } from 'react';
2
+ import { raiseSnackbar } from '@xeplr/ui-utils';
2
3
  import { getToken, getUser } from './token.js';
3
- import { logoutUser } from './api.js';
4
+ import { logoutUser, getMe, setSessionExpiredHandler } from './api.js';
4
5
 
5
6
  const AccessContext = createContext(null);
6
7
 
7
8
  /**
8
9
  * AccessProvider — wraps your app to provide access state.
9
10
  *
10
- * On mount, loads access from localStorage (set during login).
11
- * Provides helpers: hasPage, hasApi, hasMenu, hasElement, hasRole.
11
+ * Seeds from localStorage (written at login) and then RE-READS IT FROM THE
12
+ * SERVER on mount. Provides helpers: hasPage, hasApi, hasMenu, hasElement,
13
+ * hasRole.
12
14
  *
13
15
  * Usage:
14
16
  * <AccessProvider>
@@ -57,6 +59,85 @@ export function AccessProvider({ children }) {
57
59
  setAccess(result.access);
58
60
  }
59
61
 
62
+ // ── re-read access from the server ──────────────────────────────────
63
+ //
64
+ // WHAT THIS FIXES. `access` was written to localStorage at login and read
65
+ // once, here, forever after. So it was a snapshot of what the user could see
66
+ // at the moment they signed in — and nothing on the server could change it.
67
+ // Seed a menu, grant a role, take a page away: none of it reached a
68
+ // signed-in browser until that person happened to log out, which could be
69
+ // weeks. Restarting the API did not help, because the API was never the
70
+ // thing holding the stale copy.
71
+ //
72
+ // It went unnoticed because the failure is silent in exactly the wrong
73
+ // direction: useNavController drops a drawer item whose name is not in
74
+ // `access.menus` without an error, so a new page simply is not in the rail
75
+ // and nothing anywhere says why.
76
+ //
77
+ // ON MOUNT, which on a SPA means once per full page load. That is the
78
+ // shortest honest promise: a change lands on next reload rather than next
79
+ // login.
80
+ /**
81
+ * Ask the server again. Also exposed on the context, so a screen that CHANGES
82
+ * access can show its own effect — the access matrix is the obvious one: an
83
+ * admin who grants a role and sees nothing happen has no way to tell a saved
84
+ * change from a broken one.
85
+ *
86
+ * Resolves either way. A caller awaiting it is waiting for "we tried", not
87
+ * for "it worked" — there is nothing useful for a nav bar to do about a
88
+ * failed refresh except carry on with what it had.
89
+ */
90
+ async function refreshAccess() {
91
+ try {
92
+ const result = await getMe();
93
+ if (!result) return null;
94
+ // REPLACED, not merged. Access is the whole answer to "what may this
95
+ // person see", and merging would keep a page that has just been revoked
96
+ // — the direction you least want to be wrong in.
97
+ if (result.access) setAccess(result.access);
98
+ if (result.user) setUser(result.user);
99
+ return result.access || null;
100
+ } catch (err) {
101
+ // KEEP WHAT WE HAVE. A network blip, a restarting API, a laptop that woke
102
+ // up on a train — none of those mean the user lost their permissions, and
103
+ // blanking `access` would empty the nav and bounce them out of the page
104
+ // they were reading. A genuinely dead session is handled separately, by
105
+ // the setSessionExpiredHandler(logout) effect below.
106
+ //
107
+ // But silently keeping stale state used to mean silently keeping the
108
+ // user in the dark too — this used to fail with zero indication
109
+ // anything was wrong. Still don't blank anything; just say so.
110
+ raiseSnackbar(err.message || 'Could not reach the server to refresh your access.', { design: 'error' });
111
+ return null;
112
+ }
113
+ }
114
+
115
+ // A dead session (refresh token missing, or rejected) is discovered inside
116
+ // authFetch — outside React entirely — which can wipe localStorage but has
117
+ // no way to flip THIS component's `authenticated` state on its own. Without
118
+ // this, clearAuth() runs and nothing downstream ever notices: ProtectedRoute
119
+ // keeps reading a stale `authenticated: true` and the app just sits there
120
+ // throwing errors on every call instead of bouncing to login.
121
+ useEffect(() => {
122
+ setSessionExpiredHandler(logout);
123
+ return () => setSessionExpiredHandler(null);
124
+ // eslint-disable-next-line react-hooks/exhaustive-deps
125
+ }, []);
126
+
127
+ const refreshedRef = useRef(false);
128
+ useEffect(() => {
129
+ // Nothing to refresh for a signed-out visitor, and asking would be a 401 on
130
+ // every login screen.
131
+ if (!authenticated) return;
132
+ // StrictMode double-invokes effects in development. One request, not two.
133
+ // Not reset on logout either: signing back in goes through onLogin, which
134
+ // already carries fresh access from the login response.
135
+ if (refreshedRef.current) return;
136
+ refreshedRef.current = true;
137
+ refreshAccess();
138
+ // eslint-disable-next-line react-hooks/exhaustive-deps
139
+ }, [authenticated]);
140
+
60
141
  // Access checkers
61
142
  function hasPage(pageName) {
62
143
  if (!access) return false;
@@ -90,6 +171,7 @@ export function AccessProvider({ children }) {
90
171
  onLogin,
91
172
  logout,
92
173
  setAccess,
174
+ refreshAccess,
93
175
  hasPage,
94
176
  hasApi,
95
177
  hasMenu,
@@ -1,5 +1,6 @@
1
- import { Navigate } from 'react-router-dom';
1
+ import { Navigate, useLocation } from 'react-router-dom';
2
2
  import { useAccessStrict } from './AccessContext.jsx';
3
+ import { saveReturnTo } from './returnTo.js';
3
4
 
4
5
  /**
5
6
  * ProtectedRoute — guards a route based on auth and access.
@@ -35,8 +36,10 @@ export function ProtectedRoute({
35
36
  deniedPath = '/auth/login'
36
37
  }) {
37
38
  const { authenticated, hasPage, hasRole } = useAccessStrict();
39
+ const location = useLocation();
38
40
 
39
41
  if (!authenticated) {
42
+ saveReturnTo(location);
40
43
  return <Navigate to={loginPath} replace />;
41
44
  }
42
45
 
@@ -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
- }