@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 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;