fix: prevent sign-out crash and surface real email-send failures #58

Merged
ilia merged 1 commits from fix/logout-crash-and-email-delivery into dev 2026-07-14 12:14:53 -05:00
9 changed files with 121 additions and 18 deletions

View File

@ -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:

View File

@ -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) {

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 }
);

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>
);
}

View File

@ -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 (

View File

@ -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) {

View File

@ -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') {

View File

@ -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]);

View File

@ -73,6 +73,13 @@ async function sendViaSmtp(payload: EmailPayload): Promise<void> {
}
async function sendViaResend(payload: EmailPayload): Promise<void> {
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;