|
| 1 | +// AccountMenu (#578) — the panel body for the sidebar's email-with-caret |
| 2 | +// dropdown. Replaces the old "Settings" modal: same controls (theme |
| 3 | +// toggle + Sign out) but rendered as a dropdown panel instead of a |
| 4 | +// modal, since the controls don't warrant a dimmed-overlay modal. |
| 5 | +// |
| 6 | +// This component is the panel CONTENT only — the consumer wraps it in |
| 7 | +// the shared <Popover> so outside-click + Escape close behaviour is |
| 8 | +// inherited from the same primitive ListPage's Actions menu uses. |
| 9 | + |
| 10 | +import { useState } from 'react'; |
| 11 | +import { LogOut, Moon, Sun } from 'lucide-react'; |
| 12 | + |
| 13 | +import { resolveTheme, setTheme, type Theme } from '@dar/customization'; |
| 14 | +import { purgeLocalCache, useApiClient } from '@dar/data'; |
| 15 | + |
| 16 | +export interface AccountMenuProps { |
| 17 | + /** Hide the panel after a menu action runs (theme stays — only Sign out closes). */ |
| 18 | + onAfterAction?: (() => void) | undefined; |
| 19 | + /** Email / display name shown as a small grayed caption above the controls. Optional. */ |
| 20 | + identityLabel?: string | undefined; |
| 21 | + /** Role caption appended after the email (e.g. "superuser"). Optional. */ |
| 22 | + roleLabel?: string | undefined; |
| 23 | +} |
| 24 | + |
| 25 | +export function AccountMenu({ onAfterAction, identityLabel, roleLabel }: AccountMenuProps) { |
| 26 | + const [theme, setThemeState] = useState<Theme>(() => resolveTheme()); |
| 27 | + const client = useApiClient(); |
| 28 | + const [loggingOut, setLoggingOut] = useState(false); |
| 29 | + |
| 30 | + const choose = (next: Theme) => { |
| 31 | + setTheme(next); |
| 32 | + setThemeState(next); |
| 33 | + // Theme is a sticky control — keep the menu open so the operator can |
| 34 | + // see the change reflect immediately before deciding to close it. |
| 35 | + }; |
| 36 | + |
| 37 | + const handleLogout = async () => { |
| 38 | + setLoggingOut(true); |
| 39 | + try { |
| 40 | + await client.logout(); |
| 41 | + } catch { |
| 42 | + // Logout is idempotent server-side; even on a transient network |
| 43 | + // error we still bounce the user out so the UI can't pretend |
| 44 | + // they're still signed in. |
| 45 | + } |
| 46 | + // Drop every cached server response + per-model UI hint so a logged |
| 47 | + // out (or next) user can't read the previous session's data out of |
| 48 | + // localStorage. Then a full reload re-runs the auth gate, routing an |
| 49 | + // anonymous session to the login screen with clean in-memory state. |
| 50 | + purgeLocalCache(); |
| 51 | + onAfterAction?.(); |
| 52 | + window.location.reload(); |
| 53 | + }; |
| 54 | + |
| 55 | + const optionClass = (active: boolean): string => |
| 56 | + [ |
| 57 | + 'flex flex-1 items-center justify-center gap-1.5 rounded-md border px-2 py-1.5 text-sm font-medium', |
| 58 | + active |
| 59 | + ? 'border-blue-600 bg-blue-50 text-blue-700' |
| 60 | + : 'border-gray-300 text-gray-700 hover:bg-gray-50', |
| 61 | + ].join(' '); |
| 62 | + |
| 63 | + return ( |
| 64 | + <div className="w-56 p-3"> |
| 65 | + {identityLabel && ( |
| 66 | + <div className="mb-2 truncate text-xs text-gray-500" title={identityLabel}> |
| 67 | + Signed in as <span className="font-medium text-gray-700">{identityLabel}</span> |
| 68 | + {roleLabel ? <span className="text-gray-400"> · {roleLabel}</span> : null} |
| 69 | + </div> |
| 70 | + )} |
| 71 | + |
| 72 | + <div className="mb-1 text-xs font-medium uppercase tracking-wide text-gray-500"> |
| 73 | + Appearance |
| 74 | + </div> |
| 75 | + <div className="flex gap-2"> |
| 76 | + <button |
| 77 | + type="button" |
| 78 | + role="menuitem" |
| 79 | + onClick={() => choose('light')} |
| 80 | + className={optionClass(theme === 'light')} |
| 81 | + aria-pressed={theme === 'light'} |
| 82 | + > |
| 83 | + <Sun className="h-4 w-4" aria-hidden /> Light |
| 84 | + </button> |
| 85 | + <button |
| 86 | + type="button" |
| 87 | + role="menuitem" |
| 88 | + onClick={() => choose('dark')} |
| 89 | + className={optionClass(theme === 'dark')} |
| 90 | + aria-pressed={theme === 'dark'} |
| 91 | + > |
| 92 | + <Moon className="h-4 w-4" aria-hidden /> Dark |
| 93 | + </button> |
| 94 | + </div> |
| 95 | + |
| 96 | + <div className="mt-3 border-t border-gray-200 pt-3"> |
| 97 | + <button |
| 98 | + type="button" |
| 99 | + role="menuitem" |
| 100 | + onClick={handleLogout} |
| 101 | + disabled={loggingOut} |
| 102 | + className="flex w-full items-center justify-center gap-1.5 rounded-md border border-gray-300 px-2 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50" |
| 103 | + > |
| 104 | + <LogOut className="h-4 w-4" aria-hidden /> |
| 105 | + {loggingOut ? 'Signing out…' : 'Sign out'} |
| 106 | + </button> |
| 107 | + </div> |
| 108 | + </div> |
| 109 | + ); |
| 110 | +} |
0 commit comments