@xeplr/ui-account 1.0.0 → 1.0.1
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/activeScope.js +46 -0
- package/src/adminApi.js +0 -29
- package/src/api.js +57 -14
- 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 +121 -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 +4 -2
- package/src/designs/nav.css +311 -0
- package/src/index.js +19 -8
- package/src/mt.js +31 -0
- package/src/pages.jsx +85 -38
- package/src/useActivateController.js +13 -2
- package/src/useChangePasswordController.js +8 -2
- package/src/useForgotPasswordController.js +3 -0
- package/src/useLoginController.js +2 -0
- package/src/useNavController.js +102 -0
- package/src/useProfileController.js +6 -1
- package/src/useRegisterController.js +5 -1
- package/src/useResetPasswordController.js +12 -1
- package/src/validateDesign.js +6 -0
- 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.1",
|
|
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"
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
/**
|
|
31
|
+
* Clear one level's scope, or every level if omitted (e.g. on logout).
|
|
32
|
+
*/
|
|
33
|
+
export function clearActiveScope(level) {
|
|
34
|
+
if (level) {
|
|
35
|
+
setActiveScope(level, null);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
var keys = [];
|
|
40
|
+
for (var i = 0; i < localStorage.length; i++) {
|
|
41
|
+
var key = localStorage.key(i);
|
|
42
|
+
if (key && key.indexOf(STORAGE_PREFIX) === 0) keys.push(key);
|
|
43
|
+
}
|
|
44
|
+
keys.forEach(function(key) { localStorage.removeItem(key); });
|
|
45
|
+
} catch (e) {}
|
|
46
|
+
}
|
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
|
-
}
|
package/src/api.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getToken, setToken, getRefreshToken, setRefreshToken, clearAuth } from './token.js';
|
|
2
|
+
import { getActiveScope, clearActiveScope } from './activeScope.js';
|
|
3
|
+
import { getMtConfig } from './mt.js';
|
|
2
4
|
|
|
3
5
|
let _baseUrl = '';
|
|
4
6
|
let _onSessionExpired = null;
|
|
@@ -64,8 +66,23 @@ async function refreshAccessToken() {
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
+
* Absorb a server-issued sliding-refresh token. When the access token was
|
|
70
|
+
* expired-but-within-tolerance, the API serves the request normally and hands
|
|
71
|
+
* back a fresh token via the `X-New-Token` header (see @xeplr/auth
|
|
72
|
+
* authMiddleware). We just swap it into storage — no gating, no retry. This is
|
|
73
|
+
* the happy path; it means most expiries never produce a 401 at all.
|
|
74
|
+
*/
|
|
75
|
+
function absorbNewToken(res) {
|
|
76
|
+
try {
|
|
77
|
+
const fresh = res.headers.get('X-New-Token');
|
|
78
|
+
if (fresh) setToken(fresh);
|
|
79
|
+
} catch (e) {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Authenticated fetch with sliding refresh.
|
|
84
|
+
* Attaches Bearer token; absorbs X-New-Token off every response. The 401→refresh
|
|
85
|
+
* path below is now only a fallback for a token past the whole tolerance window.
|
|
69
86
|
*/
|
|
70
87
|
export async function authFetch(endpoint, options = {}) {
|
|
71
88
|
const url = endpoint.startsWith('http') ? endpoint : `${getBaseUrl()}${endpoint}`;
|
|
@@ -80,23 +97,27 @@ export async function authFetch(endpoint, options = {}) {
|
|
|
80
97
|
headers['Authorization'] = `Bearer ${token}`;
|
|
81
98
|
}
|
|
82
99
|
|
|
83
|
-
// Attach active
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
100
|
+
// Attach the app's active scope (e.g. company/workspace) per configured MT
|
|
101
|
+
// level — see mt.js's registerMTs() and activeScope.js.
|
|
102
|
+
const mtConfig = getMtConfig();
|
|
103
|
+
Object.keys(mtConfig.slots).forEach((level) => {
|
|
104
|
+
const slot = mtConfig.slots[level];
|
|
105
|
+
const scope = getActiveScope(level);
|
|
106
|
+
if (scope && scope.id) headers[slot.header] = scope.id;
|
|
107
|
+
});
|
|
91
108
|
|
|
92
109
|
let res = await fetch(url, { ...options, headers });
|
|
110
|
+
absorbNewToken(res);
|
|
93
111
|
|
|
94
|
-
//
|
|
112
|
+
// Fallback: token is past the tolerance window (truly dead) → rotate via the
|
|
113
|
+
// refresh token and retry once. With server-side sliding refresh this rarely
|
|
114
|
+
// fires; the tolerance window absorbs ordinary expiries above.
|
|
95
115
|
if (res.status === 401) {
|
|
96
116
|
const refreshed = await refreshAccessToken();
|
|
97
117
|
if (refreshed) {
|
|
98
118
|
headers['Authorization'] = `Bearer ${getToken()}`;
|
|
99
119
|
res = await fetch(url, { ...options, headers });
|
|
120
|
+
absorbNewToken(res);
|
|
100
121
|
}
|
|
101
122
|
}
|
|
102
123
|
|
|
@@ -157,7 +178,7 @@ export function resetPassword({ token, newPassword }) {
|
|
|
157
178
|
export function changePassword({ currentPassword, newPassword }) {
|
|
158
179
|
return authFetch('/auth/api/change-password', {
|
|
159
180
|
method: 'POST',
|
|
160
|
-
body: JSON.stringify({ currentPassword, newPassword }),
|
|
181
|
+
body: JSON.stringify({ oldPassword: currentPassword, newPassword }),
|
|
161
182
|
});
|
|
162
183
|
}
|
|
163
184
|
|
|
@@ -172,8 +193,29 @@ export function updateProfile(fields) {
|
|
|
172
193
|
});
|
|
173
194
|
}
|
|
174
195
|
|
|
175
|
-
|
|
176
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Upload a new profile picture. Bypasses authFetch's JSON Content-Type (the
|
|
198
|
+
* browser sets multipart/form-data with the right boundary itself when the
|
|
199
|
+
* body is a FormData — setting it manually breaks the boundary).
|
|
200
|
+
*/
|
|
201
|
+
export async function uploadAvatar(file) {
|
|
202
|
+
const formData = new FormData();
|
|
203
|
+
formData.append('avatar', file);
|
|
204
|
+
|
|
205
|
+
const headers = {};
|
|
206
|
+
const token = getToken();
|
|
207
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
208
|
+
|
|
209
|
+
const res = await fetch(`${getBaseUrl()}/auth/api/profile/avatar`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers,
|
|
212
|
+
body: formData,
|
|
213
|
+
});
|
|
214
|
+
const data = await res.json();
|
|
215
|
+
if (!res.ok) {
|
|
216
|
+
throw new Error(data.error || 'Something went wrong');
|
|
217
|
+
}
|
|
218
|
+
return data;
|
|
177
219
|
}
|
|
178
220
|
|
|
179
221
|
export function logoutUser() {
|
|
@@ -181,6 +223,7 @@ export function logoutUser() {
|
|
|
181
223
|
const accessToken = getToken();
|
|
182
224
|
|
|
183
225
|
clearAuth();
|
|
226
|
+
clearActiveScope();
|
|
184
227
|
|
|
185
228
|
// Best-effort server-side cleanup
|
|
186
229
|
if (refreshToken) {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { isValidElement } from 'react';
|
|
2
|
+
import { Route } from 'react-router-dom';
|
|
3
|
+
import { ProtectedRoute } from './ProtectedRoute.jsx';
|
|
4
|
+
import {
|
|
5
|
+
LoginPage, RegisterPage, ForgotPasswordPage, ResetPasswordPage, ActivatePage,
|
|
6
|
+
NotActivatedPage, ProfilePage, ChangePasswordPage,
|
|
7
|
+
UserRolesPage, AccessMatrixPage, MasterSettingsPage
|
|
8
|
+
} from './pages.jsx';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The auth-UI route table — the single source of truth for auth page paths.
|
|
12
|
+
*
|
|
13
|
+
* group 'public' → open (login, register, activate, …)
|
|
14
|
+
* group 'account' → login required (profile, change-password, …)
|
|
15
|
+
* group 'admin' → login required (RBAC/master screens; add page/roles to gate further)
|
|
16
|
+
*/
|
|
17
|
+
var MANIFEST = [
|
|
18
|
+
{ key: 'login', path: '/auth/login', Page: LoginPage, group: 'public' },
|
|
19
|
+
{ key: 'register', path: '/auth/register', Page: RegisterPage, group: 'public' },
|
|
20
|
+
{ key: 'forgotPassword', path: '/auth/forgot-password', Page: ForgotPasswordPage, group: 'public' },
|
|
21
|
+
{ key: 'resetPassword', path: '/auth/reset-password', Page: ResetPasswordPage, group: 'public' },
|
|
22
|
+
{ key: 'activate', path: '/auth/activate', Page: ActivatePage, group: 'public' },
|
|
23
|
+
{ key: 'notActivated', path: '/auth/not-activated', Page: NotActivatedPage, group: 'public' },
|
|
24
|
+
{ key: 'profile', path: '/auth/profile', Page: ProfilePage, group: 'account' },
|
|
25
|
+
{ key: 'changePassword', path: '/auth/change-password', Page: ChangePasswordPage, group: 'account' },
|
|
26
|
+
{ key: 'userRoles', path: '/auth/admin/user-roles', Page: UserRolesPage, group: 'admin' },
|
|
27
|
+
{ key: 'accessMatrix', path: '/auth/admin/access-matrix', Page: AccessMatrixPage, group: 'admin' },
|
|
28
|
+
{ key: 'masterSettings', path: '/auth/admin/master-settings', Page: MasterSettingsPage, group: 'admin' }
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
var _pathByKey = {};
|
|
32
|
+
MANIFEST.forEach(function (e) { _pathByKey[e.key] = e.path; });
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a manifest key to its canonical path — use it for cross-links so nothing
|
|
36
|
+
* hardcodes a URL: <Link to={authPath('register')}>Register</Link>
|
|
37
|
+
*/
|
|
38
|
+
export function authPath(key) {
|
|
39
|
+
return _pathByKey[key] || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// /auth/login → /_login, /auth/admin/user-roles → /_admin/user-roles.
|
|
43
|
+
// A stable "always the pristine framework page" escape hatch that ignores overrides.
|
|
44
|
+
function rawPathOf(path) {
|
|
45
|
+
return path.replace(/^\/auth\//, '/_');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Turn an override entry into the element to render at the canonical path.
|
|
49
|
+
function resolveElement(entry, ov) {
|
|
50
|
+
var Page = entry.Page;
|
|
51
|
+
if (!ov) return <Page />; // framework default
|
|
52
|
+
if (isValidElement(ov)) return ov; // bare element → full replace
|
|
53
|
+
if (ov.element) return ov.element; // { element } → full replace
|
|
54
|
+
if (ov.design) return <Page design={ov.design} />; // { design } → re-skin (keeps controller)
|
|
55
|
+
return <Page />; // e.g. only { path } supplied
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Emit every auth UI route in one call — the app writes no route boilerplate.
|
|
60
|
+
*
|
|
61
|
+
* <Routes>
|
|
62
|
+
* {authRoutes()} // all framework defaults
|
|
63
|
+
* {authRoutes({ login: { design: MyLogin } })} // custom login, everything else default
|
|
64
|
+
* {authRoutes({}, { layout: <Nav/> })} // account/admin pages inside your shell
|
|
65
|
+
* </Routes>
|
|
66
|
+
*
|
|
67
|
+
* @param {object} [overrides] map of manifest key → how to render it:
|
|
68
|
+
* - { design: MyDesign } re-skin: framework controller + validation, your look
|
|
69
|
+
* (design receives the controller's props)
|
|
70
|
+
* - { element: <X/> } full replace: your element, framework logic ignored
|
|
71
|
+
* - a React element shorthand for { element }
|
|
72
|
+
* - false | null drop this route (app doesn't want it)
|
|
73
|
+
* - { path: '/x', ... } remount at a different path (combine with design/element)
|
|
74
|
+
* @param {object} [opts]
|
|
75
|
+
* - layout: element wrap account+admin pages in a parent layout route
|
|
76
|
+
* (your <Nav/> with an <Outlet/>); public pages stay bare
|
|
77
|
+
* - raw: false disable the /_<name> escape-hatch routes (on by default)
|
|
78
|
+
* - loginPath: string ProtectedRoute redirect target (default: manifest login path)
|
|
79
|
+
* @returns {Array} an array of <Route> — spread it inside <Routes>
|
|
80
|
+
*/
|
|
81
|
+
export function authRoutes(overrides, opts) {
|
|
82
|
+
overrides = overrides || {};
|
|
83
|
+
opts = opts || {};
|
|
84
|
+
var loginPath = opts.loginPath || _pathByKey.login;
|
|
85
|
+
var routes = [];
|
|
86
|
+
var layoutChildren = []; // account+admin canonical routes, nested under opts.layout
|
|
87
|
+
|
|
88
|
+
function protect(el) {
|
|
89
|
+
return <ProtectedRoute loginPath={loginPath}>{el}</ProtectedRoute>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
MANIFEST.forEach(function (entry) {
|
|
93
|
+
var ov = overrides[entry.key];
|
|
94
|
+
if (ov === false || ov === null) return; // opted out
|
|
95
|
+
|
|
96
|
+
var path = (ov && ov.path) || entry.path;
|
|
97
|
+
var el = resolveElement(entry, ov);
|
|
98
|
+
|
|
99
|
+
// Canonical route (override-aware).
|
|
100
|
+
if (entry.group === 'public') {
|
|
101
|
+
routes.push(<Route key={entry.key} path={path} element={el} />);
|
|
102
|
+
} else if (opts.layout) {
|
|
103
|
+
layoutChildren.push(<Route key={entry.key} path={path} element={el} />);
|
|
104
|
+
} else {
|
|
105
|
+
routes.push(<Route key={entry.key} path={path} element={protect(el)} />);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Escape-hatch route: always the pristine framework page, ignores overrides.
|
|
109
|
+
if (opts.raw !== false) {
|
|
110
|
+
var Page = entry.Page;
|
|
111
|
+
var rawEl = entry.group === 'public' ? <Page /> : protect(<Page />);
|
|
112
|
+
routes.push(<Route key={'_' + entry.key} path={rawPathOf(entry.path)} element={rawEl} />);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (opts.layout && layoutChildren.length) {
|
|
117
|
+
routes.push(
|
|
118
|
+
<Route key="__auth_layout" element={protect(opts.layout)}>
|
|
119
|
+
{layoutChildren}
|
|
120
|
+
</Route>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return routes;
|
|
125
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { memo } from 'react';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
|
|
4
|
+
// The settings trigger — a gear icon, parallel to NotificationsBell's bell
|
|
5
|
+
// icon — and its dropdown. Used two places: top-right of the top bar
|
|
6
|
+
// (`placement="bottom"`, the default — trigger is near the top of the
|
|
7
|
+
// viewport, so the menu opens downward) and the bottom of the drawer rail
|
|
8
|
+
// (`placement="top"` — trigger is near the bottom of the viewport, so the
|
|
9
|
+
// menu opens upward instead of running off-screen). Memoized and given only
|
|
10
|
+
// its own slice of props — it does not receive drawerOpen/drawerItems, so
|
|
11
|
+
// drawer interactions never touch it. accountItems is already resolved +
|
|
12
|
+
// role-filtered + ordered (overrides above builtin) by useNavController —
|
|
13
|
+
// this component just renders whatever it's handed. `triggerLabel` is
|
|
14
|
+
// optional text next to the gear icon — the drawer passes "Settings" when
|
|
15
|
+
// expanded (matching how its other items show a label), omits it when
|
|
16
|
+
// collapsed; the top bar never passes it (icon-only, no room).
|
|
17
|
+
function AccountMenu({
|
|
18
|
+
user, accountItems, placement, triggerLabel,
|
|
19
|
+
accountOpen, toggleAccount, closeAccount, accountRef, logout
|
|
20
|
+
}) {
|
|
21
|
+
var label = (user && (user.name || user.email)) || 'Account';
|
|
22
|
+
var initial = label.charAt(0).toUpperCase();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div ref={accountRef} className={'xeplr-nav-account' + (accountOpen ? ' xeplr-nav-account-open' : '')}>
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
className={'xeplr-nav-settings-trigger' + (triggerLabel ? ' xeplr-nav-settings-trigger-labeled' : '')}
|
|
29
|
+
onClick={toggleAccount}
|
|
30
|
+
aria-haspopup="menu"
|
|
31
|
+
aria-expanded={accountOpen}
|
|
32
|
+
aria-label="Settings"
|
|
33
|
+
>
|
|
34
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
35
|
+
<circle cx="12" cy="12" r="3" />
|
|
36
|
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
37
|
+
</svg>
|
|
38
|
+
{triggerLabel && <span className="xeplr-nav-settings-trigger-label">{triggerLabel}</span>}
|
|
39
|
+
</button>
|
|
40
|
+
|
|
41
|
+
{accountOpen && (
|
|
42
|
+
<div className={'xeplr-nav-account-menu' + (placement === 'top' ? ' xeplr-nav-account-menu-top' : '')} role="menu">
|
|
43
|
+
<div className="xeplr-nav-account-header">
|
|
44
|
+
{user && user.profilePicUrl
|
|
45
|
+
? <img src={user.profilePicUrl} alt="" className="xeplr-nav-avatar xeplr-nav-avatar-img" />
|
|
46
|
+
: <span className="xeplr-nav-avatar">{initial}</span>}
|
|
47
|
+
<div className="xeplr-nav-account-header-text">
|
|
48
|
+
<div className="xeplr-nav-account-name">{label}</div>
|
|
49
|
+
{user && user.email && user.name && <div className="xeplr-nav-account-email">{user.email}</div>}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
{(accountItems || []).map(function(item) {
|
|
54
|
+
return (
|
|
55
|
+
<Link key={item.name} to={item.path} className="xeplr-nav-account-item" role="menuitem" onClick={closeAccount}>
|
|
56
|
+
{item.name}
|
|
57
|
+
</Link>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
|
|
61
|
+
<div className="xeplr-nav-account-divider" />
|
|
62
|
+
<button type="button" className="xeplr-nav-account-item xeplr-nav-account-logout" role="menuitem" onClick={logout}>
|
|
63
|
+
Logout
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default memo(AccountMenu);
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { Link } from 'react-router-dom';
|
|
2
2
|
import './auth.css';
|
|
3
3
|
|
|
4
|
+
// error/success feedback fires as a snackbar (see useActivateController.js,
|
|
5
|
+
// including the "invalid link" case) — this design doesn't render it inline,
|
|
6
|
+
// it just switches which block is visible. `loading`'s in-progress status
|
|
7
|
+
// text stays inline (it's ongoing state, not a transient notification).
|
|
4
8
|
export default function ActivateSample({ token, error, success, loading }) {
|
|
5
9
|
if (!token) {
|
|
6
10
|
return (
|
|
7
11
|
<div className="xeplr-auth-container">
|
|
8
|
-
<div className="xeplr-auth-alert xeplr-auth-alert-error">Invalid activation link</div>
|
|
9
12
|
<div className="xeplr-auth-links">
|
|
10
13
|
<p><Link to="/auth/register">Register a new account</Link></p>
|
|
11
14
|
</div>
|
|
@@ -17,21 +20,10 @@ export default function ActivateSample({ token, error, success, loading }) {
|
|
|
17
20
|
<div className="xeplr-auth-container">
|
|
18
21
|
<h1>Account Activation</h1>
|
|
19
22
|
{loading && <div className="xeplr-auth-alert">Activating your account...</div>}
|
|
20
|
-
{error && (
|
|
21
|
-
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
<p><Link to="/auth/login">Go to Login</Link></p>
|
|
25
|
-
</div>
|
|
26
|
-
</>
|
|
27
|
-
)}
|
|
28
|
-
{success && (
|
|
29
|
-
<>
|
|
30
|
-
<div className="xeplr-auth-alert xeplr-auth-alert-success">{success}</div>
|
|
31
|
-
<div className="xeplr-auth-links">
|
|
32
|
-
<p><Link to="/auth/login">Go to Login</Link></p>
|
|
33
|
-
</div>
|
|
34
|
-
</>
|
|
23
|
+
{(error || success) && (
|
|
24
|
+
<div className="xeplr-auth-links">
|
|
25
|
+
<p><Link to="/auth/login">Go to Login</Link></p>
|
|
26
|
+
</div>
|
|
35
27
|
)}
|
|
36
28
|
</div>
|
|
37
29
|
);
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import './auth.css';
|
|
2
2
|
|
|
3
|
+
// error/success feedback fires as a snackbar (see useChangePasswordController.js)
|
|
4
|
+
// — this design doesn't render it inline.
|
|
3
5
|
export default function ChangePasswordSample({
|
|
4
6
|
currentPassword, setCurrentPassword,
|
|
5
7
|
newPassword, setNewPassword,
|
|
6
8
|
confirmPassword, setConfirmPassword,
|
|
7
|
-
|
|
9
|
+
loading, handleSubmit
|
|
8
10
|
}) {
|
|
9
11
|
return (
|
|
10
12
|
<div className="xeplr-auth-container">
|
|
11
13
|
<h1>Change Password</h1>
|
|
12
|
-
{error && <div className="xeplr-auth-alert xeplr-auth-alert-error">{error}</div>}
|
|
13
|
-
{success && <div className="xeplr-auth-alert xeplr-auth-alert-success">{success}</div>}
|
|
14
14
|
<form onSubmit={handleSubmit}>
|
|
15
15
|
<div className="xeplr-auth-form-group">
|
|
16
16
|
<label htmlFor="xeplr-current-password">Current Password</label>
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Link } from 'react-router-dom';
|
|
2
2
|
import './auth.css';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// error/success feedback fires as a snackbar (see useForgotPasswordController.js)
|
|
5
|
+
// — this design doesn't render it inline.
|
|
6
|
+
export default function ForgotPasswordSample({ email, setEmail, loading, handleSubmit }) {
|
|
5
7
|
return (
|
|
6
8
|
<div className="xeplr-auth-container">
|
|
7
9
|
<h1>Forgot Password</h1>
|
|
8
|
-
{error && <div className="xeplr-auth-alert xeplr-auth-alert-error">{error}</div>}
|
|
9
|
-
{success && <div className="xeplr-auth-alert xeplr-auth-alert-success">{success}</div>}
|
|
10
10
|
<form onSubmit={handleSubmit}>
|
|
11
11
|
<div className="xeplr-auth-form-group">
|
|
12
12
|
<label htmlFor="xeplr-email">Email</label>
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Link } from 'react-router-dom';
|
|
2
2
|
import './auth.css';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// error/success feedback fires as a snackbar (see useLoginController.js) —
|
|
5
|
+
// this design doesn't render it inline.
|
|
6
|
+
export default function LoginSample({ email, setEmail, password, setPassword, loading, handleSubmit }) {
|
|
5
7
|
return (
|
|
6
8
|
<div className="xeplr-auth-container">
|
|
7
9
|
<h1>Login</h1>
|
|
8
|
-
{error && <div className="xeplr-auth-alert xeplr-auth-alert-error">{error}</div>}
|
|
9
10
|
<form onSubmit={handleSubmit}>
|
|
10
11
|
<div className="xeplr-auth-form-group">
|
|
11
12
|
<label htmlFor="xeplr-email">Email</label>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { memo, useState, useMemo } from 'react';
|
|
2
|
+
import AccountMenu from './AccountMenu.jsx';
|
|
3
|
+
import NotificationsBell from './NotificationsBell.jsx';
|
|
4
|
+
|
|
5
|
+
// The ENTIRE nav when drawerItems is non-empty — NavPage renders this instead
|
|
6
|
+
// of (never alongside) the top bar, see pages.jsx. A pure overlay: `position:
|
|
7
|
+
// fixed` (nav.css), pinned to (0,0), full height — it never pushes or resizes
|
|
8
|
+
// anything else on the page, completely decoupled from the app's own content.
|
|
9
|
+
// Two states: icon-only (collapsed, `drawerOpen=false`) and
|
|
10
|
+
// icon+label+groups+search+promo (expanded). Toggled by clicking its own
|
|
11
|
+
// logo. Settings + notifications live in its footer (bottom-pinned) instead
|
|
12
|
+
// of a top bar, since there isn't one — same accountItems/notifications the
|
|
13
|
+
// top bar would have gotten, just rendered here instead.
|
|
14
|
+
//
|
|
15
|
+
// Items are bucketed by their optional `group` — ungrouped items render first
|
|
16
|
+
// (no header), grouped ones under a section header, groups in first-seen order.
|
|
17
|
+
function bucketItems(items) {
|
|
18
|
+
var ungrouped = [];
|
|
19
|
+
var groupOrder = [];
|
|
20
|
+
var groups = {};
|
|
21
|
+
items.forEach(function(item) {
|
|
22
|
+
if (!item.group) {
|
|
23
|
+
ungrouped.push(item);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (!groups[item.group]) {
|
|
27
|
+
groups[item.group] = [];
|
|
28
|
+
groupOrder.push(item.group);
|
|
29
|
+
}
|
|
30
|
+
groups[item.group].push(item);
|
|
31
|
+
});
|
|
32
|
+
return { ungrouped: ungrouped, groupOrder: groupOrder, groups: groups };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function NavDrawer({
|
|
36
|
+
drawerOpen, toggleDrawer, drawerItems, expandedLogo, logo, drawerPromo,
|
|
37
|
+
user, accountItems, notifications, accountOpen, toggleAccount, closeAccount, accountRef, logout
|
|
38
|
+
}) {
|
|
39
|
+
var [query, setQuery] = useState('');
|
|
40
|
+
|
|
41
|
+
var visibleItems = useMemo(function() {
|
|
42
|
+
if (!drawerOpen || !query.trim()) return drawerItems;
|
|
43
|
+
var q = query.trim().toLowerCase();
|
|
44
|
+
return drawerItems.filter(function(item) { return item.name.toLowerCase().indexOf(q) !== -1; });
|
|
45
|
+
}, [drawerItems, query, drawerOpen]);
|
|
46
|
+
|
|
47
|
+
var buckets = useMemo(function() { return bucketItems(visibleItems); }, [visibleItems]);
|
|
48
|
+
|
|
49
|
+
function renderItem(item) {
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
key={item.name}
|
|
53
|
+
type="button"
|
|
54
|
+
className="xeplr-nav-drawer-link"
|
|
55
|
+
onClick={item.clickHandler}
|
|
56
|
+
title={item.name}
|
|
57
|
+
>
|
|
58
|
+
{item.icon && <span className="xeplr-nav-drawer-icon">{item.icon}</span>}
|
|
59
|
+
{drawerOpen && <span className="xeplr-nav-drawer-label">{item.name}</span>}
|
|
60
|
+
</button>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<aside className={'xeplr-nav-drawer' + (drawerOpen ? ' xeplr-nav-drawer-expanded' : ' xeplr-nav-drawer-collapsed')}>
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
className="xeplr-nav-drawer-toggle"
|
|
69
|
+
onClick={toggleDrawer}
|
|
70
|
+
aria-label={drawerOpen ? 'Collapse menu' : 'Expand menu'}
|
|
71
|
+
aria-expanded={drawerOpen}
|
|
72
|
+
>
|
|
73
|
+
{logo && <img src={logo} alt="" className="xeplr-nav-drawer-logo" />}
|
|
74
|
+
</button>
|
|
75
|
+
{/* Own row below the toggle, not squeezed inline beside it — expandedLogo
|
|
76
|
+
is typically a stacked icon+wordmark lockup (near-square, not a wide
|
|
77
|
+
banner), so it needs real height to stay legible, not the icon's 22px.
|
|
78
|
+
Additive, not a swap of the toggle's own image — no flicker either way. */}
|
|
79
|
+
{drawerOpen && expandedLogo && (
|
|
80
|
+
<img src={expandedLogo} alt="" className="xeplr-nav-drawer-expanded-logo" />
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
{drawerOpen && (
|
|
84
|
+
<input
|
|
85
|
+
type="search"
|
|
86
|
+
className="xeplr-nav-drawer-search"
|
|
87
|
+
placeholder="Search"
|
|
88
|
+
value={query}
|
|
89
|
+
onChange={function(e) { setQuery(e.target.value); }}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
<nav className="xeplr-nav-drawer-links">
|
|
94
|
+
{buckets.ungrouped.map(renderItem)}
|
|
95
|
+
{buckets.groupOrder.map(function(groupName) {
|
|
96
|
+
return (
|
|
97
|
+
<div key={groupName} className="xeplr-nav-drawer-group">
|
|
98
|
+
{drawerOpen && <div className="xeplr-nav-drawer-group-label">{groupName}</div>}
|
|
99
|
+
{buckets.groups[groupName].map(renderItem)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
{visibleItems.length === 0 && drawerOpen && <div className="xeplr-nav-drawer-empty">No matches</div>}
|
|
104
|
+
</nav>
|
|
105
|
+
|
|
106
|
+
{drawerOpen && drawerPromo && <div className="xeplr-nav-drawer-promo">{drawerPromo}</div>}
|
|
107
|
+
|
|
108
|
+
<div className="xeplr-nav-drawer-footer">
|
|
109
|
+
<NotificationsBell notifications={notifications} label={drawerOpen ? 'Notifications' : undefined} />
|
|
110
|
+
<AccountMenu
|
|
111
|
+
placement="top" triggerLabel={drawerOpen ? 'Settings' : undefined}
|
|
112
|
+
user={user} accountItems={accountItems}
|
|
113
|
+
accountOpen={accountOpen} toggleAccount={toggleAccount} closeAccount={closeAccount}
|
|
114
|
+
accountRef={accountRef} logout={logout}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
</aside>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default memo(NavDrawer);
|