@snapdragonsnursery/react-components 1.0.4 → 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 +2 -1
- package/src/AuthButtons.jsx +113 -37
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": {
|
|
@@ -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
|
},
|
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 {
|
|
@@ -8,6 +25,7 @@ import {
|
|
|
8
25
|
} from "./telemetry";
|
|
9
26
|
import ThemeToggle from "./ThemeToggle";
|
|
10
27
|
import { Menu } from "@headlessui/react";
|
|
28
|
+
import { createPopper } from "@popperjs/core";
|
|
11
29
|
|
|
12
30
|
const isMobile = window.innerWidth < 640;
|
|
13
31
|
|
|
@@ -198,6 +216,59 @@ function AuthButtons({ theme, setTheme, authState, setAuthState }) {
|
|
|
198
216
|
const displayName =
|
|
199
217
|
authState.account?.name ?? authState.account?.idTokenClaims?.name ?? "User";
|
|
200
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
|
+
|
|
201
272
|
if (!authState.account) {
|
|
202
273
|
return (
|
|
203
274
|
<div className="flex items-center gap-2">
|
|
@@ -235,9 +306,14 @@ function AuthButtons({ theme, setTheme, authState, setAuthState }) {
|
|
|
235
306
|
}
|
|
236
307
|
|
|
237
308
|
return (
|
|
238
|
-
<
|
|
309
|
+
<div className="relative inline-block text-left">
|
|
239
310
|
<div>
|
|
240
|
-
<Menu.Button
|
|
311
|
+
<Menu.Button
|
|
312
|
+
as="div"
|
|
313
|
+
ref={buttonRef}
|
|
314
|
+
className="focus:outline-none"
|
|
315
|
+
onClick={() => setMenuOpen((open) => !open)}
|
|
316
|
+
>
|
|
241
317
|
{profilePicture ? (
|
|
242
318
|
<img
|
|
243
319
|
src={profilePicture}
|
|
@@ -258,42 +334,42 @@ function AuthButtons({ theme, setTheme, authState, setAuthState }) {
|
|
|
258
334
|
)}
|
|
259
335
|
</Menu.Button>
|
|
260
336
|
</div>
|
|
261
|
-
|
|
262
|
-
<div
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
337
|
+
{menuOpen && (
|
|
338
|
+
<div
|
|
339
|
+
ref={menuRef}
|
|
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" }}
|
|
342
|
+
>
|
|
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">
|
|
344
|
+
Signed in as <span className="font-semibold">{displayName}</span>
|
|
345
|
+
</div>
|
|
346
|
+
<div className="px-4 py-2">
|
|
347
|
+
<ThemeToggle theme={theme} setTheme={setTheme} />
|
|
348
|
+
</div>
|
|
349
|
+
<button
|
|
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}
|
|
352
|
+
>
|
|
353
|
+
<span className="mr-2">
|
|
354
|
+
<svg
|
|
355
|
+
className="w-5 h-5"
|
|
356
|
+
fill="none"
|
|
357
|
+
stroke="currentColor"
|
|
358
|
+
viewBox="0 0 24 24"
|
|
359
|
+
>
|
|
360
|
+
<path
|
|
361
|
+
strokeLinecap="round"
|
|
362
|
+
strokeLinejoin="round"
|
|
363
|
+
strokeWidth={2}
|
|
364
|
+
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"
|
|
365
|
+
/>
|
|
366
|
+
</svg>
|
|
367
|
+
</span>
|
|
368
|
+
Logout
|
|
369
|
+
</button>
|
|
267
370
|
</div>
|
|
268
|
-
|
|
269
|
-
|
|
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>
|
|
371
|
+
)}
|
|
372
|
+
</div>
|
|
297
373
|
);
|
|
298
374
|
}
|
|
299
375
|
|