@caseparts-org/casecore 0.0.3 → 0.0.4
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/dist/authentication/AuthContext.d.ts +33 -13
- package/dist/authentication/AuthContext.js +246 -15
- package/dist/authentication/AuthContext.test.js +211 -26
- package/dist/authentication/AuthTypes.d.ts +82 -0
- package/dist/authentication/AuthTypes.js +1 -0
- package/dist/hooks/useLocalStorage.d.ts +14 -0
- package/dist/hooks/useLocalStorage.js +41 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +6 -1
- package/dist/utils/ClaimsUtils.d.ts +16 -0
- package/dist/utils/ClaimsUtils.js +76 -0
- package/dist/utils/SessionUtils.d.ts +7 -0
- package/dist/utils/SessionUtils.js +18 -0
- package/package.json +1 -1
@@ -1,14 +1,34 @@
|
|
1
|
-
import React from "react";
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
export
|
9
|
-
|
10
|
-
|
1
|
+
import React, { ReactNode } from "react";
|
2
|
+
import { Claims } from "./AuthTypes";
|
3
|
+
export type AuthUrls = {
|
4
|
+
Login: string;
|
5
|
+
Logout: string;
|
6
|
+
Impersonate: (_email: string) => string;
|
7
|
+
};
|
8
|
+
export type AuthProviderProps = {
|
9
|
+
children: ReactNode;
|
10
|
+
urls: AuthUrls;
|
11
11
|
apiKey: string;
|
12
|
-
|
13
|
-
}
|
14
|
-
export
|
12
|
+
sessionClientId: string;
|
13
|
+
};
|
14
|
+
export type AuthContextType = {
|
15
|
+
initialized: string;
|
16
|
+
claims: Claims | null;
|
17
|
+
email: string;
|
18
|
+
discountLevel: string;
|
19
|
+
userType: string;
|
20
|
+
login: (_email?: string, _password?: string) => Promise<unknown>;
|
21
|
+
logout: () => Promise<boolean>;
|
22
|
+
impersonate: (_email: string) => Promise<string | null>;
|
23
|
+
fetch: (_url: string, _options?: RequestInit) => Promise<Response | null>;
|
24
|
+
hasRight: (_right: string) => boolean;
|
25
|
+
token: string | null;
|
26
|
+
sessionId: string;
|
27
|
+
};
|
28
|
+
export type Credentials = {
|
29
|
+
email: string;
|
30
|
+
password: string;
|
31
|
+
};
|
32
|
+
export declare const AuthContext: React.Context<AuthContextType | undefined>;
|
33
|
+
export declare const useAuthContext: () => AuthContextType | undefined;
|
34
|
+
export default function AuthProvider({ children, urls, apiKey, sessionClientId }: AuthProviderProps): import("react/jsx-runtime").JSX.Element;
|
@@ -1,21 +1,252 @@
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
2
|
-
import {
|
2
|
+
import React, { useContext, useState, useEffect, createContext } from "react";
|
3
|
+
import { jwtDecode } from "jwt-decode";
|
4
|
+
import useLocalStorage from "../hooks/useLocalStorage";
|
5
|
+
import { getSessionId } from '../utils/SessionUtils';
|
6
|
+
import { buildClaimsFromPayload } from "../utils/ClaimsUtils";
|
3
7
|
export const AuthContext = createContext(undefined);
|
4
8
|
export const useAuthContext = () => {
|
5
|
-
|
6
|
-
if (!ctx)
|
7
|
-
throw new Error("Must be inside AuthProvider");
|
8
|
-
return ctx;
|
9
|
+
return useContext(AuthContext);
|
9
10
|
};
|
10
|
-
export
|
11
|
-
const [
|
12
|
-
const [
|
11
|
+
export default function AuthProvider({ children, urls, apiKey, sessionClientId }) {
|
12
|
+
const [token, setToken] = useState(null);
|
13
|
+
const [initialized, setInitialized] = useState("");
|
14
|
+
const [claims, setClaims] = useState(null);
|
15
|
+
const [localToken, setLocalToken] = useLocalStorage("token", "");
|
16
|
+
const credentialsRef = React.useRef(null);
|
17
|
+
const sessionId = getSessionId(sessionClientId);
|
18
|
+
useEffect(() => {
|
19
|
+
if (token)
|
20
|
+
return;
|
21
|
+
// Logs in using local storage token if present or cookies. If neither exist, API will return a Guest token.
|
22
|
+
login()
|
23
|
+
.catch(e => setInitialized(e.message));
|
24
|
+
// eslint-disable-next-line
|
25
|
+
}, [token]); // Triggers login when setting token in `logout` to obtain a Guest token.
|
26
|
+
//#region Fetch extensions
|
27
|
+
class FetchError extends Error {
|
28
|
+
statusCode;
|
29
|
+
constructor(statusCode, message) {
|
30
|
+
super(message);
|
31
|
+
this.name = "FetchError";
|
32
|
+
this.statusCode = statusCode;
|
33
|
+
}
|
34
|
+
}
|
35
|
+
function defaultErrorMessage(status, url) {
|
36
|
+
switch (status) {
|
37
|
+
case 401: return "You are not authenticated";
|
38
|
+
case 403: return "You do not have permission to access this page";
|
39
|
+
case 404: return "Page does not exist: " + url;
|
40
|
+
default: return "Unexpected Error";
|
41
|
+
}
|
42
|
+
}
|
43
|
+
/**
|
44
|
+
* Performs a fetch request and throws a FetchError for HTTP errors (except 500).
|
45
|
+
*
|
46
|
+
* - Returns the response for successful requests (status < 400) and for status 500.
|
47
|
+
* - For other error statuses, reads the response text and throws a FetchError with a normalized message.
|
48
|
+
* - Handles network or parsing errors by rethrowing them.
|
49
|
+
*
|
50
|
+
* @param url - The URL to fetch.
|
51
|
+
* @param options - Optional fetch options (method, headers, body, etc).
|
52
|
+
* @returns {Promise<Response>} The fetch response if successful.
|
53
|
+
* @throws {FetchError} If the response status is an error (except 500).
|
54
|
+
*/
|
55
|
+
const fancyFetch = (url, options) => {
|
56
|
+
return fetch(url, options)
|
57
|
+
.then(async (res) => {
|
58
|
+
if (res.status < 400) {
|
59
|
+
return res;
|
60
|
+
}
|
61
|
+
else if (res.status === 500) {
|
62
|
+
return res;
|
63
|
+
}
|
64
|
+
else {
|
65
|
+
const text = await res.text();
|
66
|
+
const message = text ? String(text).replace(/['"]+/g, '')
|
67
|
+
: res.statusText ? res.statusText
|
68
|
+
: defaultErrorMessage(res.status, url);
|
69
|
+
throw new FetchError(res.status, message);
|
70
|
+
}
|
71
|
+
})
|
72
|
+
.catch(e => { throw e; });
|
73
|
+
};
|
74
|
+
/**
|
75
|
+
* Performs a fetch request with authentication and automatic retry on 401 errors.
|
76
|
+
*
|
77
|
+
* - Adds Authorization and X-Session-ID headers if available.
|
78
|
+
* - On a 401 response, attempts to re-authenticate by calling login(), updates the Authorization header, and retries the request once.
|
79
|
+
* - Throws for other errors or if re-authentication fails.
|
80
|
+
*
|
81
|
+
* @param url - The URL to fetch.
|
82
|
+
* @param options - Optional fetch options (method, headers, body, etc).
|
83
|
+
* @returns {Promise<Response>} The fetch response if successful.
|
84
|
+
* @throws {FetchError} If the request fails or re-authentication fails.
|
85
|
+
*/
|
86
|
+
const fancyFetchWithRetry = (url, options) => {
|
87
|
+
const opts = { ...options };
|
88
|
+
opts.method = opts.method || 'GET';
|
89
|
+
opts.headers = { ...(opts.headers || {}) };
|
90
|
+
if (token)
|
91
|
+
opts.headers.Authorization = "Bearer " + token;
|
92
|
+
opts.headers['X-Session-ID'] = sessionId;
|
93
|
+
opts.credentials = 'include';
|
94
|
+
return fancyFetch(url, opts)
|
95
|
+
.catch(e => {
|
96
|
+
if (e.statusCode === 401) {
|
97
|
+
return login()
|
98
|
+
.then(fetchTokenResult => {
|
99
|
+
opts.headers.Authorization = "Bearer " + fetchTokenResult.token;
|
100
|
+
})
|
101
|
+
.then(() => fancyFetch(url, opts))
|
102
|
+
.catch(loginError => { throw loginError; });
|
103
|
+
}
|
104
|
+
else {
|
105
|
+
throw e;
|
106
|
+
}
|
107
|
+
});
|
108
|
+
};
|
109
|
+
//#endregion
|
110
|
+
//#region Login/Logout with related helpers
|
111
|
+
/**
|
112
|
+
* Authenticates the user and returns a token/claims object.
|
113
|
+
*
|
114
|
+
* The login logic attempts authentication in the following order:
|
115
|
+
* 1. If running locally and no email is provided, but credentials are stored in memory, it uses those credentials to fetch a token.
|
116
|
+
* 2. If running locally and a JWT exists in local storage (and no email is provided), it uses that token and parses claims from it.
|
117
|
+
* 3. Otherwise, it calls fetchToken with the provided email and password (or undefined for guest login).
|
118
|
+
*
|
119
|
+
* This function is used both for explicit user login and for automatic login (e.g., after logout or on mount) to obtain a guest or user token as needed.
|
120
|
+
*
|
121
|
+
* @param email - Optional email/username for login. If omitted, attempts to use stored credentials or local storage.
|
122
|
+
* @param password - Optional password for login.
|
123
|
+
* @returns {Promise<FetchTokenResult>} A promise resolving to the token and claims.
|
124
|
+
*/
|
13
125
|
const login = (email, password) => {
|
14
|
-
//
|
15
|
-
|
16
|
-
|
126
|
+
// const localTokenJson = window.localStorage.getItem("CPC.token")
|
127
|
+
// const localToken = localTokenJson ? JSON.parse(localTokenJson) : null
|
128
|
+
if (isLocal() && !email && credentialsRef.current) { // Has logged in using credentials and stored in state
|
129
|
+
return fetchToken(credentialsRef.current.email, credentialsRef.current.password);
|
130
|
+
}
|
131
|
+
if (isLocal() && localToken && !email) { // Local storage jwt exists on localhost
|
132
|
+
const claims = assignToken(localToken);
|
133
|
+
return Promise.resolve({ token: localToken, claims });
|
134
|
+
}
|
135
|
+
return fetchToken(email, password);
|
17
136
|
};
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
137
|
+
/**
|
138
|
+
* Logs out the current user and resets authentication state.
|
139
|
+
*
|
140
|
+
* - Calls the logout API endpoint to invalidate the session on the server.
|
141
|
+
* - Clears any stored credentials in memory.
|
142
|
+
* - Sets the token to null, which triggers a re-authentication as a guest via useEffect.
|
143
|
+
* - Clears claims state.
|
144
|
+
*
|
145
|
+
* @returns {Promise<boolean>} Resolves to true when logout is complete.
|
146
|
+
*/
|
147
|
+
const logout = () => {
|
148
|
+
if (isLocal())
|
149
|
+
setLocalToken();
|
150
|
+
return fancyFetch(urls.Logout, { method: 'POST', credentials: 'include' })
|
151
|
+
.then(() => {
|
152
|
+
credentialsRef.current = null;
|
153
|
+
setToken(null); // Setting token null triggers useEffect to reauthenticate as an anonymous user
|
154
|
+
setClaims(null);
|
155
|
+
return true;
|
156
|
+
});
|
157
|
+
};
|
158
|
+
/**
|
159
|
+
* Impersonates another user by email, updating authentication state with the new user's claims.
|
160
|
+
*
|
161
|
+
* - Calls the impersonate API endpoint with the provided email.
|
162
|
+
* - Parses and assigns the returned JWT as the current authentication token and claims.
|
163
|
+
* - If running locally, stores the new JWT in local storage.
|
164
|
+
*
|
165
|
+
* @param email - The email address of the user to impersonate.
|
166
|
+
* @returns {Promise<string | null>} Resolves to the new JWT string if successful, or null otherwise.
|
167
|
+
*/
|
168
|
+
const impersonate = (email) => {
|
169
|
+
return fancyFetchWithRetry(urls.Impersonate(email))
|
170
|
+
.then(res => res ? res.text() : null)
|
171
|
+
.then(jwt => jwt ? jwt.replace(/['"]+/g, '') : null)
|
172
|
+
.then(jwt => {
|
173
|
+
assignToken(jwt);
|
174
|
+
if (isLocal() && jwt)
|
175
|
+
setLocalToken(jwt);
|
176
|
+
return jwt;
|
177
|
+
});
|
178
|
+
};
|
179
|
+
function isLocal() {
|
180
|
+
const hostname = window.location.hostname;
|
181
|
+
return hostname === "localhost" || hostname === "127.0.0.1";
|
182
|
+
}
|
183
|
+
const assignToken = (jwt) => {
|
184
|
+
if (!jwt) {
|
185
|
+
throw Error("Cannot parse null");
|
186
|
+
}
|
187
|
+
const decoded = jwtDecode(jwt);
|
188
|
+
const claims = buildClaimsFromPayload(decoded);
|
189
|
+
setInitialized("OK");
|
190
|
+
setToken(jwt);
|
191
|
+
setClaims(claims);
|
192
|
+
return claims;
|
193
|
+
};
|
194
|
+
async function fetchToken(email, password) {
|
195
|
+
const options = {
|
196
|
+
method: 'POST',
|
197
|
+
credentials: 'include',
|
198
|
+
headers: {
|
199
|
+
'Accept': 'application/json',
|
200
|
+
'Content-Type': 'application/json',
|
201
|
+
'ApiKey': apiKey
|
202
|
+
}
|
203
|
+
};
|
204
|
+
if (email) {
|
205
|
+
options.body = JSON.stringify({
|
206
|
+
UserName: email,
|
207
|
+
Password: password,
|
208
|
+
RememberMe: false
|
209
|
+
});
|
210
|
+
}
|
211
|
+
return fancyFetch(urls.Login, options)
|
212
|
+
.then(res => res ? res.text() : null)
|
213
|
+
.then(jwt => jwt ? jwt.replace(/['"]+/g, '') : null)
|
214
|
+
.then(jwt => {
|
215
|
+
const decoded = assignToken(jwt);
|
216
|
+
if (isLocal() && email && password) {
|
217
|
+
credentialsRef.current = { email, password };
|
218
|
+
}
|
219
|
+
if (isLocal() && decoded.UserType !== "Guest" && jwt) {
|
220
|
+
setLocalToken(jwt); // Store token in Local Storage
|
221
|
+
}
|
222
|
+
return { token: jwt, claims: decoded };
|
223
|
+
});
|
224
|
+
}
|
225
|
+
//#endregion
|
226
|
+
const hasRight = (right) => {
|
227
|
+
const claimsType = claims;
|
228
|
+
if (claimsType.UserType === "Guest")
|
229
|
+
return false;
|
230
|
+
if (claimsType.Customer && claimsType.Customer.Rights) {
|
231
|
+
return claimsType.Customer.Rights.includes(right);
|
232
|
+
}
|
233
|
+
if (claimsType.Employee && claimsType.Employee.Rights) {
|
234
|
+
return claimsType.Employee.Rights.includes(right);
|
235
|
+
}
|
236
|
+
return false;
|
237
|
+
};
|
238
|
+
return (_jsx(AuthContext.Provider, { value: {
|
239
|
+
initialized,
|
240
|
+
claims,
|
241
|
+
email: claims?.Customer?.UserName || claims?.Employee?.UserName || "",
|
242
|
+
discountLevel: claims?.Customer?.CustClass || "EndUser",
|
243
|
+
userType: claims?.UserType || "",
|
244
|
+
login,
|
245
|
+
logout,
|
246
|
+
impersonate,
|
247
|
+
fetch: fancyFetchWithRetry,
|
248
|
+
hasRight,
|
249
|
+
token,
|
250
|
+
sessionId
|
251
|
+
}, children: children }));
|
252
|
+
}
|
@@ -1,29 +1,214 @@
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
2
|
-
import { describe, it, expect } from 'vitest';
|
3
|
-
import { render, screen } from '@testing-library/react';
|
4
|
-
import {
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
3
|
+
import { render, screen, waitFor, act } from '@testing-library/react';
|
4
|
+
import { useAuthContext, default as AuthProvider } from './AuthContext';
|
5
|
+
import { buildClaimsFromPayload } from '../utils/ClaimsUtils';
|
6
|
+
// Dummy data for API responses
|
7
|
+
const dummyEmployee = {
|
8
|
+
userId: 'emp1',
|
9
|
+
userName: 'employee@example.com',
|
10
|
+
nickName: 'Emp',
|
11
|
+
branch: 'HQ',
|
12
|
+
rights: 'VIEW,EDIT',
|
13
|
+
};
|
14
|
+
const dummyCustomer = {
|
15
|
+
userName: 'customer@example.com',
|
16
|
+
userId: 'cust1',
|
17
|
+
custKey: 123,
|
18
|
+
custId: 'C123',
|
19
|
+
custName: 'Test Customer',
|
20
|
+
custClass: 'Dealer',
|
21
|
+
firstName: 'John',
|
22
|
+
lastName: 'Doe',
|
23
|
+
address: '123 Main St',
|
24
|
+
city: 'Metropolis',
|
25
|
+
state: 'NY',
|
26
|
+
zipCode: '10001',
|
27
|
+
phone: '555-1234',
|
28
|
+
hq: 'HQ',
|
29
|
+
hqCustKey: '456',
|
30
|
+
paymentTerms: 'Net30',
|
31
|
+
lastLoginDate: '2024-01-01',
|
32
|
+
rights: 'BUY,SELL',
|
33
|
+
pendingInvites: ['invite1'],
|
34
|
+
primaryAdmin: 'admin1',
|
35
|
+
};
|
36
|
+
const dummyJwtPayload = {
|
37
|
+
Time: 'now',
|
38
|
+
IpAddress: '127.0.0.1',
|
39
|
+
Origin: 'test',
|
40
|
+
UserType: 'Employee',
|
41
|
+
Customer: JSON.stringify(dummyCustomer),
|
42
|
+
Employee: JSON.stringify(dummyEmployee),
|
43
|
+
exp: 9999999999,
|
44
|
+
iss: 'issuer',
|
45
|
+
};
|
46
|
+
const dummyJwt = 'dummy.jwt.token';
|
47
|
+
const guestDefaultClaims = {
|
48
|
+
Time: 'now',
|
49
|
+
IpAddress: '127.0.0.1',
|
50
|
+
Origin: 'test',
|
51
|
+
UserType: "Guest",
|
52
|
+
Customer: null,
|
53
|
+
Employee: null,
|
54
|
+
exp: 9999999999,
|
55
|
+
iss: 'issuer',
|
56
|
+
};
|
57
|
+
const guestJwtPayload = guestDefaultClaims;
|
58
|
+
const guestJwt = 'guest.jwt.token';
|
59
|
+
// Mock jwtDecode to just return our dummy payload
|
60
|
+
vi.mock('jwt-decode', () => ({
|
61
|
+
jwtDecode: (jwt) => {
|
62
|
+
if (jwt === dummyJwt) {
|
63
|
+
return dummyJwtPayload;
|
64
|
+
}
|
65
|
+
else {
|
66
|
+
return guestJwtPayload;
|
25
67
|
}
|
26
|
-
|
27
|
-
|
68
|
+
}
|
69
|
+
}));
|
70
|
+
global.fetch = vi.fn();
|
71
|
+
const urls = {
|
72
|
+
Login: '/api/login',
|
73
|
+
Logout: '/api/logout',
|
74
|
+
Impersonate: (email) => `/api/impersonate/${email}`,
|
75
|
+
};
|
76
|
+
const apiKey = 'test-api-key';
|
77
|
+
const sessionClientId = 'test-session-id';
|
78
|
+
function TestConsumer() {
|
79
|
+
const ctx = useAuthContext();
|
80
|
+
if (!ctx)
|
81
|
+
return _jsx("div", { children: "no context" });
|
82
|
+
return (_jsxs("div", { children: [_jsx("div", { "data-testid": "initialized", children: ctx.initialized }), _jsx("div", { "data-testid": "email", children: ctx.email }), _jsx("div", { "data-testid": "userType", children: ctx.userType }), _jsx("div", { "data-testid": "claims", children: JSON.stringify(ctx.claims) }), _jsx("button", { onClick: () => ctx.login('test@example.com', 'pw'), children: "Login" }), _jsx("button", { onClick: () => ctx.logout(), children: "Logout" }), _jsx("button", { onClick: () => ctx.impersonate('imp@example.com'), children: "Impersonate" })] }));
|
83
|
+
}
|
84
|
+
describe('AuthProvider', () => {
|
85
|
+
// Setup mock API endpoints
|
86
|
+
beforeEach(() => {
|
87
|
+
vi.clearAllMocks();
|
88
|
+
// Default fetch mock: login returns a dummy JWT string
|
89
|
+
global.fetch.mockImplementation((url, opts) => {
|
90
|
+
if (url.includes('/api/login')) {
|
91
|
+
// If no body or body does not include UserName/Password, return guest JWT
|
92
|
+
if (!opts?.body) {
|
93
|
+
return Promise.resolve({
|
94
|
+
status: 200,
|
95
|
+
text: () => Promise.resolve('"' + guestJwt + '"'),
|
96
|
+
});
|
97
|
+
}
|
98
|
+
try {
|
99
|
+
const body = JSON.parse(opts.body);
|
100
|
+
if (!body.UserName || !body.Password) {
|
101
|
+
return Promise.resolve({
|
102
|
+
status: 200,
|
103
|
+
text: () => Promise.resolve('"' + guestJwt + '"'),
|
104
|
+
});
|
105
|
+
}
|
106
|
+
}
|
107
|
+
catch {
|
108
|
+
// fallback to guest
|
109
|
+
return Promise.resolve({
|
110
|
+
status: 200,
|
111
|
+
text: () => Promise.resolve('"' + guestJwt + '"'),
|
112
|
+
});
|
113
|
+
}
|
114
|
+
// Otherwise, return employee/customer JWT
|
115
|
+
return Promise.resolve({
|
116
|
+
status: 200,
|
117
|
+
text: () => Promise.resolve('"' + dummyJwt + '"'),
|
118
|
+
});
|
119
|
+
}
|
120
|
+
if (url.includes('/api/impersonate')) {
|
121
|
+
return Promise.resolve({
|
122
|
+
status: 200,
|
123
|
+
text: () => Promise.resolve('"' + dummyJwt + '"'),
|
124
|
+
});
|
125
|
+
}
|
126
|
+
if (url.includes('/api/logout')) {
|
127
|
+
return Promise.resolve({
|
128
|
+
status: 200,
|
129
|
+
text: () => Promise.resolve(''),
|
130
|
+
});
|
131
|
+
}
|
132
|
+
return Promise.resolve({ status: 404, text: () => Promise.resolve('Not found') });
|
133
|
+
});
|
134
|
+
// Clear localStorage
|
135
|
+
window.localStorage.clear();
|
136
|
+
});
|
137
|
+
afterEach(() => {
|
138
|
+
vi.clearAllMocks();
|
139
|
+
window.localStorage.clear();
|
140
|
+
});
|
141
|
+
// Tests
|
142
|
+
it('calls login on mount (useEffect) and sets state', async () => {
|
143
|
+
await act(async () => {
|
144
|
+
render(_jsx(AuthProvider, { urls: urls, apiKey: apiKey, sessionClientId: sessionClientId, children: _jsx(TestConsumer, {}) }));
|
145
|
+
});
|
146
|
+
await waitFor(() => {
|
147
|
+
expect(screen.getByTestId('initialized').textContent).toBe('OK');
|
148
|
+
expect(screen.getByTestId('email').textContent).toBe('');
|
149
|
+
expect(screen.getByTestId('userType').textContent).toBe('Guest');
|
150
|
+
expect(screen.getByTestId('claims').textContent).toContain('');
|
151
|
+
});
|
152
|
+
});
|
153
|
+
it('loads token from localStorage and sets state on mount', async () => {
|
154
|
+
// The key used by useLocalStorage (with CPC. prefix)
|
155
|
+
window.localStorage.setItem('CPC.token', JSON.stringify(dummyJwt));
|
156
|
+
await act(async () => {
|
157
|
+
render(_jsx(AuthProvider, { urls: urls, apiKey: apiKey, sessionClientId: sessionClientId, children: _jsx(TestConsumer, {}) }));
|
158
|
+
});
|
159
|
+
await waitFor(() => {
|
160
|
+
expect(screen.getByTestId('initialized').textContent).toBe('OK');
|
161
|
+
expect(screen.getByTestId('email').textContent).toBe('customer@example.com');
|
162
|
+
expect(screen.getByTestId('userType').textContent).toBe('Employee');
|
163
|
+
expect(screen.getByTestId('claims').textContent).toContain('customer@example.com');
|
164
|
+
});
|
165
|
+
});
|
166
|
+
it('login() sets state with new credentials', async () => {
|
167
|
+
render(_jsx(AuthProvider, { urls: urls, apiKey: apiKey, sessionClientId: sessionClientId, children: _jsx(TestConsumer, {}) }));
|
168
|
+
await act(async () => {
|
169
|
+
screen.getByText('Login').click();
|
170
|
+
});
|
171
|
+
await waitFor(() => {
|
172
|
+
expect(screen.getByTestId('initialized').textContent).toBe('OK');
|
173
|
+
expect(screen.getByTestId('email').textContent).toBe('customer@example.com');
|
174
|
+
});
|
175
|
+
});
|
176
|
+
it('impersonate() sets state with impersonated user', async () => {
|
177
|
+
render(_jsx(AuthProvider, { urls: urls, apiKey: apiKey, sessionClientId: sessionClientId, children: _jsx(TestConsumer, {}) }));
|
178
|
+
await act(async () => {
|
179
|
+
screen.getByText('Impersonate').click();
|
180
|
+
});
|
181
|
+
await waitFor(() => {
|
182
|
+
expect(screen.getByTestId('initialized').textContent).toBe('OK');
|
183
|
+
expect(screen.getByTestId('email').textContent).toBe('customer@example.com');
|
184
|
+
});
|
185
|
+
});
|
186
|
+
it('logout() resets state to guest', async () => {
|
187
|
+
render(_jsx(AuthProvider, { urls: urls, apiKey: apiKey, sessionClientId: sessionClientId, children: _jsx(TestConsumer, {}) }));
|
188
|
+
// First, login
|
189
|
+
await act(async () => {
|
190
|
+
screen.getByText('Login').click();
|
191
|
+
});
|
192
|
+
await waitFor(() => {
|
193
|
+
expect(screen.getByTestId('userType').textContent).toBe('Employee');
|
194
|
+
});
|
195
|
+
// Now, logout
|
196
|
+
await act(async () => {
|
197
|
+
screen.getByText('Logout').click();
|
198
|
+
});
|
199
|
+
await waitFor(async () => {
|
200
|
+
const userTypeDiv = await screen.findByTestId('userType');
|
201
|
+
expect(userTypeDiv.textContent).toBe('Guest');
|
202
|
+
});
|
203
|
+
});
|
204
|
+
});
|
205
|
+
describe('buildClaimsFromPayload', () => {
|
206
|
+
it('parses payload and normalizes claims', () => {
|
207
|
+
const claims = buildClaimsFromPayload(dummyJwtPayload);
|
208
|
+
expect(claims.Customer?.UserName).toBe('customer@example.com');
|
209
|
+
expect(claims.Employee?.UserName).toBe('employee@example.com');
|
210
|
+
expect(claims.UserType).toBe('Employee');
|
211
|
+
expect(Array.isArray(claims.Customer?.Rights)).toBe(true);
|
212
|
+
expect(Array.isArray(claims.Employee?.Rights)).toBe(true);
|
28
213
|
});
|
29
214
|
});
|
@@ -0,0 +1,82 @@
|
|
1
|
+
export type EmployeeResponse = {
|
2
|
+
userId: string;
|
3
|
+
userName: string;
|
4
|
+
nickName: string;
|
5
|
+
branch: string;
|
6
|
+
rights: string;
|
7
|
+
};
|
8
|
+
export type CustomerResponse = {
|
9
|
+
userName: string;
|
10
|
+
userId: string;
|
11
|
+
custKey: number;
|
12
|
+
custId: string;
|
13
|
+
custName: string;
|
14
|
+
custClass: string;
|
15
|
+
firstName: string;
|
16
|
+
lastName: string;
|
17
|
+
address: string;
|
18
|
+
city: string;
|
19
|
+
state: string;
|
20
|
+
zipCode: string;
|
21
|
+
phone: string;
|
22
|
+
hq: string;
|
23
|
+
hqCustKey: string;
|
24
|
+
paymentTerms: string;
|
25
|
+
lastLoginDate: string | null;
|
26
|
+
rights: string;
|
27
|
+
pendingInvites: Invite[];
|
28
|
+
primaryAdmin: string;
|
29
|
+
};
|
30
|
+
export type InviteType = "NewAcctPrimary" | "Standard" | "Primary" | "LinkedStandard" | "LinkedPrimary";
|
31
|
+
export type Invite = {
|
32
|
+
id: string;
|
33
|
+
senderEmail: string;
|
34
|
+
custKey: number;
|
35
|
+
custId: string;
|
36
|
+
custName: string;
|
37
|
+
targetEmail: string;
|
38
|
+
targetUserId: string;
|
39
|
+
inviteType: InviteType | string;
|
40
|
+
rights: string;
|
41
|
+
status: string;
|
42
|
+
createdDate: string;
|
43
|
+
};
|
44
|
+
export type EmployeeClaims = {
|
45
|
+
UserId: string;
|
46
|
+
UserName: string;
|
47
|
+
NickName: string;
|
48
|
+
Branch: string | null;
|
49
|
+
Rights: string[];
|
50
|
+
};
|
51
|
+
export type CustomerClaims = {
|
52
|
+
UserName: string;
|
53
|
+
UserId: string;
|
54
|
+
CustKey: number;
|
55
|
+
CustId: string;
|
56
|
+
CustName: string;
|
57
|
+
CustClass: string;
|
58
|
+
FirstName: string;
|
59
|
+
LastName: string;
|
60
|
+
Address: string;
|
61
|
+
City: string;
|
62
|
+
State: string;
|
63
|
+
ZipCode: string;
|
64
|
+
Phone: string;
|
65
|
+
HQ: string | null;
|
66
|
+
HQCustKey: number;
|
67
|
+
PaymentTerms: string;
|
68
|
+
LastLoginDate: string;
|
69
|
+
Rights: string[];
|
70
|
+
PendingInvites: unknown[];
|
71
|
+
PrimaryAdmin: unknown | null;
|
72
|
+
};
|
73
|
+
export type Claims = {
|
74
|
+
Time: string;
|
75
|
+
IpAddress: string;
|
76
|
+
Origin: string;
|
77
|
+
UserType: "Guest" | "Employee" | "LinkedCustomer" | string;
|
78
|
+
Customer: CustomerClaims | null;
|
79
|
+
Employee: EmployeeClaims | null;
|
80
|
+
exp: number;
|
81
|
+
iss: string;
|
82
|
+
};
|
@@ -0,0 +1 @@
|
|
1
|
+
export {};
|
@@ -0,0 +1,14 @@
|
|
1
|
+
/**
|
2
|
+
* React hook for managing a value in localStorage with a CPC-prefixed key.
|
3
|
+
*
|
4
|
+
* The key is automatically prefixed with "CPC." if not already present (case-insensitive).
|
5
|
+
*
|
6
|
+
* @template T The type of the value to store.
|
7
|
+
* @param {string} key - The key to use in localStorage. Will be prefixed with "CPC." if not already.
|
8
|
+
* @param {T} initialValue - The initial value to use if no value is found in localStorage.
|
9
|
+
* @returns {[T, (value?: T) => void]} A tuple containing the current value and a setter function.
|
10
|
+
*
|
11
|
+
* The setter function updates both state and localStorage. Passing `undefined` removes the item from localStorage.
|
12
|
+
* Passing `null` resets the value to the initial value.
|
13
|
+
*/
|
14
|
+
export default function useLocalStorage<T>(key: string, initialValue: T): [T, (_value?: T) => void];
|
@@ -0,0 +1,41 @@
|
|
1
|
+
import { useState } from "react";
|
2
|
+
function getPrefixedKey(key) {
|
3
|
+
return key.toLowerCase().startsWith("cpc.") ? key : "CPC." + key;
|
4
|
+
}
|
5
|
+
/**
|
6
|
+
* React hook for managing a value in localStorage with a CPC-prefixed key.
|
7
|
+
*
|
8
|
+
* The key is automatically prefixed with "CPC." if not already present (case-insensitive).
|
9
|
+
*
|
10
|
+
* @template T The type of the value to store.
|
11
|
+
* @param {string} key - The key to use in localStorage. Will be prefixed with "CPC." if not already.
|
12
|
+
* @param {T} initialValue - The initial value to use if no value is found in localStorage.
|
13
|
+
* @returns {[T, (value?: T) => void]} A tuple containing the current value and a setter function.
|
14
|
+
*
|
15
|
+
* The setter function updates both state and localStorage. Passing `undefined` removes the item from localStorage.
|
16
|
+
* Passing `null` resets the value to the initial value.
|
17
|
+
*/
|
18
|
+
export default function useLocalStorage(key, initialValue) {
|
19
|
+
const fullKey = getPrefixedKey(key);
|
20
|
+
const getStoredValue = () => {
|
21
|
+
try {
|
22
|
+
const item = window.localStorage.getItem(fullKey);
|
23
|
+
return item ? JSON.parse(item) : initialValue;
|
24
|
+
}
|
25
|
+
catch {
|
26
|
+
return initialValue;
|
27
|
+
}
|
28
|
+
};
|
29
|
+
const [storedValue, setStoredValue] = useState(getStoredValue);
|
30
|
+
const setValue = (value) => {
|
31
|
+
const denullified = value === null ? initialValue : value;
|
32
|
+
setStoredValue(denullified);
|
33
|
+
if (value === undefined) {
|
34
|
+
window.localStorage.removeItem(fullKey);
|
35
|
+
}
|
36
|
+
else {
|
37
|
+
window.localStorage.setItem(fullKey, JSON.stringify(denullified));
|
38
|
+
}
|
39
|
+
};
|
40
|
+
return [storedValue, setValue];
|
41
|
+
}
|
package/dist/index.d.ts
CHANGED
@@ -1,2 +1,6 @@
|
|
1
|
-
export type {
|
2
|
-
export {
|
1
|
+
export type { AuthUrls, AuthContextType, AuthProviderProps, Credentials } from './authentication/AuthContext';
|
2
|
+
export { default as AuthProvider, useAuthContext } from './authentication/AuthContext';
|
3
|
+
export type { EmployeeResponse, CustomerResponse, InviteType, Invite, EmployeeClaims, CustomerClaims, Claims } from './authentication/AuthTypes';
|
4
|
+
export { default as useLocalStorage } from './hooks/useLocalStorage';
|
5
|
+
export { getSessionId } from './utils/SessionUtils';
|
6
|
+
export { buildClaimsFromPayload } from './utils/ClaimsUtils';
|
package/dist/index.js
CHANGED
@@ -1 +1,6 @@
|
|
1
|
-
|
1
|
+
// Export AuthProvider as default and useAuthContext hook
|
2
|
+
export { default as AuthProvider, useAuthContext } from './authentication/AuthContext';
|
3
|
+
// Export utility hooks and functions
|
4
|
+
export { default as useLocalStorage } from './hooks/useLocalStorage';
|
5
|
+
export { getSessionId } from './utils/SessionUtils';
|
6
|
+
export { buildClaimsFromPayload } from './utils/ClaimsUtils';
|
@@ -0,0 +1,16 @@
|
|
1
|
+
import { Claims } from "../authentication/AuthTypes";
|
2
|
+
/**
|
3
|
+
* Converts a decoded JWT payload into a normalized Claims object for authentication.
|
4
|
+
*
|
5
|
+
* This function parses the nested Customer and Employee fields (if present) from the payload,
|
6
|
+
* normalizes their structure (including splitting comma-separated rights into arrays),
|
7
|
+
* and returns a Claims object suitable for use in authentication and authorization logic.
|
8
|
+
*
|
9
|
+
* - If the payload contains stringified JSON for Customer or Employee, they are parsed and normalized.
|
10
|
+
* - Rights fields are always returned as arrays of strings.
|
11
|
+
* - Handles missing or malformed fields gracefully, returning nulls or empty arrays as appropriate.
|
12
|
+
*
|
13
|
+
* @param payload - The decoded JWT payload as a plain object.
|
14
|
+
* @returns {Claims} The normalized Claims object with Customer and Employee claims, or nulls if not present.
|
15
|
+
*/
|
16
|
+
export declare function buildClaimsFromPayload(payload: Record<string, any>): Claims;
|
@@ -0,0 +1,76 @@
|
|
1
|
+
/**
|
2
|
+
* Converts a decoded JWT payload into a normalized Claims object for authentication.
|
3
|
+
*
|
4
|
+
* This function parses the nested Customer and Employee fields (if present) from the payload,
|
5
|
+
* normalizes their structure (including splitting comma-separated rights into arrays),
|
6
|
+
* and returns a Claims object suitable for use in authentication and authorization logic.
|
7
|
+
*
|
8
|
+
* - If the payload contains stringified JSON for Customer or Employee, they are parsed and normalized.
|
9
|
+
* - Rights fields are always returned as arrays of strings.
|
10
|
+
* - Handles missing or malformed fields gracefully, returning nulls or empty arrays as appropriate.
|
11
|
+
*
|
12
|
+
* @param payload - The decoded JWT payload as a plain object.
|
13
|
+
* @returns {Claims} The normalized Claims object with Customer and Employee claims, or nulls if not present.
|
14
|
+
*/
|
15
|
+
export function buildClaimsFromPayload(payload) {
|
16
|
+
// Parse nested Customer and Employee using API response types
|
17
|
+
function parseNested(obj, name) {
|
18
|
+
if (obj && typeof obj === "object" && typeof obj[name] === "string" && obj[name].length > 0) {
|
19
|
+
return JSON.parse(obj[name]);
|
20
|
+
}
|
21
|
+
return "";
|
22
|
+
}
|
23
|
+
const customerRaw = parseNested(payload, "Customer");
|
24
|
+
const employeeRaw = parseNested(payload, "Employee");
|
25
|
+
// Convert EmployeeResponse to EmployeeClaims (normalize Rights)
|
26
|
+
let employeeClaims = null;
|
27
|
+
if (employeeRaw && typeof employeeRaw === "object") {
|
28
|
+
employeeClaims = {
|
29
|
+
UserId: employeeRaw.userId,
|
30
|
+
UserName: employeeRaw.userName,
|
31
|
+
NickName: employeeRaw.nickName,
|
32
|
+
Branch: employeeRaw.branch ?? null,
|
33
|
+
Rights: typeof employeeRaw.rights === "string"
|
34
|
+
? employeeRaw.rights.split(",").map((s) => s.trim())
|
35
|
+
: [],
|
36
|
+
};
|
37
|
+
}
|
38
|
+
// Convert CustomerResponse to CustomerClaims (normalize Rights, PendingInvites, etc.)
|
39
|
+
let customerClaims = null;
|
40
|
+
if (customerRaw && typeof customerRaw === "object") {
|
41
|
+
customerClaims = {
|
42
|
+
UserName: customerRaw.userName,
|
43
|
+
UserId: customerRaw.userId,
|
44
|
+
CustKey: customerRaw.custKey,
|
45
|
+
CustId: customerRaw.custId,
|
46
|
+
CustName: customerRaw.custName,
|
47
|
+
CustClass: customerRaw.custClass,
|
48
|
+
FirstName: customerRaw.firstName,
|
49
|
+
LastName: customerRaw.lastName,
|
50
|
+
Address: customerRaw.address,
|
51
|
+
City: customerRaw.city,
|
52
|
+
State: customerRaw.state,
|
53
|
+
ZipCode: customerRaw.zipCode,
|
54
|
+
Phone: customerRaw.phone,
|
55
|
+
HQ: customerRaw.hq ?? null,
|
56
|
+
HQCustKey: typeof customerRaw.hqCustKey === "string" ? parseInt(customerRaw.hqCustKey, 10) : customerRaw.hqCustKey,
|
57
|
+
PaymentTerms: customerRaw.paymentTerms,
|
58
|
+
LastLoginDate: customerRaw.lastLoginDate ?? "",
|
59
|
+
Rights: typeof customerRaw.rights === "string"
|
60
|
+
? customerRaw.rights.split(",").map((s) => s.trim())
|
61
|
+
: [],
|
62
|
+
PendingInvites: customerRaw.pendingInvites ?? [],
|
63
|
+
PrimaryAdmin: customerRaw.primaryAdmin ?? null,
|
64
|
+
};
|
65
|
+
}
|
66
|
+
return {
|
67
|
+
Time: String(payload.Time ?? ""),
|
68
|
+
IpAddress: String(payload.IpAddress ?? ""),
|
69
|
+
Origin: String(payload.Origin ?? ""),
|
70
|
+
UserType: String(payload.UserType ?? "Guest"),
|
71
|
+
Customer: customerClaims,
|
72
|
+
Employee: employeeClaims,
|
73
|
+
exp: Number(payload.exp ?? 0),
|
74
|
+
iss: String(payload.iss ?? ""),
|
75
|
+
};
|
76
|
+
}
|
@@ -0,0 +1,7 @@
|
|
1
|
+
/**
|
2
|
+
* Gets or creates a session ID stored in sessionStorage under a CPC-prefixed key.
|
3
|
+
*
|
4
|
+
* @param {string} clientId - The identifier to inject into the session key (e.g., 'CatBaker2').
|
5
|
+
* @returns {string} The session ID.
|
6
|
+
*/
|
7
|
+
export declare const getSessionId: (clientId: string) => string;
|
@@ -0,0 +1,18 @@
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
2
|
+
/**
|
3
|
+
* Gets or creates a session ID stored in sessionStorage under a CPC-prefixed key.
|
4
|
+
*
|
5
|
+
* @param {string} clientId - The identifier to inject into the session key (e.g., 'CatBaker2').
|
6
|
+
* @returns {string} The session ID.
|
7
|
+
*/
|
8
|
+
export const getSessionId = (clientId) => {
|
9
|
+
const SESSION_ID_KEY = `CPC.${clientId}.SessionId`;
|
10
|
+
// Check if we already have a session ID
|
11
|
+
let sessionId = sessionStorage.getItem(SESSION_ID_KEY);
|
12
|
+
// If not, generate a new one and store it
|
13
|
+
if (!sessionId) {
|
14
|
+
sessionId = uuidv4();
|
15
|
+
sessionStorage.setItem(SESSION_ID_KEY, sessionId);
|
16
|
+
}
|
17
|
+
return sessionId;
|
18
|
+
};
|