Merge pull request 'fix: prevent sign-out crash and surface real email-send failures' (#58) from fix/logout-crash-and-email-delivery into dev
This commit is contained in:
commit
e54c60912c
@ -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:
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
viewer-frontend/app/error.tsx
Normal file
38
viewer-frontend/app/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user