Files
punimtag/viewer-frontend/components/UserMenu.tsx
T
ilia bb09159133
CI / skip-ci-check (pull_request) Successful in 4s
CI / docker-ci (pull_request) Successful in 6s
CI / secret-scan (pull_request) Successful in 9s
fix: prevent sign-out crash and surface real email-send failures
- NextAuth session/jwt callbacks no longer return null (the client
  useSession() hook chokes on a null session, crashing the app on
  sign-out for deactivated/refreshed sessions) — fixes #57
- UserMenu/Header sign-out handlers close the Manage Users overlay and
  popover before calling signOut(), and no longer let a rejected
  signOut() promise go unhandled
- Added an app-level error boundary as a safety net for any remaining
  client render errors
- Registration and forgot-password flows now tell the user honestly
  when the confirmation/reset email failed to send instead of always
  claiming success — related to #56
- lib/email.ts throws a clear, actionable error when neither
  SMTP nor Resend is configured, instead of a cryptic SDK error
- Documented required SMTP/Resend env vars in DEPLOYMENT_CHECKLIST.md
  (were missing entirely, which is why no confirmation/reset emails
  were ever sent in deployed environments) — fixes #56
2026-07-14 12:44:33 -04:00

180 lines
5.7 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useSession, signOut } from 'next-auth/react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { User, Upload, Users, LogIn, UserPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { LoginDialog } from '@/components/LoginDialog';
import { RegisterDialog } from '@/components/RegisterDialog';
import { ManageUsersPageClient } from '@/app/admin/users/ManageUsersPageClient';
function UserMenu() {
const { data: session, status } = useSession();
const router = useRouter();
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [manageUsersOpen, setManageUsersOpen] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const handleSignOut = async () => {
// Close any overlays tied to the authenticated state (e.g. Manage Users)
// *before* the session clears, otherwise their effects/portals can unmount
// mid-transition and throw (issue #57).
setPopoverOpen(false);
setManageUsersOpen(false);
try {
await signOut({ callbackUrl: '/' });
} catch (error) {
console.error('[SIGN_OUT] Failed to sign out cleanly:', error);
router.push('/');
}
};
if (status === 'loading') {
return <div className="h-9 w-9 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />;
}
if (session?.user) {
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
asChild
className="bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800"
>
<Link href="/upload" aria-label="Upload Photos">
<Upload className="h-5 w-5" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Upload your own photos</p>
</TooltipContent>
</Tooltip>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full"
aria-label="Account menu"
>
<User className="h-5 w-5 text-orange-600" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2 z-[110]" align="end">
<div className="space-y-1">
<div className="px-2 py-1.5">
<p className="text-sm font-medium text-secondary">
{session.user.name || 'User'}
</p>
<p className="text-xs text-muted-foreground">
{session.user.email}
</p>
</div>
<div className="border-t pt-1">
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
router.push('/upload');
}}
>
<Upload className="mr-2 h-4 w-4" />
Upload Photos
</Button>
{session.user.isAdmin && (
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
setManageUsersOpen(true);
}}
>
<Users className="mr-2 h-4 w-4" />
Manage Users
</Button>
)}
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
handleSignOut();
}}
>
Sign out
</Button>
</div>
</div>
</PopoverContent>
</Popover>
{manageUsersOpen && (
<ManageUsersPageClient onClose={() => setManageUsersOpen(false)} />
)}
</>
);
}
return (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setLoginDialogOpen(true)}
className="flex items-center gap-2"
>
<LogIn className="h-4 w-4" />
<span className="hidden sm:inline">Sign in</span>
</Button>
<Button
size="sm"
onClick={() => setRegisterDialogOpen(true)}
className="flex items-center gap-2"
>
<UserPlus className="h-4 w-4" />
<span className="hidden sm:inline">Sign up</span>
</Button>
<LoginDialog
open={loginDialogOpen}
onOpenChange={(open) => {
setLoginDialogOpen(open);
}}
onOpenRegister={() => {
setLoginDialogOpen(false);
setRegisterDialogOpen(true);
}}
/>
<RegisterDialog
open={registerDialogOpen}
onOpenChange={(open) => {
setRegisterDialogOpen(open);
}}
onOpenLogin={() => {
setRegisterDialogOpen(false);
setLoginDialogOpen(true);
}}
/>
</>
);
}
export default UserMenu;