@snapdragonsnursery/react-components 1.0.2 → 1.0.5
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 +444 -30
- package/package.json +3 -1
- package/src/AuthButtons.jsx +104 -48
- package/src/ThemeToggleTest.jsx +37 -0
- package/src/index.js +1 -0
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 /
|
|
15
|
+
npm install @snapdragonsnursery/react-components
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
37
|
+
## Complete Setup Guide for New Projects
|
|
25
38
|
|
|
26
|
-
|
|
27
|
-
- `react` (>=18)
|
|
28
|
-
- `react-dom` (>=18)
|
|
29
|
-
- `@azure/msal-react` (>=1.0.0)
|
|
39
|
+
### 1. Install Dependencies
|
|
30
40
|
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
import { AuthButtons, ThemeToggle } from 'react-components';
|
|
56
|
+
### 2. Configure Tailwind CSS
|
|
35
57
|
|
|
36
|
-
|
|
37
|
-
<AuthButtons
|
|
38
|
-
theme={theme}
|
|
39
|
-
setTheme={setTheme}
|
|
40
|
-
authState={authState}
|
|
41
|
-
setAuthState={setAuthState}
|
|
42
|
-
/>
|
|
58
|
+
Update `tailwind.config.js`:
|
|
43
59
|
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
268
|
+
### 5. Create Theme Provider
|
|
52
269
|
|
|
53
|
-
|
|
270
|
+
Create `src/utils/theme.jsx`:
|
|
54
271
|
|
|
55
|
-
|
|
272
|
+
```javascript
|
|
273
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
56
274
|
|
|
57
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"homepage": "https://github.com/Snapdragons-Nursery/react-components#readme",
|
|
21
21
|
"dependencies": {
|
|
22
|
+
"@headlessui/react": "^2.2.4",
|
|
23
|
+
"@popperjs/core": "^2.11.8",
|
|
22
24
|
"react": "^18.3.1",
|
|
23
25
|
"react-dom": "^18.3.1"
|
|
24
26
|
},
|
package/src/AuthButtons.jsx
CHANGED
|
@@ -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,34 +24,20 @@ import {
|
|
|
7
24
|
clearUserContext,
|
|
8
25
|
} from "./telemetry";
|
|
9
26
|
import ThemeToggle from "./ThemeToggle";
|
|
27
|
+
import { Menu } from "@headlessui/react";
|
|
28
|
+
import { createPopper } from "@popperjs/core";
|
|
10
29
|
|
|
11
30
|
const isMobile = window.innerWidth < 640;
|
|
12
31
|
|
|
13
32
|
function AuthButtons({ theme, setTheme, authState, setAuthState }) {
|
|
14
33
|
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
|
15
34
|
const [profilePicture, setProfilePicture] = useState(null);
|
|
16
|
-
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
|
17
|
-
const menuRef = useRef();
|
|
18
35
|
const isLocalhost = window.location.hostname === "localhost";
|
|
19
36
|
|
|
20
37
|
// Use MSAL hooks - parent app should ensure MSAL is initialized
|
|
21
38
|
const { instance, accounts } = useMsal();
|
|
22
39
|
const isAuthenticated = useIsAuthenticated();
|
|
23
40
|
|
|
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
41
|
// Update auth state when MSAL state changes
|
|
39
42
|
useEffect(() => {
|
|
40
43
|
if (instance) {
|
|
@@ -213,6 +216,59 @@ function AuthButtons({ theme, setTheme, authState, setAuthState }) {
|
|
|
213
216
|
const displayName =
|
|
214
217
|
authState.account?.name ?? authState.account?.idTokenClaims?.name ?? "User";
|
|
215
218
|
|
|
219
|
+
// Popper.js refs and logic
|
|
220
|
+
const buttonRef = useRef(null);
|
|
221
|
+
const menuRef = useRef(null);
|
|
222
|
+
const popperInstance = useRef(null);
|
|
223
|
+
|
|
224
|
+
// Show/hide state for menu
|
|
225
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
226
|
+
|
|
227
|
+
// Setup Popper when menu is open
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
if (menuOpen && buttonRef.current && menuRef.current) {
|
|
230
|
+
popperInstance.current = createPopper(
|
|
231
|
+
buttonRef.current,
|
|
232
|
+
menuRef.current,
|
|
233
|
+
{
|
|
234
|
+
placement: "bottom-end",
|
|
235
|
+
modifiers: [
|
|
236
|
+
{ name: "preventOverflow", options: { boundary: "viewport" } },
|
|
237
|
+
{
|
|
238
|
+
name: "flip",
|
|
239
|
+
options: {
|
|
240
|
+
fallbackPlacements: ["bottom-start", "top-end", "top-start"],
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
}
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return () => {
|
|
248
|
+
if (popperInstance.current) {
|
|
249
|
+
popperInstance.current.destroy();
|
|
250
|
+
popperInstance.current = null;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}, [menuOpen]);
|
|
254
|
+
|
|
255
|
+
// Close menu on outside click
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (!menuOpen) return;
|
|
258
|
+
function handleClick(e) {
|
|
259
|
+
if (
|
|
260
|
+
menuRef.current &&
|
|
261
|
+
!menuRef.current.contains(e.target) &&
|
|
262
|
+
buttonRef.current &&
|
|
263
|
+
!buttonRef.current.contains(e.target)
|
|
264
|
+
) {
|
|
265
|
+
setMenuOpen(false);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
document.addEventListener("mousedown", handleClick);
|
|
269
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
270
|
+
}, [menuOpen]);
|
|
271
|
+
|
|
216
272
|
if (!authState.account) {
|
|
217
273
|
return (
|
|
218
274
|
<div className="flex items-center gap-2">
|
|
@@ -250,51 +306,51 @@ function AuthButtons({ theme, setTheme, authState, setAuthState }) {
|
|
|
250
306
|
}
|
|
251
307
|
|
|
252
308
|
return (
|
|
253
|
-
<div className="relative">
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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)}
|
|
309
|
+
<div className="relative inline-block text-left">
|
|
310
|
+
<div>
|
|
311
|
+
<Menu.Button
|
|
312
|
+
as="div"
|
|
313
|
+
ref={buttonRef}
|
|
314
|
+
className="focus:outline-none"
|
|
315
|
+
onClick={() => setMenuOpen((open) => !open)}
|
|
266
316
|
>
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
317
|
+
{profilePicture ? (
|
|
318
|
+
<img
|
|
319
|
+
src={profilePicture}
|
|
320
|
+
alt={`${displayName}'s profile picture`}
|
|
321
|
+
className="w-8 h-8 rounded-full object-cover border-2 border-blue-500 cursor-pointer"
|
|
322
|
+
/>
|
|
323
|
+
) : (
|
|
324
|
+
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center cursor-pointer border-2 border-blue-500">
|
|
325
|
+
<span className="text-white text-sm font-medium">
|
|
326
|
+
{displayName
|
|
327
|
+
.split(" ")
|
|
328
|
+
.map((n) => n[0])
|
|
329
|
+
.join("")
|
|
330
|
+
.toUpperCase()
|
|
331
|
+
.slice(0, 2)}
|
|
332
|
+
</span>
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
</Menu.Button>
|
|
336
|
+
</div>
|
|
337
|
+
{menuOpen && (
|
|
278
338
|
<div
|
|
279
339
|
ref={menuRef}
|
|
280
|
-
className="
|
|
340
|
+
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"
|
|
341
|
+
style={{ position: "absolute" }}
|
|
281
342
|
>
|
|
282
343
|
<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
344
|
Signed in as <span className="font-semibold">{displayName}</span>
|
|
284
345
|
</div>
|
|
285
|
-
{/* Theme toggle inside dropdown */}
|
|
286
346
|
<div className="px-4 py-2">
|
|
287
347
|
<ThemeToggle theme={theme} setTheme={setTheme} />
|
|
288
348
|
</div>
|
|
289
349
|
<button
|
|
290
|
-
className="flex items-center w-full px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700
|
|
291
|
-
onClick={
|
|
292
|
-
setShowProfileMenu(false);
|
|
293
|
-
handleLogout();
|
|
294
|
-
}}
|
|
350
|
+
className="flex items-center w-full px-4 py-2 text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
351
|
+
onClick={handleLogout}
|
|
295
352
|
>
|
|
296
353
|
<span className="mr-2">
|
|
297
|
-
{/* Logout icon */}
|
|
298
354
|
<svg
|
|
299
355
|
className="w-5 h-5"
|
|
300
356
|
fill="none"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import ThemeToggle from "./ThemeToggle";
|
|
3
|
+
|
|
4
|
+
export default function ThemeToggleTest() {
|
|
5
|
+
const [theme, setTheme] = useState("system");
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
style={{
|
|
10
|
+
minHeight: "100vh",
|
|
11
|
+
background:
|
|
12
|
+
theme === "dark"
|
|
13
|
+
? "#1a202c"
|
|
14
|
+
: theme === "light"
|
|
15
|
+
? "#f9fafb"
|
|
16
|
+
: "#e5e7eb",
|
|
17
|
+
display: "flex",
|
|
18
|
+
alignItems: "center",
|
|
19
|
+
justifyContent: "center",
|
|
20
|
+
transition: "background 0.3s",
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
<div style={{ width: 300 }}>
|
|
24
|
+
<ThemeToggle theme={theme} setTheme={setTheme} />
|
|
25
|
+
<div
|
|
26
|
+
style={{
|
|
27
|
+
marginTop: 24,
|
|
28
|
+
textAlign: "center",
|
|
29
|
+
color: theme === "dark" ? "#fff" : "#222",
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
<strong>Current theme:</strong> {theme}
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { default as AuthButtons } from "./AuthButtons";
|
|
2
2
|
export { default as ThemeToggle } from "./ThemeToggle";
|
|
3
3
|
export { default as ChildSearchModal } from "./ChildSearchModal";
|
|
4
|
+
export { default as ThemeToggleTest } from "./ThemeToggleTest";
|
|
4
5
|
export { configureTelemetry } from "./telemetry";
|