@xeplr/ui-account 1.0.0
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/LICENSE +21 -0
- package/package.json +17 -0
- package/src/AccessContext.jsx +125 -0
- package/src/AccessGuard.jsx +35 -0
- package/src/ProtectedRoute.jsx +55 -0
- package/src/ThemeContext.jsx +84 -0
- package/src/adminApi.js +68 -0
- package/src/api.js +196 -0
- package/src/designs/AccessMatrixSample.jsx +199 -0
- package/src/designs/ActivateSample.jsx +38 -0
- package/src/designs/ChangePasswordSample.jsx +31 -0
- package/src/designs/ForgotPasswordSample.jsx +22 -0
- package/src/designs/LoginSample.jsx +26 -0
- package/src/designs/MasterSettingsSample.jsx +206 -0
- package/src/designs/NotActivatedSample.jsx +17 -0
- package/src/designs/ProfileSample.jsx +36 -0
- package/src/designs/RegisterSample.jsx +34 -0
- package/src/designs/ResetPasswordSample.jsx +42 -0
- package/src/designs/TenantPickerSample.jsx +39 -0
- package/src/designs/TenantSample.jsx +182 -0
- package/src/designs/UserRolesMatrixSample.jsx +77 -0
- package/src/designs/admin.css +525 -0
- package/src/designs/auth.css +160 -0
- package/src/designs/index.js +13 -0
- package/src/designs/theme.css +231 -0
- package/src/index.js +41 -0
- package/src/masterApi.js +28 -0
- package/src/pages.jsx +92 -0
- package/src/token.js +38 -0
- package/src/useAccessMatrixController.js +244 -0
- package/src/useActivateController.js +37 -0
- package/src/useChangePasswordController.js +48 -0
- package/src/useForgotPasswordController.js +32 -0
- package/src/useLoginController.js +53 -0
- package/src/useMasterSettingsController.js +199 -0
- package/src/useProfileController.js +62 -0
- package/src/useRegisterController.js +41 -0
- package/src/useResetPasswordController.js +36 -0
- package/src/useTenantController.js +146 -0
- package/src/useTenantPickerController.js +97 -0
- package/src/useUserRolesController.js +81 -0
- package/src/validateDesign.js +101 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xeplr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xeplr/ui-account",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Account UI: auth, profile, RBAC admin, tenant management — React controller hooks and designs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"files": ["src/"],
|
|
8
|
+
"keywords": ["auth", "account", "profile", "react", "hooks", "login", "register", "rbac", "multi-tenancy"],
|
|
9
|
+
"author": "xeplr",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": { "type": "git", "url": "https://github.com/Xeplr/xeplr-ui-account" },
|
|
12
|
+
"publishConfig": { "access": "public" },
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
15
|
+
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useEffect } from 'react';
|
|
2
|
+
import { getToken, getUser } from './token.js';
|
|
3
|
+
import { logoutUser } from './api.js';
|
|
4
|
+
|
|
5
|
+
const AccessContext = createContext(null);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* AccessProvider — wraps your app to provide access state.
|
|
9
|
+
*
|
|
10
|
+
* On mount, loads access from localStorage (set during login).
|
|
11
|
+
* Provides helpers: hasPage, hasApi, hasMenu, hasElement, hasRole.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* <AccessProvider>
|
|
15
|
+
* <App />
|
|
16
|
+
* </AccessProvider>
|
|
17
|
+
*/
|
|
18
|
+
export function AccessProvider({ children }) {
|
|
19
|
+
const [access, setAccessState] = useState(() => {
|
|
20
|
+
const raw = localStorage.getItem('access');
|
|
21
|
+
return raw ? JSON.parse(raw) : null;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const [user, setUserState] = useState(() => getUser());
|
|
25
|
+
const [authenticated, setAuthenticated] = useState(() => !!getToken());
|
|
26
|
+
|
|
27
|
+
function setAccess(accessObj) {
|
|
28
|
+
if (accessObj) {
|
|
29
|
+
localStorage.setItem('access', JSON.stringify(accessObj));
|
|
30
|
+
} else {
|
|
31
|
+
localStorage.removeItem('access');
|
|
32
|
+
}
|
|
33
|
+
setAccessState(accessObj);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setUser(userObj) {
|
|
37
|
+
if (userObj) {
|
|
38
|
+
localStorage.setItem('user', JSON.stringify(userObj));
|
|
39
|
+
} else {
|
|
40
|
+
localStorage.removeItem('user');
|
|
41
|
+
}
|
|
42
|
+
setUserState(userObj);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function logout() {
|
|
46
|
+
logoutUser(); // clears localStorage + best-effort server-side cleanup
|
|
47
|
+
localStorage.removeItem('access');
|
|
48
|
+
setAccessState(null);
|
|
49
|
+
setUserState(null);
|
|
50
|
+
setAuthenticated(false);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onLogin(result) {
|
|
54
|
+
// result = { accessToken, refreshToken, user, access } from login API
|
|
55
|
+
setAuthenticated(true);
|
|
56
|
+
setUser(result.user);
|
|
57
|
+
setAccess(result.access);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Access checkers
|
|
61
|
+
function hasPage(pageName) {
|
|
62
|
+
if (!access) return false;
|
|
63
|
+
return access.pages && access.pages.includes(pageName);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hasApi(apiName) {
|
|
67
|
+
if (!access) return false;
|
|
68
|
+
return access.apis && access.apis.includes(apiName);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasMenu(menuName) {
|
|
72
|
+
if (!access) return false;
|
|
73
|
+
return access.menus && access.menus.includes(menuName);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function hasElement(elementName) {
|
|
77
|
+
if (!access) return false;
|
|
78
|
+
return access.elements && access.elements.includes(elementName);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hasRole(roleName) {
|
|
82
|
+
if (!access) return false;
|
|
83
|
+
return access.roles && access.roles.includes(roleName);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const value = {
|
|
87
|
+
user,
|
|
88
|
+
access,
|
|
89
|
+
authenticated,
|
|
90
|
+
onLogin,
|
|
91
|
+
logout,
|
|
92
|
+
setAccess,
|
|
93
|
+
hasPage,
|
|
94
|
+
hasApi,
|
|
95
|
+
hasMenu,
|
|
96
|
+
hasElement,
|
|
97
|
+
hasRole
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<AccessContext.Provider value={value}>
|
|
102
|
+
{children}
|
|
103
|
+
</AccessContext.Provider>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Hook to access the auth/access context.
|
|
109
|
+
* Returns null if no AccessProvider is present (safe to call unconditionally).
|
|
110
|
+
*/
|
|
111
|
+
export function useAccess() {
|
|
112
|
+
return useContext(AccessContext);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Strict version — throws if no AccessProvider.
|
|
117
|
+
* Use in components that require access control (ProtectedRoute, AccessGuard).
|
|
118
|
+
*/
|
|
119
|
+
export function useAccessStrict() {
|
|
120
|
+
const ctx = useContext(AccessContext);
|
|
121
|
+
if (!ctx) {
|
|
122
|
+
throw new Error('useAccessStrict must be used within an <AccessProvider>');
|
|
123
|
+
}
|
|
124
|
+
return ctx;
|
|
125
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useAccessStrict } from './AccessContext.jsx';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AccessGuard — conditionally renders children based on access.
|
|
5
|
+
*
|
|
6
|
+
* Props:
|
|
7
|
+
* element - element name to check (from uiElements table)
|
|
8
|
+
* menu - menu name to check (from menus table)
|
|
9
|
+
* page - page name to check (from uiPages table)
|
|
10
|
+
* role - role name to check
|
|
11
|
+
* fallback - what to render if no access (default: null)
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* <AccessGuard element="btn-delete-user">
|
|
15
|
+
* <button onClick={handleDelete}>Delete User</button>
|
|
16
|
+
* </AccessGuard>
|
|
17
|
+
*
|
|
18
|
+
* <AccessGuard menu="admin-nav">
|
|
19
|
+
* <AdminNavigation />
|
|
20
|
+
* </AccessGuard>
|
|
21
|
+
*
|
|
22
|
+
* <AccessGuard role="admin" fallback={<span>No access</span>}>
|
|
23
|
+
* <AdminTools />
|
|
24
|
+
* </AccessGuard>
|
|
25
|
+
*/
|
|
26
|
+
export function AccessGuard({ children, element, menu, page, role, fallback = null }) {
|
|
27
|
+
const { hasElement, hasMenu, hasPage, hasRole } = useAccessStrict();
|
|
28
|
+
|
|
29
|
+
if (element && !hasElement(element)) return fallback;
|
|
30
|
+
if (menu && !hasMenu(menu)) return fallback;
|
|
31
|
+
if (page && !hasPage(page)) return fallback;
|
|
32
|
+
if (role && !hasRole(role)) return fallback;
|
|
33
|
+
|
|
34
|
+
return children;
|
|
35
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Navigate } from 'react-router-dom';
|
|
2
|
+
import { useAccessStrict } from './AccessContext.jsx';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ProtectedRoute — guards a route based on auth and access.
|
|
6
|
+
*
|
|
7
|
+
* Props:
|
|
8
|
+
* page - page name to check (from uiPages table). Optional.
|
|
9
|
+
* roles - array of role names, user must have at least one. Optional.
|
|
10
|
+
* loginPath - redirect path if not authenticated (default: /auth/login)
|
|
11
|
+
* deniedPath - redirect path if no access (default: /auth/login)
|
|
12
|
+
* children - the page component to render
|
|
13
|
+
*
|
|
14
|
+
* Behavior:
|
|
15
|
+
* - No token → redirect to loginPath
|
|
16
|
+
* - page prop set + user doesn't have that page → redirect to deniedPath
|
|
17
|
+
* - roles prop set + user doesn't have any of those roles → redirect to deniedPath
|
|
18
|
+
* - Otherwise → render children
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* // Any authenticated user
|
|
22
|
+
* <ProtectedRoute><Dashboard /></ProtectedRoute>
|
|
23
|
+
*
|
|
24
|
+
* // Must have access to this page (DB-driven)
|
|
25
|
+
* <ProtectedRoute page="/dashboard"><Dashboard /></ProtectedRoute>
|
|
26
|
+
*
|
|
27
|
+
* // Must have admin role
|
|
28
|
+
* <ProtectedRoute roles={['admin']}><AdminPanel /></ProtectedRoute>
|
|
29
|
+
*/
|
|
30
|
+
export function ProtectedRoute({
|
|
31
|
+
children,
|
|
32
|
+
page,
|
|
33
|
+
roles,
|
|
34
|
+
loginPath = '/auth/login',
|
|
35
|
+
deniedPath = '/auth/login'
|
|
36
|
+
}) {
|
|
37
|
+
const { authenticated, hasPage, hasRole } = useAccessStrict();
|
|
38
|
+
|
|
39
|
+
if (!authenticated) {
|
|
40
|
+
return <Navigate to={loginPath} replace />;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (page && !hasPage(page)) {
|
|
44
|
+
return <Navigate to={deniedPath} replace />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (roles && roles.length > 0) {
|
|
48
|
+
const hasAny = roles.some(r => hasRole(r));
|
|
49
|
+
if (!hasAny) {
|
|
50
|
+
return <Navigate to={deniedPath} replace />;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return children;
|
|
55
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
var STORAGE_KEY = 'xeplr-theme';
|
|
4
|
+
var DEFAULT_THEME = 'dark';
|
|
5
|
+
var BUILT_IN_THEMES = ['dark', 'light', 'medium', 'bright'];
|
|
6
|
+
|
|
7
|
+
var ThemeContext = createContext(null);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ThemeProvider — wraps your app to provide theme state.
|
|
11
|
+
*
|
|
12
|
+
* Props:
|
|
13
|
+
* theme — initial theme name (default: 'dark', or last saved)
|
|
14
|
+
* persist — save to localStorage (default: true)
|
|
15
|
+
* children — React children
|
|
16
|
+
*
|
|
17
|
+
* The provider applies a `xeplr-theme-{name}` class to a wrapper div.
|
|
18
|
+
* Any xeplr-ui component inside will pick up the theme via CSS variables.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* <ThemeProvider theme="dark">
|
|
22
|
+
* <App />
|
|
23
|
+
* </ThemeProvider>
|
|
24
|
+
*
|
|
25
|
+
* Custom themes:
|
|
26
|
+
* 1. Define `.xeplr-theme-corporate { --xeplr-bg-primary: ...; }` in your CSS
|
|
27
|
+
* 2. Use <ThemeProvider theme="corporate">
|
|
28
|
+
*/
|
|
29
|
+
export function ThemeProvider({ theme: initialTheme, persist = true, children }) {
|
|
30
|
+
var [theme, setThemeState] = useState(function() {
|
|
31
|
+
if (persist) {
|
|
32
|
+
try {
|
|
33
|
+
var saved = localStorage.getItem(STORAGE_KEY);
|
|
34
|
+
if (saved) return saved;
|
|
35
|
+
} catch (e) {}
|
|
36
|
+
}
|
|
37
|
+
return initialTheme || DEFAULT_THEME;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
var setTheme = useCallback(function(newTheme) {
|
|
41
|
+
setThemeState(newTheme);
|
|
42
|
+
if (persist) {
|
|
43
|
+
try { localStorage.setItem(STORAGE_KEY, newTheme); } catch (e) {}
|
|
44
|
+
}
|
|
45
|
+
}, [persist]);
|
|
46
|
+
|
|
47
|
+
var value = {
|
|
48
|
+
theme: theme,
|
|
49
|
+
setTheme: setTheme,
|
|
50
|
+
themes: BUILT_IN_THEMES,
|
|
51
|
+
className: 'xeplr-theme-' + theme,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<ThemeContext.Provider value={value}>
|
|
56
|
+
<div className={'xeplr-theme-' + theme}>
|
|
57
|
+
{children}
|
|
58
|
+
</div>
|
|
59
|
+
</ThemeContext.Provider>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* useTheme — access current theme and setter.
|
|
65
|
+
*
|
|
66
|
+
* Returns null if no ThemeProvider is wrapping the app.
|
|
67
|
+
* Use useThemeStrict() if you want it to throw.
|
|
68
|
+
*
|
|
69
|
+
* Returns: { theme, setTheme, themes, className }
|
|
70
|
+
*/
|
|
71
|
+
export function useTheme() {
|
|
72
|
+
return useContext(ThemeContext);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* useThemeStrict — throws if no ThemeProvider found.
|
|
77
|
+
*/
|
|
78
|
+
export function useThemeStrict() {
|
|
79
|
+
var ctx = useContext(ThemeContext);
|
|
80
|
+
if (!ctx) throw new Error('[xeplr] useThemeStrict() must be used inside <ThemeProvider>');
|
|
81
|
+
return ctx;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export { BUILT_IN_THEMES, DEFAULT_THEME };
|
package/src/adminApi.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { authFetch } from './api.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Admin API — model layer for RBAC management.
|
|
5
|
+
* Pure logic, no React dependency.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function getUsers() {
|
|
9
|
+
return authFetch('/auth/api/admin/users');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getRoles() {
|
|
13
|
+
return authFetch('/auth/api/admin/roles');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getAccessItems() {
|
|
17
|
+
return authFetch('/auth/api/admin/access-items');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function toggleUserRole({ userId, roleId, assign }) {
|
|
21
|
+
return authFetch('/auth/api/admin/user-role', {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
body: JSON.stringify({ userId, roleId, assign }),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toggleAccessRole({ type, itemId, roleId, assign }) {
|
|
28
|
+
return authFetch('/auth/api/admin/access-role', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
body: JSON.stringify({ type, itemId, roleId, assign }),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function toggleModuleRole({ module, action, roleId, assign }) {
|
|
35
|
+
return authFetch('/auth/api/admin/module-role', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
body: JSON.stringify({ module, action, roleId, assign }),
|
|
38
|
+
});
|
|
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
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { getToken, setToken, getRefreshToken, setRefreshToken, clearAuth } from './token.js';
|
|
2
|
+
|
|
3
|
+
let _baseUrl = '';
|
|
4
|
+
let _onSessionExpired = null;
|
|
5
|
+
let _refreshPromise = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configure the auth API base URL.
|
|
9
|
+
* @param {string} baseUrl - e.g. 'http://localhost:19001'
|
|
10
|
+
* @param {object} [options]
|
|
11
|
+
* @param {function} [options.onSessionExpired] - Called when refresh token also fails (full logout)
|
|
12
|
+
*/
|
|
13
|
+
export function configure(baseUrl, options = {}) {
|
|
14
|
+
_baseUrl = baseUrl;
|
|
15
|
+
_onSessionExpired = options.onSessionExpired || null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getBaseUrl() {
|
|
19
|
+
return _baseUrl || '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Refresh the access token using the stored refresh token.
|
|
24
|
+
* Returns true if refresh succeeded, false if session is dead.
|
|
25
|
+
* Deduplicates concurrent refresh calls.
|
|
26
|
+
*/
|
|
27
|
+
async function refreshAccessToken() {
|
|
28
|
+
// If a refresh is already in flight, wait for it
|
|
29
|
+
if (_refreshPromise) return _refreshPromise;
|
|
30
|
+
|
|
31
|
+
_refreshPromise = (async () => {
|
|
32
|
+
const refreshToken = getRefreshToken();
|
|
33
|
+
if (!refreshToken) return false;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(`${getBaseUrl()}/auth/api/refresh`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({ refreshToken }),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
clearAuth();
|
|
44
|
+
if (_onSessionExpired) _onSessionExpired();
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const data = await res.json();
|
|
49
|
+
setToken(data.accessToken);
|
|
50
|
+
setRefreshToken(data.refreshToken);
|
|
51
|
+
return true;
|
|
52
|
+
} catch (e) {
|
|
53
|
+
clearAuth();
|
|
54
|
+
if (_onSessionExpired) _onSessionExpired();
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
return await _refreshPromise;
|
|
61
|
+
} finally {
|
|
62
|
+
_refreshPromise = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Authenticated fetch with auto-refresh.
|
|
68
|
+
* Attaches Bearer token, retries once on 401 after refreshing.
|
|
69
|
+
*/
|
|
70
|
+
export async function authFetch(endpoint, options = {}) {
|
|
71
|
+
const url = endpoint.startsWith('http') ? endpoint : `${getBaseUrl()}${endpoint}`;
|
|
72
|
+
|
|
73
|
+
const headers = {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
...options.headers,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const token = getToken();
|
|
79
|
+
if (token) {
|
|
80
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Attach active tenant from session
|
|
84
|
+
try {
|
|
85
|
+
var tenantRaw = localStorage.getItem('xeplr:activeTenant');
|
|
86
|
+
if (tenantRaw) {
|
|
87
|
+
var tenant = JSON.parse(tenantRaw);
|
|
88
|
+
if (tenant && tenant.id) headers['X-Tenant-Id'] = tenant.id;
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {}
|
|
91
|
+
|
|
92
|
+
let res = await fetch(url, { ...options, headers });
|
|
93
|
+
|
|
94
|
+
// If 401, try refreshing the token and retry once
|
|
95
|
+
if (res.status === 401) {
|
|
96
|
+
const refreshed = await refreshAccessToken();
|
|
97
|
+
if (refreshed) {
|
|
98
|
+
headers['Authorization'] = `Bearer ${getToken()}`;
|
|
99
|
+
res = await fetch(url, { ...options, headers });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const data = await res.json();
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
throw new Error(data.error || 'Something went wrong');
|
|
106
|
+
}
|
|
107
|
+
return data;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Unauthenticated request (for login, register, etc.)
|
|
112
|
+
*/
|
|
113
|
+
async function request(endpoint, options = {}) {
|
|
114
|
+
const res = await fetch(`${getBaseUrl()}${endpoint}`, {
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
...options,
|
|
117
|
+
});
|
|
118
|
+
const data = await res.json();
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
throw new Error(data.error || 'Something went wrong');
|
|
121
|
+
}
|
|
122
|
+
return data;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function registerUser({ email, password, name, phoneNumber }) {
|
|
126
|
+
return request('/auth/api/register', {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
body: JSON.stringify({ email, password, name, phoneNumber }),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function activateAccount(token) {
|
|
133
|
+
return request('/auth/api/activate?token=' + encodeURIComponent(token));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function loginUser({ email, password }) {
|
|
137
|
+
return request('/auth/api/login', {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
body: JSON.stringify({ email, password }),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function forgotPassword({ email }) {
|
|
144
|
+
return request('/auth/api/forgot-password', {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
body: JSON.stringify({ email }),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function resetPassword({ token, newPassword }) {
|
|
151
|
+
return request('/auth/api/reset-password', {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
body: JSON.stringify({ token, newPassword }),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function changePassword({ currentPassword, newPassword }) {
|
|
158
|
+
return authFetch('/auth/api/change-password', {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
body: JSON.stringify({ currentPassword, newPassword }),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function getProfile() {
|
|
165
|
+
return authFetch('/auth/api/profile');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function updateProfile(fields) {
|
|
169
|
+
return authFetch('/auth/api/profile', {
|
|
170
|
+
method: 'PUT',
|
|
171
|
+
body: JSON.stringify(fields),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function getMyTenants() {
|
|
176
|
+
return authFetch('/auth/api/my-tenants');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function logoutUser() {
|
|
180
|
+
const refreshToken = getRefreshToken();
|
|
181
|
+
const accessToken = getToken();
|
|
182
|
+
|
|
183
|
+
clearAuth();
|
|
184
|
+
|
|
185
|
+
// Best-effort server-side cleanup
|
|
186
|
+
if (refreshToken) {
|
|
187
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
188
|
+
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
|
|
189
|
+
|
|
190
|
+
fetch(`${getBaseUrl()}/auth/api/logout`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers,
|
|
193
|
+
body: JSON.stringify({ refreshToken }),
|
|
194
|
+
}).catch(() => {});
|
|
195
|
+
}
|
|
196
|
+
}
|