@snapdragonsnursery/react-components 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/README.md +63 -0
- package/package.json +34 -0
- package/src/AuthButtons.jsx +320 -0
- package/src/ChildSearchDemo.jsx +478 -0
- package/src/ChildSearchModal.jsx +787 -0
- package/src/ThemeToggle.jsx +83 -0
- package/src/index.js +4 -0
- package/src/telemetry.js +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# react-components
|
|
2
|
+
|
|
3
|
+
Reusable React components for Snapdragons projects.
|
|
4
|
+
|
|
5
|
+
## Components
|
|
6
|
+
|
|
7
|
+
- **AuthButtons**: Profile button with authentication, user image, dropdown menu (sign out, dark mode), and login/logout handling for MSAL (Azure AD).
|
|
8
|
+
- **ThemeToggle**: Simple theme (light/dark/system) toggle button.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Install via npm (local path or published package):
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
npm install /path/to/react-components
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or, if published:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
npm install snaps-react-components
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Peer Dependencies
|
|
25
|
+
|
|
26
|
+
Your project must have the following installed:
|
|
27
|
+
- `react` (>=18)
|
|
28
|
+
- `react-dom` (>=18)
|
|
29
|
+
- `@azure/msal-react` (>=1.0.0)
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```jsx
|
|
34
|
+
import { AuthButtons, ThemeToggle } from 'react-components';
|
|
35
|
+
|
|
36
|
+
// Example usage in your header:
|
|
37
|
+
<AuthButtons
|
|
38
|
+
theme={theme}
|
|
39
|
+
setTheme={setTheme}
|
|
40
|
+
authState={authState}
|
|
41
|
+
setAuthState={setAuthState}
|
|
42
|
+
/>
|
|
43
|
+
|
|
44
|
+
// Standalone theme toggle:
|
|
45
|
+
<ThemeToggle theme={theme} setTheme={setTheme} />
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `theme` and `setTheme` should be managed by your app (e.g., via context or state).
|
|
49
|
+
- `authState` and `setAuthState` should be managed by your app, typically using MSAL hooks.
|
|
50
|
+
|
|
51
|
+
## Telemetry
|
|
52
|
+
|
|
53
|
+
This package includes stub telemetry functions by default. You can replace or extend these in your app as needed by providing your own implementations in place of `src/telemetry.js`.
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
- Components are in `src/`.
|
|
58
|
+
- Exported via `src/index.js`.
|
|
59
|
+
- Update or add new components as needed.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
For questions or contributions, contact the Snapdragons development team.
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@snapdragonsnursery/react-components",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Snapdragons-Nursery/react-components.git"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [],
|
|
14
|
+
"author": "",
|
|
15
|
+
"license": "ISC",
|
|
16
|
+
"type": "commonjs",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/Snapdragons-Nursery/react-components/issues"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/Snapdragons-Nursery/react-components#readme",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"react": "^19.1.0",
|
|
23
|
+
"react-dom": "^19.1.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": ">=18.0.0",
|
|
27
|
+
"react-dom": ">=18.0.0",
|
|
28
|
+
"@azure/msal-react": ">=1.0.0"
|
|
29
|
+
},
|
|
30
|
+
"module": "src/index.js",
|
|
31
|
+
"files": [
|
|
32
|
+
"src"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { useEffect, useState, useRef } from "react";
|
|
2
|
+
import { useMsal, useIsAuthenticated } from "@azure/msal-react";
|
|
3
|
+
import {
|
|
4
|
+
trackAuthEvent,
|
|
5
|
+
trackError,
|
|
6
|
+
setUserContext,
|
|
7
|
+
clearUserContext,
|
|
8
|
+
} from "./telemetry";
|
|
9
|
+
import ThemeToggle from "./ThemeToggle";
|
|
10
|
+
|
|
11
|
+
const isMobile = window.innerWidth < 640;
|
|
12
|
+
|
|
13
|
+
function AuthButtons({ theme, setTheme, authState, setAuthState }) {
|
|
14
|
+
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
|
15
|
+
const [profilePicture, setProfilePicture] = useState(null);
|
|
16
|
+
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
|
17
|
+
const menuRef = useRef();
|
|
18
|
+
const isLocalhost = window.location.hostname === "localhost";
|
|
19
|
+
|
|
20
|
+
// Use MSAL hooks - parent app should ensure MSAL is initialized
|
|
21
|
+
const { instance, accounts } = useMsal();
|
|
22
|
+
const isAuthenticated = useIsAuthenticated();
|
|
23
|
+
|
|
24
|
+
// Close profile menu when clicking outside
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const handleClickOutside = (event) => {
|
|
27
|
+
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
|
28
|
+
setShowProfileMenu(false);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
33
|
+
return () => {
|
|
34
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
// Update auth state when MSAL state changes
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (instance) {
|
|
41
|
+
const account = accounts[0] || instance.getActiveAccount();
|
|
42
|
+
const newAuthState = {
|
|
43
|
+
isAuthenticated,
|
|
44
|
+
accountsCount: accounts.length,
|
|
45
|
+
account,
|
|
46
|
+
instance,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
setAuthState(newAuthState);
|
|
50
|
+
|
|
51
|
+
// Track authentication state changes
|
|
52
|
+
if (isAuthenticated && account) {
|
|
53
|
+
trackAuthEvent("login_success", true);
|
|
54
|
+
setUserContext(true, "authenticated");
|
|
55
|
+
} else if (!isAuthenticated) {
|
|
56
|
+
trackAuthEvent("not_authenticated", false);
|
|
57
|
+
setUserContext(false, "anonymous");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}, [instance, accounts, isAuthenticated, setAuthState]);
|
|
61
|
+
|
|
62
|
+
// Fetch profile picture when user is authenticated
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const fetchProfilePicture = async () => {
|
|
65
|
+
if (!authState.account || !authState.instance) return;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
// Get access token for Microsoft Graph
|
|
69
|
+
const response = await authState.instance.acquireTokenSilent({
|
|
70
|
+
scopes: ["User.Read", "User.Read.All"],
|
|
71
|
+
account: authState.account,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Fetch user's profile picture
|
|
75
|
+
const photoResponse = await fetch(
|
|
76
|
+
"https://graph.microsoft.com/v1.0/me/photo/$value",
|
|
77
|
+
{
|
|
78
|
+
headers: {
|
|
79
|
+
Authorization: `Bearer ${response.accessToken}`,
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (photoResponse.ok) {
|
|
85
|
+
const blob = await photoResponse.blob();
|
|
86
|
+
const imageUrl = URL.createObjectURL(blob);
|
|
87
|
+
setProfilePicture(imageUrl);
|
|
88
|
+
} else {
|
|
89
|
+
// If no profile picture, clear any existing one
|
|
90
|
+
setProfilePicture(null);
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error("Failed to fetch profile picture:", error);
|
|
94
|
+
setProfilePicture(null);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (authState.isAuthenticated && authState.account) {
|
|
99
|
+
fetchProfilePicture();
|
|
100
|
+
} else {
|
|
101
|
+
setProfilePicture(null);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Cleanup function to revoke object URL when component unmounts or dependencies change
|
|
105
|
+
return () => {
|
|
106
|
+
if (profilePicture) {
|
|
107
|
+
URL.revokeObjectURL(profilePicture);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}, [
|
|
111
|
+
authState.isAuthenticated,
|
|
112
|
+
authState.account,
|
|
113
|
+
authState.instance,
|
|
114
|
+
profilePicture,
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
// Debug logging - only log on state changes
|
|
118
|
+
const prevAuthState = useRef();
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (
|
|
121
|
+
isLocalhost &&
|
|
122
|
+
JSON.stringify(prevAuthState.current) !== JSON.stringify(authState)
|
|
123
|
+
) {
|
|
124
|
+
console.log("Auth state changed:", authState);
|
|
125
|
+
prevAuthState.current = authState;
|
|
126
|
+
}
|
|
127
|
+
}, [authState, isLocalhost]);
|
|
128
|
+
|
|
129
|
+
const handleLogin = async () => {
|
|
130
|
+
if (isLoggingIn || !instance) return;
|
|
131
|
+
|
|
132
|
+
console.log("Attempting login...");
|
|
133
|
+
setIsLoggingIn(true);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
// Track login attempt
|
|
137
|
+
trackAuthEvent("login_attempt", false);
|
|
138
|
+
|
|
139
|
+
// Check if we're in an iframe and break out first
|
|
140
|
+
if (window !== window.top) {
|
|
141
|
+
console.log("Breaking out of iframe before login");
|
|
142
|
+
trackAuthEvent("iframe_detected", false);
|
|
143
|
+
window.top.location.href = window.location.href;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const loginRequest = {
|
|
148
|
+
scopes: ["User.Read", "User.Read.All"],
|
|
149
|
+
prompt: "select_account", // Force account selection
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
console.log("Starting redirect login with request:", loginRequest);
|
|
153
|
+
|
|
154
|
+
// Use redirect flow only - no fallback to popup
|
|
155
|
+
await instance.loginRedirect(loginRequest);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error("Login failed:", error);
|
|
158
|
+
console.error("Error details:", {
|
|
159
|
+
name: error.name,
|
|
160
|
+
errorCode: error.errorCode,
|
|
161
|
+
message: error.message,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Track login failure
|
|
165
|
+
trackAuthEvent("login_failed", false);
|
|
166
|
+
trackError(error, { context: "login_attempt" });
|
|
167
|
+
|
|
168
|
+
setIsLoggingIn(false);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const handleLogout = async () => {
|
|
173
|
+
if (!instance) return;
|
|
174
|
+
|
|
175
|
+
console.log("Attempting logout...");
|
|
176
|
+
try {
|
|
177
|
+
// Track logout
|
|
178
|
+
trackAuthEvent("logout", true);
|
|
179
|
+
clearUserContext();
|
|
180
|
+
|
|
181
|
+
// Clear all accounts from MSAL cache
|
|
182
|
+
instance.clearCache();
|
|
183
|
+
|
|
184
|
+
// Clear any stored tokens from localStorage/sessionStorage
|
|
185
|
+
const msalKeys = Object.keys(localStorage).filter((key) =>
|
|
186
|
+
key.startsWith("msal.")
|
|
187
|
+
);
|
|
188
|
+
msalKeys.forEach((key) => localStorage.removeItem(key));
|
|
189
|
+
|
|
190
|
+
const msalSessionKeys = Object.keys(sessionStorage).filter((key) =>
|
|
191
|
+
key.startsWith("msal.")
|
|
192
|
+
);
|
|
193
|
+
msalSessionKeys.forEach((key) => sessionStorage.removeItem(key));
|
|
194
|
+
|
|
195
|
+
// Force a re-render by updating the auth state
|
|
196
|
+
setAuthState({
|
|
197
|
+
isAuthenticated: false,
|
|
198
|
+
accountsCount: 0,
|
|
199
|
+
account: null,
|
|
200
|
+
instance: null,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Clear profile picture
|
|
204
|
+
setProfilePicture(null);
|
|
205
|
+
|
|
206
|
+
console.log("Local logout completed - user remains on site");
|
|
207
|
+
} catch (error) {
|
|
208
|
+
console.error("Logout failed:", error);
|
|
209
|
+
trackError(error, { context: "logout" });
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const displayName =
|
|
214
|
+
authState.account?.name ?? authState.account?.idTokenClaims?.name ?? "User";
|
|
215
|
+
|
|
216
|
+
if (!authState.account) {
|
|
217
|
+
return (
|
|
218
|
+
<div className="flex items-center gap-2">
|
|
219
|
+
{isLocalhost && (
|
|
220
|
+
<span className="hidden sm:block text-xs text-red-500">
|
|
221
|
+
Not authenticated
|
|
222
|
+
</span>
|
|
223
|
+
)}
|
|
224
|
+
<button
|
|
225
|
+
onClick={handleLogin}
|
|
226
|
+
disabled={isLoggingIn}
|
|
227
|
+
className={`rounded-full p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-gray-300 dark:border-gray-600 ${
|
|
228
|
+
isLoggingIn
|
|
229
|
+
? "bg-gray-100 dark:bg-gray-700"
|
|
230
|
+
: "bg-white dark:bg-gray-800"
|
|
231
|
+
}`}
|
|
232
|
+
title={isLoggingIn ? "Signing in..." : "Employee Login"}
|
|
233
|
+
>
|
|
234
|
+
<svg
|
|
235
|
+
className="w-6 h-6"
|
|
236
|
+
fill="none"
|
|
237
|
+
stroke="currentColor"
|
|
238
|
+
viewBox="0 0 24 24"
|
|
239
|
+
>
|
|
240
|
+
<path
|
|
241
|
+
strokeLinecap="round"
|
|
242
|
+
strokeLinejoin="round"
|
|
243
|
+
strokeWidth={2}
|
|
244
|
+
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
|
245
|
+
/>
|
|
246
|
+
</svg>
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div className="relative">
|
|
254
|
+
{/* Profile picture as trigger */}
|
|
255
|
+
{profilePicture ? (
|
|
256
|
+
<img
|
|
257
|
+
src={profilePicture}
|
|
258
|
+
alt={`${displayName}'s profile picture`}
|
|
259
|
+
className="w-8 h-8 rounded-full object-cover border-2 border-blue-500 cursor-pointer"
|
|
260
|
+
onClick={() => setShowProfileMenu((v) => !v)}
|
|
261
|
+
/>
|
|
262
|
+
) : (
|
|
263
|
+
<div
|
|
264
|
+
className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center cursor-pointer border-2 border-blue-500"
|
|
265
|
+
onClick={() => setShowProfileMenu((v) => !v)}
|
|
266
|
+
>
|
|
267
|
+
<span className="text-white text-sm font-medium">
|
|
268
|
+
{displayName
|
|
269
|
+
.split(" ")
|
|
270
|
+
.map((n) => n[0])
|
|
271
|
+
.join("")
|
|
272
|
+
.toUpperCase()
|
|
273
|
+
.slice(0, 2)}
|
|
274
|
+
</span>
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
{showProfileMenu && (
|
|
278
|
+
<div
|
|
279
|
+
ref={menuRef}
|
|
280
|
+
className="absolute right-0 mt-2 min-w-[220px] max-w-xs w-auto bg-white dark:bg-gray-800 rounded-lg shadow-lg z-50 py-2 border border-gray-200 dark:border-gray-700"
|
|
281
|
+
>
|
|
282
|
+
<div className="px-4 py-2 text-xs text-gray-500 dark:text-gray-300 border-b border-gray-100 dark:border-gray-700">
|
|
283
|
+
Signed in as <span className="font-semibold">{displayName}</span>
|
|
284
|
+
</div>
|
|
285
|
+
{/* Theme toggle inside dropdown */}
|
|
286
|
+
<div className="px-4 py-2">
|
|
287
|
+
<ThemeToggle theme={theme} setTheme={setTheme} />
|
|
288
|
+
</div>
|
|
289
|
+
<button
|
|
290
|
+
className="flex items-center w-full px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
291
|
+
onClick={() => {
|
|
292
|
+
setShowProfileMenu(false);
|
|
293
|
+
handleLogout();
|
|
294
|
+
}}
|
|
295
|
+
>
|
|
296
|
+
<span className="mr-2">
|
|
297
|
+
{/* Logout icon */}
|
|
298
|
+
<svg
|
|
299
|
+
className="w-5 h-5"
|
|
300
|
+
fill="none"
|
|
301
|
+
stroke="currentColor"
|
|
302
|
+
viewBox="0 0 24 24"
|
|
303
|
+
>
|
|
304
|
+
<path
|
|
305
|
+
strokeLinecap="round"
|
|
306
|
+
strokeLinejoin="round"
|
|
307
|
+
strokeWidth={2}
|
|
308
|
+
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
|
309
|
+
/>
|
|
310
|
+
</svg>
|
|
311
|
+
</span>
|
|
312
|
+
Logout
|
|
313
|
+
</button>
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export default AuthButtons;
|