fix: prevent sign-out crash and surface real email-send failures
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

- 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
This commit is contained in:
2026-07-14 12:44:33 -04:00
parent 3e39f978f6
commit bb09159133
9 changed files with 121 additions and 18 deletions
@@ -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) {
+10 -4
View File
@@ -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 }
);
+38
View File
@@ -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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-6 text-center">
<h1 className="text-xl font-semibold text-secondary">Something went wrong</h1>
<p className="max-w-md text-sm text-muted-foreground">
An unexpected error occurred. You can try again, or head back to the homepage.
</p>
<div className="flex gap-3">
<Button variant="outline" onClick={() => reset()}>
Try again
</Button>
<Button onClick={() => { window.location.href = '/'; }}>
Go home
</Button>
</div>
</div>
);
}