@xeplr/ui-account 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +25 -5
- package/src/AccessContext.jsx +86 -4
- package/src/ProtectedRoute.jsx +4 -1
- package/src/activeScope.js +71 -0
- package/src/adminApi.js +0 -29
- package/src/api.js +321 -22
- package/src/authRoutes.jsx +125 -0
- package/src/designs/AccountMenu.jsx +71 -0
- package/src/designs/ActivateSample.jsx +8 -16
- package/src/designs/ChangePasswordSample.jsx +3 -3
- package/src/designs/ForgotPasswordSample.jsx +3 -3
- package/src/designs/LoginSample.jsx +3 -2
- package/src/designs/NavDrawer.jsx +194 -0
- package/src/designs/NavFloatingSettings.jsx +25 -0
- package/src/designs/NavTopSample.jsx +34 -0
- package/src/designs/NotificationsBell.jsx +31 -0
- package/src/designs/ProfileSample.jsx +3 -3
- package/src/designs/RegisterSample.jsx +3 -3
- package/src/designs/ResetPasswordSample.jsx +7 -9
- package/src/designs/admin.css +13 -3
- package/src/designs/auth.css +6 -60
- package/src/designs/index.js +5 -2
- package/src/designs/nav.css +439 -0
- package/src/index.js +24 -8
- package/src/mt.js +31 -0
- package/src/pages.jsx +95 -38
- package/src/returnTo.js +24 -0
- package/src/useActivateController.js +19 -4
- package/src/useChangePasswordController.js +8 -2
- package/src/useForgotPasswordController.js +3 -0
- package/src/useLoginController.js +8 -1
- package/src/useNavController.js +224 -0
- package/src/useProfileController.js +6 -1
- package/src/useRegisterController.js +5 -1
- package/src/useResetPasswordController.js +12 -1
- package/src/validateDesign.js +20 -2
- package/src/designs/TenantPickerSample.jsx +0 -39
- package/src/designs/TenantSample.jsx +0 -182
- package/src/useTenantController.js +0 -146
- package/src/useTenantPickerController.js +0 -97
package/src/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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
+
var View = design || MasterSettingsSample;
|
|
101
|
+
return <div ref={ref}><View {...controller} /></div>;
|
|
82
102
|
}
|
|
83
103
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
90
|
-
var controller = useTenantPickerController(props);
|
|
91
|
-
return <TenantPickerSample {...controller} />;
|
|
92
|
-
}
|
|
149
|
+
export var NavPage = memo(NavPageImpl);
|
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
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|