@xeplr/ui-account 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +25 -5
  2. package/src/AccessContext.jsx +86 -4
  3. package/src/ProtectedRoute.jsx +4 -1
  4. package/src/activeScope.js +71 -0
  5. package/src/adminApi.js +0 -29
  6. package/src/api.js +321 -22
  7. package/src/authRoutes.jsx +125 -0
  8. package/src/designs/AccountMenu.jsx +71 -0
  9. package/src/designs/ActivateSample.jsx +8 -16
  10. package/src/designs/ChangePasswordSample.jsx +3 -3
  11. package/src/designs/ForgotPasswordSample.jsx +3 -3
  12. package/src/designs/LoginSample.jsx +3 -2
  13. package/src/designs/NavDrawer.jsx +194 -0
  14. package/src/designs/NavFloatingSettings.jsx +25 -0
  15. package/src/designs/NavTopSample.jsx +34 -0
  16. package/src/designs/NotificationsBell.jsx +31 -0
  17. package/src/designs/ProfileSample.jsx +3 -3
  18. package/src/designs/RegisterSample.jsx +3 -3
  19. package/src/designs/ResetPasswordSample.jsx +7 -9
  20. package/src/designs/admin.css +13 -3
  21. package/src/designs/auth.css +6 -60
  22. package/src/designs/index.js +5 -2
  23. package/src/designs/nav.css +439 -0
  24. package/src/index.js +24 -8
  25. package/src/mt.js +31 -0
  26. package/src/pages.jsx +95 -38
  27. package/src/returnTo.js +24 -0
  28. package/src/useActivateController.js +19 -4
  29. package/src/useChangePasswordController.js +8 -2
  30. package/src/useForgotPasswordController.js +3 -0
  31. package/src/useLoginController.js +8 -1
  32. package/src/useNavController.js +224 -0
  33. package/src/useProfileController.js +6 -1
  34. package/src/useRegisterController.js +5 -1
  35. package/src/useResetPasswordController.js +12 -1
  36. package/src/validateDesign.js +20 -2
  37. package/src/designs/TenantPickerSample.jsx +0 -39
  38. package/src/designs/TenantSample.jsx +0 -182
  39. package/src/useTenantController.js +0 -146
  40. package/src/useTenantPickerController.js +0 -97
package/src/pages.jsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { memo } from 'react';
1
2
  import { useLoginController } from './useLoginController.js';
2
3
  import { useRegisterController } from './useRegisterController.js';
3
4
  import { useForgotPasswordController } from './useForgotPasswordController.js';
@@ -8,85 +9,141 @@ import { useProfileController } from './useProfileController.js';
8
9
  import { useUserRolesController } from './useUserRolesController.js';
9
10
  import { useAccessMatrixController } from './useAccessMatrixController.js';
10
11
  import { useMasterSettingsController } from './useMasterSettingsController.js';
11
- import { useTenantController } from './useTenantController.js';
12
- import { useTenantPickerController } from './useTenantPickerController.js';
13
- import { LoginSample, RegisterSample, ForgotPasswordSample, ResetPasswordSample, ActivateSample, NotActivatedSample, ChangePasswordSample, ProfileSample, UserRolesMatrixSample, AccessMatrixSample, MasterSettingsSample, TenantSample, TenantPickerSample } from './designs/index.js';
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 } from './validateDesign.js';
12
+ import { useNavController } from './useNavController.js';
13
+ import { LoginSample, RegisterSample, ForgotPasswordSample, ResetPasswordSample, ActivateSample, NotActivatedSample, ChangePasswordSample, ProfileSample, UserRolesMatrixSample, AccessMatrixSample, MasterSettingsSample, NavTopSample, NavDrawer, NavFloatingSettings } from './designs/index.js';
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
  /**
17
- * Ready-made pages using sample designs.
18
- * Use these for quick setup, or build your own with the controllers + your own designs.
17
+ * Ready-made pages: controller (the logic) + sample design (the look), wired together.
18
+ *
19
+ * Every page takes an optional `design` prop — pass your OWN design component to
20
+ * re-skin the page while keeping the framework's controller + validation. The design
21
+ * receives the controller's output as props (the same contract the sample honours), e.g.
22
+ * LoginSample gets { email, setEmail, password, setPassword, error, loading, handleSubmit }.
23
+ *
24
+ * <LoginPage design={MyLogin} onSuccess={...} /> // my look, framework logic
25
+ * <LoginPage onSuccess={...} /> // framework look
26
+ *
27
+ * Any other props (onSuccess, etc.) flow through to the controller.
19
28
  */
20
29
 
21
- export function LoginPage(props) {
30
+ export function LoginPage({ design, ...props }) {
22
31
  var controller = useLoginController(props);
23
32
  var ref = useDesignValidator('LoginPage', LOGIN_RULES);
24
- return <div ref={ref}><LoginSample {...controller} /></div>;
33
+ var View = design || LoginSample;
34
+ return <div ref={ref}><View {...controller} /></div>;
25
35
  }
26
36
 
27
- export function RegisterPage(props) {
37
+ export function RegisterPage({ design, ...props }) {
28
38
  var controller = useRegisterController(props);
29
39
  var ref = useDesignValidator('RegisterPage', REGISTER_RULES);
30
- return <div ref={ref}><RegisterSample {...controller} /></div>;
40
+ var View = design || RegisterSample;
41
+ return <div ref={ref}><View {...controller} /></div>;
31
42
  }
32
43
 
33
- export function ForgotPasswordPage() {
34
- var controller = useForgotPasswordController();
44
+ export function ForgotPasswordPage({ design, ...props }) {
45
+ var controller = useForgotPasswordController(props);
35
46
  var ref = useDesignValidator('ForgotPasswordPage', FORGOT_PASSWORD_RULES);
36
- return <div ref={ref}><ForgotPasswordSample {...controller} /></div>;
47
+ var View = design || ForgotPasswordSample;
48
+ return <div ref={ref}><View {...controller} /></div>;
37
49
  }
38
50
 
39
- export function ResetPasswordPage() {
40
- var controller = useResetPasswordController();
51
+ export function ResetPasswordPage({ design, ...props }) {
52
+ var controller = useResetPasswordController(props);
41
53
  var ref = useDesignValidator('ResetPasswordPage', RESET_PASSWORD_RULES);
42
- return <div ref={ref}><ResetPasswordSample {...controller} /></div>;
54
+ var View = design || ResetPasswordSample;
55
+ return <div ref={ref}><View {...controller} /></div>;
43
56
  }
44
57
 
45
- export function ActivatePage() {
46
- var controller = useActivateController();
47
- return <ActivateSample {...controller} />;
58
+ export function ActivatePage({ design, ...props }) {
59
+ var controller = useActivateController(props);
60
+ var View = design || ActivateSample;
61
+ return <View {...controller} />;
48
62
  }
49
63
 
50
- export function NotActivatedPage() {
51
- return <NotActivatedSample />;
64
+ export function NotActivatedPage({ design }) {
65
+ var View = design || NotActivatedSample;
66
+ return <View />;
52
67
  }
53
68
 
54
- export function ChangePasswordPage(props) {
69
+ export function ChangePasswordPage({ design, ...props }) {
55
70
  var controller = useChangePasswordController(props);
56
71
  var ref = useDesignValidator('ChangePasswordPage', CHANGE_PASSWORD_RULES);
57
- return <div ref={ref}><ChangePasswordSample {...controller} /></div>;
72
+ var View = design || ChangePasswordSample;
73
+ return <div ref={ref}><View {...controller} /></div>;
58
74
  }
59
75
 
60
- export function ProfilePage(props) {
76
+ export function ProfilePage({ design, ...props }) {
61
77
  var controller = useProfileController(props);
62
78
  var ref = useDesignValidator('ProfilePage', PROFILE_RULES);
63
- return <div ref={ref}><ProfileSample {...controller} /></div>;
79
+ var View = design || ProfileSample;
80
+ return <div ref={ref}><View {...controller} /></div>;
64
81
  }
65
82
 
66
- export function UserRolesPage(props) {
83
+ export function UserRolesPage({ design, ...props }) {
67
84
  var controller = useUserRolesController(props);
68
85
  var ref = useDesignValidator('UserRolesPage', USER_ROLES_MATRIX_RULES);
69
- return <div ref={ref}><UserRolesMatrixSample {...controller} /></div>;
86
+ var View = design || UserRolesMatrixSample;
87
+ return <div ref={ref}><View {...controller} /></div>;
70
88
  }
71
89
 
72
- export function AccessMatrixPage(props) {
90
+ export function AccessMatrixPage({ design, ...props }) {
73
91
  var controller = useAccessMatrixController(props);
74
92
  var ref = useDesignValidator('AccessMatrixPage', ACCESS_MATRIX_RULES);
75
- return <div ref={ref}><AccessMatrixSample {...controller} /></div>;
93
+ var View = design || AccessMatrixSample;
94
+ return <div ref={ref}><View {...controller} /></div>;
76
95
  }
77
96
 
78
- export function MasterSettingsPage(props) {
97
+ export function MasterSettingsPage({ design, ...props }) {
79
98
  var controller = useMasterSettingsController(props);
80
99
  var ref = useDesignValidator('MasterSettingsPage', MASTER_SETTINGS_RULES);
81
- return <div ref={ref}><MasterSettingsSample {...controller} /></div>;
100
+ var View = design || MasterSettingsSample;
101
+ return <div ref={ref}><View {...controller} /></div>;
82
102
  }
83
103
 
84
- export function TenantPage(props) {
85
- var controller = useTenantController(props);
86
- return <TenantSample {...controller} />;
104
+ // NavPage is memoized — unlike the other pages, it lives in your app's layout
105
+ // route alongside <Outlet/>, so it must not re-render when the routed page
106
+ // changes. Exactly ONE of two things renders, never both: if `drawerItems` is
107
+ // non-empty, the drawer rail (NavDrawer) IS the nav — it hosts settings,
108
+ // notifications, and everything else that would otherwise be in the top bar.
109
+ // Otherwise the top bar (`design`, defaults to NavTopSample) renders alone.
110
+ // `navMiddle` has no equivalent in the drawer (there's no middle slot in a
111
+ // vertical rail) — it's simply unused whenever a drawer is present.
112
+ // See useNavController.js for the drawerItems/settingsOverrides/notifications
113
+ // props this accepts.
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 }) {
122
+ var controller = useNavController(props);
123
+ var ref = useDesignValidator('NavPage', NAV_RULES);
124
+ var TopBar = design || NavTopSample;
125
+ var hasDrawer = controller.drawerItems.length > 0;
126
+ var floating = floatingSettings && hasDrawer;
127
+ return (
128
+ <div ref={ref}>
129
+ {hasDrawer ? (
130
+ <NavDrawer
131
+ {...controller}
132
+ logo={logo}
133
+ expandedLogo={expandedLogo}
134
+ drawerPromo={drawerPromo}
135
+ hideFooterIcons={floating}
136
+ />
137
+ ) : (
138
+ <TopBar
139
+ {...controller}
140
+ logo={logo}
141
+ navMiddle={navMiddle}
142
+ />
143
+ )}
144
+ {floating && <NavFloatingSettings {...controller} />}
145
+ </div>
146
+ );
87
147
  }
88
148
 
89
- export function TenantPickerPage(props) {
90
- var controller = useTenantPickerController(props);
91
- return <TenantPickerSample {...controller} />;
92
- }
149
+ export var NavPage = memo(NavPageImpl);
@@ -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
+ }
@@ -1,32 +1,47 @@
1
1
  import { useState, useEffect, useRef } from 'react';
2
2
  import { useSearchParams } from 'react-router-dom';
3
+ import { raiseSnackbar } from '@xeplr/ui-utils';
3
4
  import { activateAccount } from './api.js';
4
5
 
5
6
  export function useActivateController() {
6
7
  var [searchParams] = useSearchParams();
7
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');
8
13
  var [error, setError] = useState('');
9
14
  var [success, setSuccess] = useState('');
10
15
  var [loading, setLoading] = useState(false);
11
16
  var called = useRef(false);
12
17
 
13
18
  useEffect(function() {
14
- if (!token || called.current) return;
19
+ if (!token) {
20
+ if (!called.current) {
21
+ called.current = true;
22
+ raiseSnackbar('Invalid activation link', { design: 'error' });
23
+ }
24
+ return;
25
+ }
26
+ if (called.current) return;
15
27
  called.current = true;
16
28
  setLoading(true);
17
29
  setError('');
18
30
  setSuccess('');
19
- activateAccount(token)
31
+ activateAccount(token, workflowKey)
20
32
  .then(function(result) {
21
- setSuccess(result.message || 'Account activated successfully');
33
+ var message = result.message || 'Account activated successfully';
34
+ setSuccess(message);
35
+ raiseSnackbar(message, { design: 'success' });
22
36
  })
23
37
  .catch(function(err) {
24
38
  setError(err.message);
39
+ raiseSnackbar(err.message, { design: 'error' });
25
40
  })
26
41
  .finally(function() {
27
42
  setLoading(false);
28
43
  });
29
- }, [token]);
44
+ }, [token, workflowKey]);
30
45
 
31
46
  return {
32
47
  token,
@@ -1,4 +1,5 @@
1
1
  import { useState } from 'react';
2
+ import { raiseSnackbar } from '@xeplr/ui-utils';
2
3
  import { changePassword } from './api.js';
3
4
 
4
5
  export function useChangePasswordController(options = {}) {
@@ -17,20 +18,25 @@ export function useChangePasswordController(options = {}) {
17
18
  setSuccess('');
18
19
 
19
20
  if (newPassword !== confirmPassword) {
20
- setError('Passwords do not match');
21
+ var mismatchMessage = 'Passwords do not match';
22
+ setError(mismatchMessage);
23
+ raiseSnackbar(mismatchMessage, { design: 'error' });
21
24
  return;
22
25
  }
23
26
 
24
27
  setLoading(true);
25
28
  try {
26
29
  var result = await changePassword({ currentPassword, newPassword });
27
- setSuccess('Password changed successfully');
30
+ var message = 'Password changed successfully';
31
+ setSuccess(message);
32
+ raiseSnackbar(message, { design: 'success' });
28
33
  setCurrentPassword('');
29
34
  setNewPassword('');
30
35
  setConfirmPassword('');
31
36
  if (onSuccess) onSuccess(result);
32
37
  } catch (err) {
33
38
  setError(err.message);
39
+ raiseSnackbar(err.message, { design: 'error' });
34
40
  } finally {
35
41
  setLoading(false);
36
42
  }
@@ -1,4 +1,5 @@
1
1
  import { useState } from 'react';
2
+ import { raiseSnackbar } from '@xeplr/ui-utils';
2
3
  import { forgotPassword } from './api.js';
3
4
 
4
5
  export function useForgotPasswordController() {
@@ -15,8 +16,10 @@ export function useForgotPasswordController() {
15
16
  try {
16
17
  const result = await forgotPassword({ email });
17
18
  setSuccess(result.message);
19
+ raiseSnackbar(result.message, { design: 'success' });
18
20
  } catch (err) {
19
21
  setError(err.message);
22
+ raiseSnackbar(err.message, { design: 'error' });
20
23
  } finally {
21
24
  setLoading(false);
22
25
  }
@@ -1,8 +1,10 @@
1
1
  import { useState } from 'react';
2
2
  import { useNavigate } from 'react-router-dom';
3
+ import { raiseSnackbar } from '@xeplr/ui-utils';
3
4
  import { loginUser } from './api.js';
4
5
  import { setToken, setRefreshToken } from './token.js';
5
6
  import { useAccess } from './AccessContext.jsx';
7
+ import { consumeReturnTo } from './returnTo.js';
6
8
 
7
9
  export function useLoginController(options = {}) {
8
10
  const [email, setEmail] = useState('');
@@ -14,7 +16,11 @@ export function useLoginController(options = {}) {
14
16
  // Safe — returns null if no AccessProvider wraps the app
15
17
  const accessCtx = useAccess();
16
18
 
17
- const onSuccess = options.onSuccess || (() => navigate('/'));
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() || '/'));
18
24
  const notActivatedPath = options.notActivatedPath || '/auth/not-activated';
19
25
 
20
26
  async function handleSubmit(e) {
@@ -37,6 +43,7 @@ export function useLoginController(options = {}) {
37
43
  navigate(notActivatedPath);
38
44
  } else {
39
45
  setError(err.message);
46
+ raiseSnackbar(err.message, { design: 'error' });
40
47
  }
41
48
  } finally {
42
49
  setLoading(false);
@@ -0,0 +1,224 @@
1
+ import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
2
+ import { useAccessStrict } from './AccessContext.jsx';
3
+ import { authPath } from './authRoutes.jsx';
4
+
5
+ // The built-in settings-dropdown items — seeded via auth's migrations (menus
6
+ // table, see migrations/0006_seed_catalog.sql), not hardcoded here. Maps a
7
+ // seeded menu NAME to the authRoutes key it links to. "Admin" is deliberately
8
+ // NOT here — the framework's builtin section is Profile/Change Password only;
9
+ // an app that wants an Admin link adds it to its own settingsOverrides. Only
10
+ // names present in access.menus (role-filtered, computed at login) render.
11
+ var BUILTIN_SETTINGS_ITEMS = [
12
+ { menuName: 'Profile', authKey: 'profile' },
13
+ { menuName: 'Change Password', authKey: 'changePassword' }
14
+ ];
15
+ var NOTIFICATIONS_MENU_NAME = 'Notifications';
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
+
67
+ /**
68
+ * Nav is self-contained by design: it reads access/user from context (which only
69
+ * changes on login/logout/access updates) and owns its OWN open/closed UI state.
70
+ * It never depends on — and never forces a re-render of — whatever page is
71
+ * currently mounted in the app's <Outlet/>. Keep it that way when extending:
72
+ * don't lift this state into the app's layout component.
73
+ *
74
+ * @param {object} props
75
+ * @param {Array<{name:string, icon?:string, clickHandler?:Function, group?:string}>} [props.drawerItems] —
76
+ * the app's own drawer catalog (each `name` must match a `menus.name` row in
77
+ * the auth DB). Passing a non-empty array auto-activates the drawer rail —
78
+ * there's no separate flag. `group` is optional; ungrouped items render
79
+ * first, grouped ones under their section header (see NavDrawer.jsx).
80
+ * Role-filtered via access.menus, same mechanism as the settings items.
81
+ * @param {Array<{name:string, path:string}>} [props.settingsOverrides] — the app's
82
+ * OWN items rendered ABOVE the builtin (Profile/Change Password) section in
83
+ * the settings dropdown — this is where an "Admin" link belongs now. Each
84
+ * `name` must match a `menus.name` row you've seeded (in YOUR app's
85
+ * migrations-auth, not auth's own) — role-filtered via access.menus.
86
+ * @param {{count?:number, onClick?:Function}} [props.notifications] — the app's feed
87
+ * config. Only takes effect if the user's role grants the "Notifications" menu
88
+ * (seeded basic-tier, visible to everyone by default) — otherwise the bell is
89
+ * hidden regardless of what's passed here.
90
+ */
91
+ export function useNavController(props) {
92
+ props = props || {};
93
+ var accessCtx = useAccessStrict();
94
+ var access = accessCtx.access || {};
95
+ var allowedMenus = access.menus || [];
96
+
97
+ var [accountOpen, setAccountOpen] = useState(false);
98
+ var [drawerOpen, setDrawerOpen] = useState(false);
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
+
105
+ var accountRef = useRef(null);
106
+
107
+ var toggleAccount = useCallback(function() { setAccountOpen(function(v) { return !v; }); }, []);
108
+ var closeAccount = useCallback(function() { setAccountOpen(false); }, []);
109
+ var toggleDrawer = useCallback(function() { setDrawerOpen(function(v) { return !v; }); }, []);
110
+ var closeDrawer = useCallback(function() { setDrawerOpen(false); }, []);
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
+
170
+ // Click-outside for the settings menu only — the drawer closes via its own overlay.
171
+ useEffect(function() {
172
+ if (!accountOpen) return;
173
+ function onDocClick(e) {
174
+ if (accountRef.current && !accountRef.current.contains(e.target)) closeAccount();
175
+ }
176
+ document.addEventListener('mousedown', onDocClick);
177
+ return function() { document.removeEventListener('mousedown', onDocClick); };
178
+ }, [accountOpen, closeAccount]);
179
+
180
+ // Settings dropdown, in render order: app overrides first, then the fixed
181
+ // builtin section — role-filtered the same way for both.
182
+ var accountItems = useMemo(function() {
183
+ var overrides = (props.settingsOverrides || [])
184
+ .filter(function(item) { return allowedMenus.indexOf(item.name) !== -1; });
185
+ var builtin = BUILTIN_SETTINGS_ITEMS
186
+ .filter(function(item) { return allowedMenus.indexOf(item.menuName) !== -1; })
187
+ .map(function(item) { return { name: item.menuName, path: authPath(item.authKey) }; });
188
+ return overrides.concat(builtin);
189
+ }, [allowedMenus, props.settingsOverrides]);
190
+
191
+ var notificationsAllowed = allowedMenus.indexOf(NOTIFICATIONS_MENU_NAME) !== -1;
192
+
193
+ // Role-filter the app's own drawer catalog down to what this user can actually see.
194
+ var drawerItems = useMemo(function() {
195
+ var catalog = props.drawerItems || [];
196
+ return catalog.filter(function(m) { return allowedMenus.indexOf(m.name) !== -1; });
197
+ }, [props.drawerItems, allowedMenus]);
198
+
199
+ return {
200
+ user: accessCtx.user,
201
+ accountItems: accountItems,
202
+ notifications: notificationsAllowed ? (props.notifications || null) : null,
203
+ logout: accessCtx.logout,
204
+
205
+ accountOpen: accountOpen,
206
+ toggleAccount: toggleAccount,
207
+ closeAccount: closeAccount,
208
+ accountRef: accountRef,
209
+
210
+ drawerOpen: drawerOpen,
211
+ toggleDrawer: toggleDrawer,
212
+ closeDrawer: closeDrawer,
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
223
+ };
224
+ }
@@ -1,4 +1,5 @@
1
1
  import { useState, useEffect } from 'react';
2
+ import { raiseSnackbar } from '@xeplr/ui-utils';
2
3
  import { getProfile, updateProfile } from './api.js';
3
4
 
4
5
  export function useProfileController(options = {}) {
@@ -25,6 +26,7 @@ export function useProfileController(options = {}) {
25
26
  });
26
27
  } catch (err) {
27
28
  setError(err.message);
29
+ raiseSnackbar(err.message, { design: 'error' });
28
30
  } finally {
29
31
  setFetching(false);
30
32
  }
@@ -41,10 +43,13 @@ export function useProfileController(options = {}) {
41
43
  setLoading(true);
42
44
  try {
43
45
  var result = await updateProfile(form);
44
- setSuccess('Profile updated successfully');
46
+ var message = 'Profile updated successfully';
47
+ setSuccess(message);
48
+ raiseSnackbar(message, { design: 'success' });
45
49
  if (onSuccess) onSuccess(result);
46
50
  } catch (err) {
47
51
  setError(err.message);
52
+ raiseSnackbar(err.message, { design: 'error' });
48
53
  } finally {
49
54
  setLoading(false);
50
55
  }
@@ -1,4 +1,5 @@
1
1
  import { useState } from 'react';
2
+ import { raiseSnackbar } from '@xeplr/ui-utils';
2
3
  import { registerUser } from './api.js';
3
4
 
4
5
  export function useRegisterController(options = {}) {
@@ -20,11 +21,14 @@ export function useRegisterController(options = {}) {
20
21
  setLoading(true);
21
22
  try {
22
23
  const result = await registerUser(form);
23
- setSuccess('Registration successful. Please wait, someone will activate you.');
24
+ const message = 'Registration successful. Please wait, someone will activate you.';
25
+ setSuccess(message);
26
+ raiseSnackbar(message, { design: 'success' });
24
27
  setForm({ name: '', email: '', phoneNumber: '', password: '' });
25
28
  if (onSuccess) onSuccess(result);
26
29
  } catch (err) {
27
30
  setError(err.message);
31
+ raiseSnackbar(err.message, { design: 'error' });
28
32
  } finally {
29
33
  setLoading(false);
30
34
  }
@@ -1,5 +1,6 @@
1
- import { useState } from 'react';
1
+ import { useState, useEffect, useRef } from 'react';
2
2
  import { useSearchParams } from 'react-router-dom';
3
+ import { raiseSnackbar } from '@xeplr/ui-utils';
3
4
  import { resetPassword } from './api.js';
4
5
 
5
6
  export function useResetPasswordController() {
@@ -9,6 +10,14 @@ export function useResetPasswordController() {
9
10
  const [error, setError] = useState('');
10
11
  const [success, setSuccess] = useState('');
11
12
  const [loading, setLoading] = useState(false);
13
+ const warnedNoToken = useRef(false);
14
+
15
+ useEffect(function() {
16
+ if (!token && !warnedNoToken.current) {
17
+ warnedNoToken.current = true;
18
+ raiseSnackbar('Invalid or expired reset link', { design: 'error' });
19
+ }
20
+ }, [token]);
12
21
 
13
22
  async function handleSubmit(e) {
14
23
  e.preventDefault();
@@ -18,8 +27,10 @@ export function useResetPasswordController() {
18
27
  try {
19
28
  const result = await resetPassword({ token, newPassword: password });
20
29
  setSuccess(result.message);
30
+ raiseSnackbar(result.message, { design: 'success' });
21
31
  } catch (err) {
22
32
  setError(err.message);
33
+ raiseSnackbar(err.message, { design: 'error' });
23
34
  } finally {
24
35
  setLoading(false);
25
36
  }