@snapdragonsnursery/react-components 1.0.4 → 1.0.6

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.
Files changed (3) hide show
  1. package/README.md +444 -30
  2. package/package.json +2 -1
  3. package/src/AuthButtons.jsx +113 -39
package/README.md CHANGED
@@ -12,52 +12,466 @@ Reusable React components for Snapdragons projects.
12
12
  Install via npm (local path or published package):
13
13
 
14
14
  ```
15
- npm install /path/to/react-components
15
+ npm install @snapdragonsnursery/react-components
16
16
  ```
17
17
 
18
- Or, if published:
18
+ ## Required Dependencies
19
19
 
20
+ Your project must have the following installed:
21
+
22
+ ### Core Dependencies
23
+
24
+ ```bash
25
+ npm install react react-dom
26
+ npm install @azure/msal-browser @azure/msal-react
27
+ npm install @headlessui/react @heroicons/react
20
28
  ```
21
- npm install snaps-react-components
29
+
30
+ ### CSS Framework (Required for AuthButtons styling)
31
+
32
+ ```bash
33
+ npm install -D tailwindcss postcss autoprefixer
34
+ npx tailwindcss init -p
22
35
  ```
23
36
 
24
- ## Peer Dependencies
37
+ ## Complete Setup Guide for New Projects
25
38
 
26
- Your project must have the following installed:
27
- - `react` (>=18)
28
- - `react-dom` (>=18)
29
- - `@azure/msal-react` (>=1.0.0)
39
+ ### 1. Install Dependencies
30
40
 
31
- ## Usage
41
+ ```bash
42
+ # Core authentication dependencies
43
+ npm install @azure/msal-browser @azure/msal-react
44
+
45
+ # UI dependencies for AuthButtons
46
+ npm install @headlessui/react @heroicons/react
47
+
48
+ # Tailwind CSS (required for styling)
49
+ npm install -D tailwindcss postcss autoprefixer
50
+ npx tailwindcss init -p
51
+
52
+ # Install this component library
53
+ npm install @snapdragonsnursery/react-components
54
+ ```
32
55
 
33
- ```jsx
34
- import { AuthButtons, ThemeToggle } from 'react-components';
56
+ ### 2. Configure Tailwind CSS
35
57
 
36
- // Example usage in your header:
37
- <AuthButtons
38
- theme={theme}
39
- setTheme={setTheme}
40
- authState={authState}
41
- setAuthState={setAuthState}
42
- />
58
+ Update `tailwind.config.js`:
43
59
 
44
- // Standalone theme toggle:
45
- <ThemeToggle theme={theme} setTheme={setTheme} />
60
+ ```javascript
61
+ /** @type {import('tailwindcss').Config} */
62
+ export default {
63
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
64
+ darkMode: "class",
65
+ theme: {
66
+ extend: {},
67
+ },
68
+ plugins: [],
69
+ };
46
70
  ```
47
71
 
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.
72
+ Update `src/index.css`:
73
+
74
+ ```css
75
+ @tailwind base;
76
+ @tailwind components;
77
+ @tailwind utilities;
78
+
79
+ /* Your custom styles */
80
+ body {
81
+ margin: 0;
82
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
83
+ "Oxygen", "Ubuntu", "Cantarell", "Open Sans", "Helvetica Neue", sans-serif;
84
+ -webkit-font-smoothing: antialiased;
85
+ -moz-osx-font-smoothing: grayscale;
86
+ }
87
+
88
+ #root {
89
+ min-height: 100vh;
90
+ }
91
+ ```
92
+
93
+ ### 3. Create Authentication Configuration
94
+
95
+ Create `src/utils/authConfig.js`:
96
+
97
+ ```javascript
98
+ import { LogLevel } from "@azure/msal-browser";
99
+
100
+ export const msalConfig = {
101
+ auth: {
102
+ clientId: "2e2d9456-a0b7-4651-a8f8-c979ed1486b0", // Snapdragons Azure AD App
103
+ authority:
104
+ "https://login.microsoftonline.com/d098ee48-9874-4959-8229-da24c99c36fb",
105
+ redirectUri: window.location.origin,
106
+ postLogoutRedirectUri: window.location.origin,
107
+ navigateToLoginRequestUrl: false,
108
+ },
109
+ cache: {
110
+ cacheLocation: "sessionStorage",
111
+ storeAuthStateInCookie: false,
112
+ },
113
+ system: {
114
+ loggerOptions: {
115
+ loggerCallback: (level, message, containsPii) => {
116
+ if (containsPii) return;
117
+ switch (level) {
118
+ case LogLevel.Error:
119
+ console.error(message);
120
+ return;
121
+ case LogLevel.Warning:
122
+ console.warn(message);
123
+ return;
124
+ default:
125
+ return;
126
+ }
127
+ },
128
+ logLevel: LogLevel.Warning,
129
+ },
130
+ },
131
+ };
132
+
133
+ export const loginRequest = {
134
+ scopes: ["User.Read", "email", "profile", "Directory.Read.All"],
135
+ };
136
+
137
+ export const adminConfig = {
138
+ adminEmailDomains: [],
139
+ adminEmails: [
140
+ "james@snapdragonsnursery.com",
141
+ "accounts@snapdragonsnursery.com",
142
+ ],
143
+ adminGroupIds: [],
144
+ adminRoles: ["FundingAdmin", "Admin"],
145
+ };
146
+
147
+ export const isUserAdmin = (account, idTokenClaims = null) => {
148
+ if (!account) return false;
149
+ const claims = idTokenClaims || account.idTokenClaims;
150
+ const email = account.username || claims?.email || claims?.preferred_username;
151
+
152
+ if (
153
+ email &&
154
+ adminConfig.adminEmailDomains.some((domain) =>
155
+ email.toLowerCase().endsWith(`@${domain.toLowerCase()}`)
156
+ )
157
+ ) {
158
+ return true;
159
+ }
160
+
161
+ if (
162
+ email &&
163
+ adminConfig.adminEmails.some(
164
+ (adminEmail) => email.toLowerCase() === adminEmail.toLowerCase()
165
+ )
166
+ ) {
167
+ return true;
168
+ }
169
+
170
+ if (claims?.groups && adminConfig.adminGroupIds.length > 0) {
171
+ const userGroups = Array.isArray(claims.groups)
172
+ ? claims.groups
173
+ : [claims.groups];
174
+ if (
175
+ userGroups.some((groupId) => adminConfig.adminGroupIds.includes(groupId))
176
+ ) {
177
+ return true;
178
+ }
179
+ }
180
+
181
+ if (claims?.roles && adminConfig.adminRoles.length > 0) {
182
+ const userRoles = Array.isArray(claims.roles)
183
+ ? claims.roles
184
+ : [claims.roles];
185
+ if (userRoles.some((role) => adminConfig.adminRoles.includes(role))) {
186
+ return true;
187
+ }
188
+ }
189
+
190
+ return false;
191
+ };
192
+ ```
193
+
194
+ ### 4. Create Authentication Provider
195
+
196
+ Create `src/utils/AuthProvider.jsx`:
197
+
198
+ ```javascript
199
+ import React, { createContext, useContext, useEffect, useState } from "react";
200
+ import { useMsal, useAccount } from "@azure/msal-react";
201
+ import { isUserAdmin } from "./authConfig";
202
+
203
+ const AuthContext = createContext();
204
+
205
+ export const useAuth = () => {
206
+ const context = useContext(AuthContext);
207
+ if (!context) {
208
+ throw new Error("useAuth must be used within an AuthProvider");
209
+ }
210
+ return context;
211
+ };
212
+
213
+ export const AuthProvider = ({ children }) => {
214
+ const { instance, accounts } = useMsal();
215
+ const account = useAccount(accounts[0] || {});
216
+ const [isLoading, setIsLoading] = useState(true);
217
+ const [userRole, setUserRole] = useState(null);
218
+
219
+ useEffect(() => {
220
+ const checkUserRole = async () => {
221
+ if (account) {
222
+ const adminStatus = isUserAdmin(account);
223
+ setUserRole(adminStatus ? "admin" : "employee");
224
+ } else {
225
+ setUserRole(null);
226
+ }
227
+ setIsLoading(false);
228
+ };
229
+ checkUserRole();
230
+ }, [account]);
231
+
232
+ const login = async () => {
233
+ try {
234
+ await instance.loginPopup();
235
+ } catch (error) {
236
+ console.error("Login failed:", error);
237
+ throw error;
238
+ }
239
+ };
240
+
241
+ const logout = async () => {
242
+ try {
243
+ await instance.logoutPopup();
244
+ } catch (error) {
245
+ console.error("Logout failed:", error);
246
+ throw error;
247
+ }
248
+ };
249
+
250
+ const isAuthenticated = !!account;
251
+ const isAdmin = userRole === "admin";
252
+
253
+ const value = {
254
+ account,
255
+ isAuthenticated,
256
+ isAdmin,
257
+ userRole,
258
+ isLoading,
259
+ login,
260
+ logout,
261
+ instance,
262
+ };
263
+
264
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
265
+ };
266
+ ```
50
267
 
51
- ## Telemetry
268
+ ### 5. Create Theme Provider
52
269
 
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`.
270
+ Create `src/utils/theme.jsx`:
54
271
 
55
- ## Development
272
+ ```javascript
273
+ import { createContext, useContext, useEffect, useState } from "react";
56
274
 
57
- - Components are in `src/`.
58
- - Exported via `src/index.js`.
59
- - Update or add new components as needed.
275
+ const ThemeContext = createContext();
60
276
 
61
- ---
277
+ export function ThemeProvider({ children }) {
278
+ const [theme, setTheme] = useState("system");
279
+
280
+ useEffect(() => {
281
+ const savedTheme = localStorage.getItem("theme");
282
+ if (savedTheme && ["light", "dark", "system"].includes(savedTheme)) {
283
+ setTheme(savedTheme);
284
+ } else {
285
+ setTheme("system");
286
+ }
287
+ }, []);
288
+
289
+ useEffect(() => {
290
+ const applyTheme = () => {
291
+ if (theme === "system") {
292
+ const systemPrefersDark = window.matchMedia(
293
+ "(prefers-color-scheme: dark)"
294
+ ).matches;
295
+ if (systemPrefersDark) {
296
+ document.documentElement.classList.add("dark");
297
+ } else {
298
+ document.documentElement.classList.remove("dark");
299
+ }
300
+ } else if (theme === "dark") {
301
+ document.documentElement.classList.add("dark");
302
+ } else {
303
+ document.documentElement.classList.remove("dark");
304
+ }
305
+ };
306
+
307
+ applyTheme();
308
+ localStorage.setItem("theme", theme);
309
+ }, [theme]);
310
+
311
+ useEffect(() => {
312
+ if (theme !== "system") return;
313
+
314
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
315
+ const handleChange = () => {
316
+ if (theme === "system") {
317
+ if (mediaQuery.matches) {
318
+ document.documentElement.classList.add("dark");
319
+ } else {
320
+ document.documentElement.classList.remove("dark");
321
+ }
322
+ }
323
+ };
324
+
325
+ mediaQuery.addEventListener("change", handleChange);
326
+ return () => mediaQuery.removeEventListener("change", handleChange);
327
+ }, [theme]);
328
+
329
+ return (
330
+ <ThemeContext.Provider value={{ theme, setTheme }}>
331
+ {children}
332
+ </ThemeContext.Provider>
333
+ );
334
+ }
335
+
336
+ export function useTheme() {
337
+ const context = useContext(ThemeContext);
338
+ if (context === undefined) {
339
+ throw new Error("useTheme must be used within a ThemeProvider");
340
+ }
341
+ return context;
342
+ }
343
+ ```
344
+
345
+ ### 6. Setup Main Entry Point
346
+
347
+ Update `src/main.jsx`:
348
+
349
+ ```javascript
350
+ import React from "react";
351
+ import ReactDOM from "react-dom/client";
352
+ import App from "./App.jsx";
353
+ import "./index.css";
354
+ import { MsalProvider } from "@azure/msal-react";
355
+ import { PublicClientApplication } from "@azure/msal-browser";
356
+ import { msalConfig } from "./utils/authConfig";
357
+ import { AuthProvider } from "./utils/AuthProvider";
358
+ import { ThemeProvider } from "./utils/theme";
359
+
360
+ const msalInstance = new PublicClientApplication(msalConfig);
361
+
362
+ ReactDOM.createRoot(document.getElementById("root")).render(
363
+ <React.StrictMode>
364
+ <MsalProvider instance={msalInstance}>
365
+ <AuthProvider>
366
+ <ThemeProvider>
367
+ <App />
368
+ </ThemeProvider>
369
+ </AuthProvider>
370
+ </MsalProvider>
371
+ </React.StrictMode>
372
+ );
373
+ ```
374
+
375
+ ### 7. Create Navbar Component
376
+
377
+ Create `src/components/Navbar.jsx`:
378
+
379
+ ```javascript
380
+ import React, { useState, useEffect } from "react";
381
+ import { AuthButtons } from "@snapdragonsnursery/react-components";
382
+ import { useAuth } from "../utils/AuthProvider";
383
+ import { useTheme } from "../utils/theme";
384
+
385
+ const Navbar = () => {
386
+ const { account, userRole, instance } = useAuth();
387
+ const { theme, setTheme } = useTheme();
388
+ const [authState, setAuthState] = useState({
389
+ isAuthenticated: !!account,
390
+ accountsCount: account ? 1 : 0,
391
+ account,
392
+ instance,
393
+ });
394
+
395
+ useEffect(() => {
396
+ setAuthState({
397
+ isAuthenticated: !!account,
398
+ accountsCount: account ? 1 : 0,
399
+ account,
400
+ instance,
401
+ });
402
+ }, [account, instance]);
403
+
404
+ return (
405
+ <nav className="bg-white dark:bg-gray-800 shadow-md sticky top-0 z-40 px-4 py-2 flex justify-between items-center">
406
+ <div className="flex items-center space-x-4">
407
+ <span className="font-bold text-lg text-gray-800 dark:text-gray-100">
408
+ Your App Name
409
+ </span>
410
+ </div>
411
+ <div className="flex items-center space-x-4">
412
+ <AuthButtons
413
+ theme={theme}
414
+ setTheme={setTheme}
415
+ authState={authState}
416
+ setAuthState={setAuthState}
417
+ />
418
+ </div>
419
+ </nav>
420
+ );
421
+ };
422
+
423
+ export default Navbar;
424
+ ```
425
+
426
+ ### 8. Use in Your App
427
+
428
+ Update `src/App.jsx`:
429
+
430
+ ```javascript
431
+ import Navbar from "./components/Navbar";
432
+
433
+ function App() {
434
+ return (
435
+ <>
436
+ <Navbar />
437
+ {/* Your app content */}
438
+ </>
439
+ );
440
+ }
441
+
442
+ export default App;
443
+ ```
444
+
445
+ ## Usage
446
+
447
+ Once setup is complete, the AuthButtons component will:
448
+
449
+ - Show a user icon when not authenticated (clickable to login)
450
+ - Show user profile picture or initials when authenticated
451
+ - Provide a dropdown menu with theme toggle and logout option
452
+ - Handle all authentication flows automatically
453
+
454
+ ## Troubleshooting
455
+
456
+ ### Common Issues
457
+
458
+ 1. **Blank icon**: Ensure Tailwind CSS is properly configured and included in your build
459
+ 2. **Authentication errors**: Check that all MSAL dependencies are installed and providers are set up correctly
460
+ 3. **Theme not working**: Ensure ThemeProvider is wrapping your app and `darkMode: 'class'` is set in Tailwind config
461
+
462
+ ### Required File Structure
463
+
464
+ ```
465
+ src/
466
+ ├── utils/
467
+ │ ├── authConfig.js
468
+ │ ├── AuthProvider.jsx
469
+ │ └── theme.jsx
470
+ ├── components/
471
+ │ └── Navbar.jsx
472
+ ├── main.jsx
473
+ ├── App.jsx
474
+ └── index.css
475
+ ```
62
476
 
63
- For questions or contributions, contact the Snapdragons development team.
477
+ For questions or contributions, contact the Snapdragons development team.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snapdragonsnursery/react-components",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -20,6 +20,7 @@
20
20
  "homepage": "https://github.com/Snapdragons-Nursery/react-components#readme",
21
21
  "dependencies": {
22
22
  "@headlessui/react": "^2.2.4",
23
+ "@popperjs/core": "^2.11.8",
23
24
  "react": "^18.3.1",
24
25
  "react-dom": "^18.3.1"
25
26
  },
@@ -1,3 +1,20 @@
1
+ // AuthButtons.jsx
2
+ //
3
+ // Authentication/profile button with dropdown menu for user actions and theme toggle.
4
+ // Uses Popper.js for smart dropdown positioning to prevent overflow.
5
+ //
6
+ // Example usage:
7
+ // import AuthButtons from './AuthButtons';
8
+ // <AuthButtons theme={theme} setTheme={setTheme} authState={authState} setAuthState={setAuthState} />
9
+ //
10
+ // Props:
11
+ // - theme: current theme string
12
+ // - setTheme: function to update theme
13
+ // - authState: authentication state object
14
+ // - setAuthState: function to update auth state
15
+ //
16
+ // The dropdown menu will always remain within the viewport, even if the button is near the edge.
17
+
1
18
  import { useEffect, useState, useRef } from "react";
2
19
  import { useMsal, useIsAuthenticated } from "@azure/msal-react";
3
20
  import {
@@ -7,7 +24,7 @@ import {
7
24
  clearUserContext,
8
25
  } from "./telemetry";
9
26
  import ThemeToggle from "./ThemeToggle";
10
- import { Menu } from "@headlessui/react";
27
+ import { createPopper } from "@popperjs/core";
11
28
 
12
29
  const isMobile = window.innerWidth < 640;
13
30
 
@@ -198,6 +215,59 @@ function AuthButtons({ theme, setTheme, authState, setAuthState }) {
198
215
  const displayName =
199
216
  authState.account?.name ?? authState.account?.idTokenClaims?.name ?? "User";
200
217
 
218
+ // Popper.js refs and logic
219
+ const buttonRef = useRef(null);
220
+ const menuRef = useRef(null);
221
+ const popperInstance = useRef(null);
222
+
223
+ // Show/hide state for menu
224
+ const [menuOpen, setMenuOpen] = useState(false);
225
+
226
+ // Setup Popper when menu is open
227
+ useEffect(() => {
228
+ if (menuOpen && buttonRef.current && menuRef.current) {
229
+ popperInstance.current = createPopper(
230
+ buttonRef.current,
231
+ menuRef.current,
232
+ {
233
+ placement: "bottom-end",
234
+ modifiers: [
235
+ { name: "preventOverflow", options: { boundary: "viewport" } },
236
+ {
237
+ name: "flip",
238
+ options: {
239
+ fallbackPlacements: ["bottom-start", "top-end", "top-start"],
240
+ },
241
+ },
242
+ ],
243
+ }
244
+ );
245
+ }
246
+ return () => {
247
+ if (popperInstance.current) {
248
+ popperInstance.current.destroy();
249
+ popperInstance.current = null;
250
+ }
251
+ };
252
+ }, [menuOpen]);
253
+
254
+ // Close menu on outside click
255
+ useEffect(() => {
256
+ if (!menuOpen) return;
257
+ function handleClick(e) {
258
+ if (
259
+ menuRef.current &&
260
+ !menuRef.current.contains(e.target) &&
261
+ buttonRef.current &&
262
+ !buttonRef.current.contains(e.target)
263
+ ) {
264
+ setMenuOpen(false);
265
+ }
266
+ }
267
+ document.addEventListener("mousedown", handleClick);
268
+ return () => document.removeEventListener("mousedown", handleClick);
269
+ }, [menuOpen]);
270
+
201
271
  if (!authState.account) {
202
272
  return (
203
273
  <div className="flex items-center gap-2">
@@ -235,9 +305,13 @@ function AuthButtons({ theme, setTheme, authState, setAuthState }) {
235
305
  }
236
306
 
237
307
  return (
238
- <Menu as="div" className="relative inline-block text-left">
308
+ <div className="relative inline-block text-left">
239
309
  <div>
240
- <Menu.Button className="focus:outline-none">
310
+ <button
311
+ ref={buttonRef}
312
+ className="focus:outline-none"
313
+ onClick={() => setMenuOpen((open) => !open)}
314
+ >
241
315
  {profilePicture ? (
242
316
  <img
243
317
  src={profilePicture}
@@ -256,44 +330,44 @@ function AuthButtons({ theme, setTheme, authState, setAuthState }) {
256
330
  </span>
257
331
  </div>
258
332
  )}
259
- </Menu.Button>
333
+ </button>
260
334
  </div>
261
- <Menu.Items 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 focus:outline-none">
262
- <div className="px-4 py-2 text-xs text-gray-500 dark:text-gray-300 border-b border-gray-100 dark:border-gray-700">
263
- Signed in as <span className="font-semibold">{displayName}</span>
264
- </div>
265
- <div className="px-4 py-2">
266
- <ThemeToggle theme={theme} setTheme={setTheme} />
335
+ {menuOpen && (
336
+ <div
337
+ ref={menuRef}
338
+ className="z-50 mt-2 min-w-[220px] max-w-xs w-auto bg-white dark:bg-gray-800 rounded-lg shadow-lg py-2 border border-gray-200 dark:border-gray-700 focus:outline-none"
339
+ style={{ position: "absolute" }}
340
+ >
341
+ <div className="px-4 py-2 text-xs text-gray-500 dark:text-gray-300 border-b border-gray-100 dark:border-gray-700">
342
+ Signed in as <span className="font-semibold">{displayName}</span>
343
+ </div>
344
+ <div className="px-4 py-2">
345
+ <ThemeToggle theme={theme} setTheme={setTheme} />
346
+ </div>
347
+ <button
348
+ className="flex items-center w-full px-4 py-2 text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
349
+ onClick={handleLogout}
350
+ >
351
+ <span className="mr-2">
352
+ <svg
353
+ className="w-5 h-5"
354
+ fill="none"
355
+ stroke="currentColor"
356
+ viewBox="0 0 24 24"
357
+ >
358
+ <path
359
+ strokeLinecap="round"
360
+ strokeLinejoin="round"
361
+ strokeWidth={2}
362
+ 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"
363
+ />
364
+ </svg>
365
+ </span>
366
+ Logout
367
+ </button>
267
368
  </div>
268
- <Menu.Item>
269
- {({ active }) => (
270
- <button
271
- className={`flex items-center w-full px-4 py-2 text-sm transition-colors ${
272
- active ? "bg-gray-100 dark:bg-gray-700" : ""
273
- }`}
274
- onClick={handleLogout}
275
- >
276
- <span className="mr-2">
277
- <svg
278
- className="w-5 h-5"
279
- fill="none"
280
- stroke="currentColor"
281
- viewBox="0 0 24 24"
282
- >
283
- <path
284
- strokeLinecap="round"
285
- strokeLinejoin="round"
286
- strokeWidth={2}
287
- 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"
288
- />
289
- </svg>
290
- </span>
291
- Logout
292
- </button>
293
- )}
294
- </Menu.Item>
295
- </Menu.Items>
296
- </Menu>
369
+ )}
370
+ </div>
297
371
  );
298
372
  }
299
373