Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions components/layouts/user-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { useTheme } from 'next-themes';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useTransition } from 'react';

import {
Expand All @@ -16,10 +15,11 @@ import {
} from 'lucide-react';
import { toast } from 'sonner';

import { signOutUser } from '@/prisma/actions/auth';
import { logoutBypassUser } from '@/prisma/services/dev-bypass';

import { authClient } from '@/lib/auth/client';
import type { NavIdentity } from '@/lib/types';
import { isError } from '@/lib/utils';

import {
DropdownMenu,
Expand Down Expand Up @@ -57,7 +57,6 @@ export function UserMenu({
const { name, email, roleLabel, isBypass } = identity;
const displayName = name ?? email;
const [pending, startTransition] = useTransition();
const router = useRouter();
// next-themes is loaded with { ssr: false }; `theme` is undefined on first
// render before the provider resolves. Default to 'system' to match
// defaultTheme so the radio shows a selection without a flash.
Expand All @@ -74,14 +73,14 @@ export function UserMenu({
await logoutBypassUser();
return;
}
try {
await authClient.signOut();
toast.success('Signed out.');
router.push('/login');
router.refresh();
} catch {
toast.error('Could not sign out. Please try again.');
// Optimistic success toast — fires before the server redirect so it
// displays regardless of which path (redirect or error) completes first.
toast.success('Signed out.');
const result = await signOutUser();
if (isError(result)) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R4-M1 🟠 Medium — toast.success('Signed out.') fires unconditionally before the action is called, so on the error path the user sees a success toast immediately followed by an error toast — contradictory feedback. ENGINEERING.md §4 requires a success toast for success and a specific error toast for { error }; showing both simultaneously violates that model. The rationale in the comment (fires before server redirect) is real but the fix is wrong: redirect() in a server action sends a redirect response to the client after the await resolves, so a toast fired after await signOutUser() on the non-error branch would also display before navigation. Fix: const result = await signOutUser(); if (isError(result)) { toast.error(result.error); } else { toast.success('Signed out.'); }.

toast.error(result.error);
}
// On success the server action redirects to /login — no client navigation needed.
Comment thread
b-at-neu marked this conversation as resolved.
});
}

Expand Down
24 changes: 24 additions & 0 deletions prisma/actions/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

import { authServer, getCurrentUser } from '@/lib/auth/server';
import type { ErrorType } from '@/lib/utils';

// Signs out the current real (non-bypass) Neon Auth session.
// Sends the sign-out request server-side via authServer, which forwards the
// session cookie through next/headers — avoiding the browser SameSite/CSRF
// fragility that caused the 403 when sign-out was driven client-side.
export async function signOutUser(): Promise<ErrorType | void> {
const user = await getCurrentUser();
Comment thread
b-at-neu marked this conversation as resolved.
// getCurrentUser() always redirects when unauthenticated; this throw is defense-in-depth.
if (!user) throw new Error('Unauthenticated');

const result = await authServer.signOut();
Comment thread
b-at-neu marked this conversation as resolved.

if (result.error) return { error: 'Could not sign out. Please try again.' };

revalidatePath('/', 'layout');
redirect('/login');
}
Loading