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:
@@ -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')}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user