fix(ui+auth): origin-forwarding for sign-in + disable dark mode + center dialog

Three related cleanups while QA-testing on iPad:

1. Origin-forwarding bug on /api/auth/sign-in-by-identifier
   - The custom identifier-sign-in route forwarded to better-auth's
     /sign-in/email handler but did NOT preserve the inbound Origin +
     Referer headers. Better-auth's CSRF check then 403'd every login
     with MISSING_OR_NULL_ORIGIN — and the UI showed a generic
     "Invalid credentials" toast even when the password was right.
   - Fix: pass through req.headers.get('origin') and
     req.headers.get('referer') when constructing forwardReq.
   - Affects: every login attempt from any device (this isn't dev-
     only); discovered testing from 192.168.1.17 → app on the same
     LAN IP. Production users hit the same path.

2. Dark mode disabled
   - Drop the Sun/Moon toggle from user-menu, the documentElement
     class flip, darkMode from ui-store, darkMode from the user-
     preferences validator. Hardcode sonner theme="light" (was
     reading next-themes which isn't actually wired anywhere else).
   - The 10 stray `dark:` Tailwind utilities are left alone — they're
     inactive without the `dark` class on <html> so they don't ship
     anything that renders, just dead CSS.

3. Center dialog animation
   - Dialog content was sliding in from the top-right corner (slide-
     in-from-left-1/2 + slide-in-from-top-[48%]) which felt jarring.
     Drop the slide directions, keep just zoom-in-95 + the base
     fade-in/out so dialogs appear in place with a subtle scale-up.

4. Login placeholder
   - Removed the "you@example.com  or  yourname" placeholder so the
     field reads as a clean empty input below the "Email or username"
     label.

No tests added (the 1340 vitest suite passes); changes are surface-
level UI tweaks + the origin-header fix where a unit-test of the
custom route would mostly be testing better-auth's behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 15:20:06 +02:00
parent bd432fc6c7
commit 12e22d9be3
7 changed files with 19 additions and 34 deletions

View File

@@ -81,7 +81,6 @@ export default function LoginPage() {
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
placeholder="you@example.com or yourname"
disabled={isLoading}
className={cn(errors.identifier && 'border-destructive focus-visible:ring-destructive')}
{...register('identifier')}

View File

@@ -93,6 +93,14 @@ export async function POST(req: NextRequest) {
'x-forwarded-for': req.headers.get('x-forwarded-for') ?? ip,
'user-agent': req.headers.get('user-agent') ?? '',
cookie: req.headers.get('cookie') ?? '',
// CRITICAL: forward Origin + Referer so better-auth's CSRF check
// passes. Without these the internal call lands as a cross-origin
// request with no Origin → 403 MISSING_OR_NULL_ORIGIN, and the
// user sees a generic "Invalid credentials" toast even though
// the password is right. (Bug surfaced 2026-05-13 testing on
// 192.168.1.17:3000 from an iPad.)
...(req.headers.get('origin') ? { origin: req.headers.get('origin')! } : {}),
...(req.headers.get('referer') ? { referer: req.headers.get('referer')! } : {}),
},
body: forwardBody,
});

View File

@@ -15,7 +15,7 @@
import { useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { Moon, Sun, LogOut, User, Settings, Bell, Check, Building2 } from 'lucide-react';
import { LogOut, User, Settings, Bell, Check, Building2 } from 'lucide-react';
import { type ReactNode } from 'react';
import { useUIStore } from '@/stores/ui-store';
@@ -51,17 +51,10 @@ export function UserMenu({ trigger, align = 'end', user, ports }: UserMenuProps)
const currentPortId = useUIStore((s) => s.currentPortId);
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
const setPort = useUIStore((s) => s.setPort);
const darkMode = useUIStore((s) => s.darkMode);
const toggleDarkMode = useUIStore((s) => s.toggleDarkMode);
const base = currentPortSlug ? `/${currentPortSlug}` : '';
const showPortSwitcher = ports && ports.length > 1;
function handleToggleDarkMode() {
toggleDarkMode();
document.documentElement.classList.toggle('dark');
}
function handlePortChange(port: Port) {
if (port.id === currentPortId) return;
setPort(port.id, port.slug);
@@ -132,20 +125,6 @@ export function UserMenu({ trigger, align = 'end', user, ports }: UserMenuProps)
Notification preferences
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleToggleDarkMode}>
{darkMode ? (
<>
<Sun className="w-4 h-4 mr-2" aria-hidden />
Light Mode
</>
) : (
<>
<Moon className="w-4 h-4 mr-2" aria-hidden />
Dark Mode
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => router.push('/api/auth/sign-out')}

View File

@@ -51,7 +51,12 @@ const DialogContent = React.forwardRef<
'max-h-dvh overflow-y-auto sm:max-h-[calc(100dvh-2rem)]',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg',
'sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%]',
// Desktop animation: subtle centered fade + zoom (no slide-from-
// corner so the dialog appears in place rather than flying in
// from top-right). The base fade-in/out classes above provide
// the opacity transition; zoom-95 adds a 5% scale-in for
// depth without feeling jarring.
'sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95',
className,
)}
{...props}

View File

@@ -1,16 +1,16 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
// Dark mode is disabled across the app — hardcode the light theme so
// sonner doesn't pick up a `prefers-color-scheme: dark` system hint and
// render against a dark background that the rest of the UI doesn't use.
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
theme="light"
className="toaster group"
toastOptions={{
classNames: {

View File

@@ -11,7 +11,6 @@ export const reminderPreferencesSchema = z.object({
});
export const updateUserPreferencesSchema = z.object({
darkMode: z.boolean().optional(),
locale: z.string().optional(),
timezone: z.string().optional(),
reminders: reminderPreferencesSchema.optional(),

View File

@@ -5,10 +5,8 @@ interface UIStore {
sidebarCollapsed: boolean;
currentPortId: string | null;
currentPortSlug: string | null;
darkMode: boolean;
toggleSidebar: () => void;
setPort: (portId: string, portSlug: string) => void;
toggleDarkMode: () => void;
}
export const useUIStore = create<UIStore>()(
@@ -17,10 +15,8 @@ export const useUIStore = create<UIStore>()(
sidebarCollapsed: false,
currentPortId: null,
currentPortSlug: null,
darkMode: false,
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setPort: (portId, portSlug) => set({ currentPortId: portId, currentPortSlug: portSlug }),
toggleDarkMode: () => set((s) => ({ darkMode: !s.darkMode })),
}),
{
name: 'pn-crm-ui',
@@ -30,7 +26,6 @@ export const useUIStore = create<UIStore>()(
// the previous session before the URL-derived effect runs.
partialize: (state) => ({
sidebarCollapsed: state.sidebarCollapsed,
darkMode: state.darkMode,
}),
},
),