From bb0915913385bb442c6a2e18275f610764b58265 Mon Sep 17 00:00:00 2001 From: ilia Date: Tue, 14 Jul 2026 12:44:33 -0400 Subject: [PATCH] fix: prevent sign-out crash and surface real email-send failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- DEPLOYMENT_CHECKLIST.md | 21 ++++++++++ .../app/api/auth/[...nextauth]/route.ts | 25 +++++++----- .../app/api/auth/register/route.ts | 14 +++++-- viewer-frontend/app/error.tsx | 38 +++++++++++++++++++ viewer-frontend/components/Header.tsx | 12 +++++- viewer-frontend/components/RegisterDialog.tsx | 6 ++- viewer-frontend/components/UserMenu.tsx | 12 +++++- viewer-frontend/hooks/useIdleLogout.ts | 4 +- viewer-frontend/lib/email.ts | 7 ++++ 9 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 viewer-frontend/app/error.tsx diff --git a/DEPLOYMENT_CHECKLIST.md b/DEPLOYMENT_CHECKLIST.md index f69deed..8d57d52 100644 --- a/DEPLOYMENT_CHECKLIST.md +++ b/DEPLOYMENT_CHECKLIST.md @@ -31,8 +31,29 @@ DATABASE_URL_AUTH=postgresql://user:password@10.0.10.179:5432/punimtag_auth NEXTAUTH_URL=http://10.0.10.121:3001 NEXTAUTH_SECRET=your-secret-key-here AUTH_URL=http://10.0.10.121:3001 + +# Password reset / email verification emails (lib/email.ts). Without these, +# "Forgot password" and "Confirm your email" never actually send mail — see +# issue #56. Pick ONE provider path: + +# Option A: SMTP (primary; falls back to Resend automatically on failure) +EMAIL_PROVIDER=smtp +SMTP_HOST=mail.your-domain.com +SMTP_PORT=465 +SMTP_SECURE=true +SMTP_USER=mailer@your-domain.com +SMTP_PASS=your-mailbox-password +SMTP_FROM_EMAIL=noreply@your-domain.com +SMTP_FROM_NAME=PunimTag Viewer + +# Option B: Resend only (set EMAIL_PROVIDER=resend, or leave SMTP_* unset above) +RESEND_API_KEY=re_xxx +RESEND_FROM_EMAIL=onboarding@resend.dev +RESEND_FROM_NAME=PunimTag Viewer ``` +Verify email sending works with `cd viewer-frontend && npx tsx scripts/test-email-sending.ts` after setting these. + ## 2. PM2 Configuration Copy the template and customize for your server: diff --git a/viewer-frontend/app/api/auth/[...nextauth]/route.ts b/viewer-frontend/app/api/auth/[...nextauth]/route.ts index 244c9b4..f70bec7 100644 --- a/viewer-frontend/app/api/auth/[...nextauth]/route.ts +++ b/viewer-frontend/app/api/auth/[...nextauth]/route.ts @@ -99,7 +99,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ token.email = user.email; token.isAdmin = user.isAdmin; token.hasWriteAccess = user.hasWriteAccess; - token.exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60); // 24 hours from now + token.isActive = true; } // Refresh user data from database on token refresh to get latest hasWriteAccess and isActive @@ -117,12 +117,15 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ }, }); - if (dbUser) { - // Check if user is still active (treat null/undefined as true) - if (dbUser.isActive === false) { - // User was deactivated, invalidate token - return null as any; - } + if (!dbUser || dbUser.isActive === false) { + // User was deactivated or deleted. Flag it on the token instead of + // returning `null` here: NextAuth v5's client `useSession()` hook does + // not expect a `null` JWT and throws when it encounters one, which is + // what caused the sign-out crash (issue #57). The `session` callback + // below turns this flag into a clean signed-out session instead. + token.isActive = false; + } else { + token.isActive = true; token.id = dbUser.id.toString(); token.isAdmin = dbUser.isAdmin; token.hasWriteAccess = dbUser.hasWriteAccess; @@ -136,9 +139,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ return token; }, async session({ session, token }) { - // If token is null or expired, return null session to force logout - if (!token || (token.exp && token.exp < Math.floor(Date.now() / 1000))) { - return null as any; + // Never return `null` for the whole session object — NextAuth v5's client + // hooks assume a `{ user, expires }` shape and crash on `null` (issue #57). + // Instead, drop `user` so the client treats this as a normal signed-out state. + if (!token || token.isActive === false) { + return { ...session, user: undefined } as any; } if (session.user) { diff --git a/viewer-frontend/app/api/auth/register/route.ts b/viewer-frontend/app/api/auth/register/route.ts index f4f1567..c70bef0 100644 --- a/viewer-frontend/app/api/auth/register/route.ts +++ b/viewer-frontend/app/api/auth/register/route.ts @@ -83,19 +83,25 @@ export async function POST(request: NextRequest) { }); // Send confirmation email + let emailSendFailed = false; try { await sendEmailConfirmation(email, name.trim(), confirmationToken); } catch (emailError) { console.error('Error sending confirmation email:', emailError); - // Don't fail registration if email fails, but log it - // User can request a resend later + // Don't fail registration if email fails, but let the client know so it + // doesn't tell the user to "check their email" when nothing was sent + // (issue #56). The user can still request a resend later. + emailSendFailed = true; } return NextResponse.json( { - message: 'User created successfully. Please check your email to confirm your account.', + message: emailSendFailed + ? 'Account created, but we could not send the confirmation email right now. Use "Resend confirmation email" on the login screen once you try to sign in.' + : 'User created successfully. Please check your email to confirm your account.', user, - requiresEmailConfirmation: true + requiresEmailConfirmation: true, + emailSendFailed, }, { status: 201 } ); diff --git a/viewer-frontend/app/error.tsx b/viewer-frontend/app/error.tsx new file mode 100644 index 0000000..77aa86a --- /dev/null +++ b/viewer-frontend/app/error.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; + +/** + * App-level error boundary. Without this, any uncaught client render error + * (e.g. during a sign-out/session transition) produces a hard white-screen + * crash instead of a recoverable UI (issue #57). + */ +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('[APP_ERROR]', error); + }, [error]); + + return ( +
+

Something went wrong

+

+ An unexpected error occurred. You can try again, or head back to the homepage. +

+
+ + +
+
+ ); +} diff --git a/viewer-frontend/components/Header.tsx b/viewer-frontend/components/Header.tsx index f908e88..08c8c91 100644 --- a/viewer-frontend/components/Header.tsx +++ b/viewer-frontend/components/Header.tsx @@ -29,7 +29,17 @@ export function Header() { const [popoverOpen, setPopoverOpen] = useState(false); const handleSignOut = async () => { - await signOut({ callbackUrl: '/' }); + // 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('/'); + } }; return ( diff --git a/viewer-frontend/components/RegisterDialog.tsx b/viewer-frontend/components/RegisterDialog.tsx index 09bf729..7888ae4 100644 --- a/viewer-frontend/components/RegisterDialog.tsx +++ b/viewer-frontend/components/RegisterDialog.tsx @@ -107,7 +107,11 @@ export function RegisterDialog({ setError(''); // Show success state - alert('Account created successfully! Please check your email to confirm your account before signing in.'); + alert( + data.emailSendFailed + ? 'Account created, but we could not send the confirmation email right now. Once you try to sign in, use "Resend confirmation email" to try again.' + : 'Account created successfully! Please check your email to confirm your account before signing in.' + ); onOpenChange(false); if (onOpenLogin) { diff --git a/viewer-frontend/components/UserMenu.tsx b/viewer-frontend/components/UserMenu.tsx index 6bc0506..408c31a 100644 --- a/viewer-frontend/components/UserMenu.tsx +++ b/viewer-frontend/components/UserMenu.tsx @@ -29,7 +29,17 @@ function UserMenu() { const [popoverOpen, setPopoverOpen] = useState(false); const handleSignOut = async () => { - await signOut({ callbackUrl: '/' }); + // 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') { diff --git a/viewer-frontend/hooks/useIdleLogout.ts b/viewer-frontend/hooks/useIdleLogout.ts index 281ab73..dfa0bd9 100644 --- a/viewer-frontend/hooks/useIdleLogout.ts +++ b/viewer-frontend/hooks/useIdleLogout.ts @@ -23,7 +23,9 @@ export function useIdleLogout(timeoutMs: number = 2 * 60 * 60 * 1000, enabled: b if (session && enabled) { timerRef.current = setTimeout(() => { console.log('[IDLE_LOGOUT] User inactive for', timeoutMs / 1000 / 60, 'minutes, logging out...'); - signOut({ callbackUrl: '/' }); + signOut({ callbackUrl: '/' }).catch((error) => { + console.error('[IDLE_LOGOUT] Failed to sign out cleanly:', error); + }); }, timeoutMs); } }, [session, enabled, timeoutMs]); diff --git a/viewer-frontend/lib/email.ts b/viewer-frontend/lib/email.ts index 84984e8..7856784 100644 --- a/viewer-frontend/lib/email.ts +++ b/viewer-frontend/lib/email.ts @@ -73,6 +73,13 @@ async function sendViaSmtp(payload: EmailPayload): Promise { } async function sendViaResend(payload: EmailPayload): Promise { + if (!process.env.RESEND_API_KEY) { + throw new Error( + 'Missing Resend configuration (RESEND_API_KEY). Set SMTP_HOST/SMTP_USER/SMTP_PASS ' + + 'or RESEND_API_KEY so password reset / email verification can actually send mail.' + ); + } + const fromEmail = process.env.RESEND_FROM_EMAIL || process.env.SMTP_FROM_EMAIL || 'onboarding@resend.dev'; const fromName = process.env.RESEND_FROM_NAME || process.env.SMTP_FROM_NAME; const replyTo = process.env.RESEND_REPLY_TO || process.env.SMTP_REPLY_TO || fromEmail;