diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx index 819fc61..8ff3499 100644 --- a/apps/web/src/app/(auth)/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -1,11 +1,14 @@ import type { ReactNode } from 'react'; +import { GuestGuard } from '@/components/guest-guard'; export default function AuthLayout({ children }: { children: ReactNode }): React.ReactElement { return ( -
-
- {children} + +
+
+ {children} +
-
+ ); } diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 40e86e8..b01d04f 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -1,14 +1,50 @@ 'use client'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import { signIn } from '@/lib/auth-client'; export default function LoginPage(): React.ReactElement { + const router = useRouter(); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + setError(null); + setLoading(true); + + const form = new FormData(e.currentTarget); + const email = form.get('email') as string; + const password = form.get('password') as string; + + const result = await signIn.email({ email, password }); + + if (result.error) { + setError(result.error.message ?? 'Sign in failed'); + setLoading(false); + return; + } + + router.push('/chat'); + } + return (

Sign in

Sign in to your Mosaic account

-
e.preventDefault()}> + {error && ( +
+ {error} +
+ )} + +
@@ -34,16 +71,18 @@ export default function LoginPage(): React.ReactElement { type="password" autoComplete="current-password" required - className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + disabled={loading} + className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50" placeholder="••••••••" />
diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx index 12d30bd..a17228c 100644 --- a/apps/web/src/app/(auth)/register/page.tsx +++ b/apps/web/src/app/(auth)/register/page.tsx @@ -1,14 +1,51 @@ 'use client'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import { signUp } from '@/lib/auth-client'; export default function RegisterPage(): React.ReactElement { + const router = useRouter(); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + setError(null); + setLoading(true); + + const form = new FormData(e.currentTarget); + const name = form.get('name') as string; + const email = form.get('email') as string; + const password = form.get('password') as string; + + const result = await signUp.email({ name, email, password }); + + if (result.error) { + setError(result.error.message ?? 'Registration failed'); + setLoading(false); + return; + } + + router.push('/chat'); + } + return (

Create account

Get started with Mosaic

-
e.preventDefault()}> + {error && ( +
+ {error} +
+ )} + +
@@ -34,7 +72,8 @@ export default function RegisterPage(): React.ReactElement { type="email" autoComplete="email" required - className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + disabled={loading} + className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50" placeholder="you@example.com" />
@@ -49,16 +88,18 @@ export default function RegisterPage(): React.ReactElement { type="password" autoComplete="new-password" required - className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + disabled={loading} + className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50" placeholder="••••••••" />
diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 7e70dc1..9edd362 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -1,6 +1,11 @@ import type { ReactNode } from 'react'; import { AppShell } from '@/components/layout/app-shell'; +import { AuthGuard } from '@/components/auth-guard'; export default function DashboardLayout({ children }: { children: ReactNode }): React.ReactElement { - return {children}; + return ( + + {children} + + ); } diff --git a/apps/web/src/components/auth-guard.tsx b/apps/web/src/components/auth-guard.tsx new file mode 100644 index 0000000..3de8aef --- /dev/null +++ b/apps/web/src/components/auth-guard.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { useSession } from '@/lib/auth-client'; + +interface AuthGuardProps { + children: React.ReactNode; +} + +export function AuthGuard({ children }: AuthGuardProps): React.ReactElement | null { + const { data: session, isPending } = useSession(); + const router = useRouter(); + + useEffect(() => { + if (!isPending && !session) { + router.replace('/login'); + } + }, [isPending, session, router]); + + if (isPending) { + return ( +
+
Loading...
+
+ ); + } + + if (!session) { + return null; + } + + return <>{children}; +} diff --git a/apps/web/src/components/guest-guard.tsx b/apps/web/src/components/guest-guard.tsx new file mode 100644 index 0000000..d71e210 --- /dev/null +++ b/apps/web/src/components/guest-guard.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { useSession } from '@/lib/auth-client'; + +interface GuestGuardProps { + children: React.ReactNode; +} + +/** Redirects authenticated users away from auth pages. */ +export function GuestGuard({ children }: GuestGuardProps): React.ReactElement | null { + const { data: session, isPending } = useSession(); + const router = useRouter(); + + useEffect(() => { + if (!isPending && session) { + router.replace('/chat'); + } + }, [isPending, session, router]); + + if (isPending) { + return ( +
+
Loading...
+
+ ); + } + + if (session) { + return null; + } + + return <>{children}; +} diff --git a/apps/web/src/components/layout/topbar.tsx b/apps/web/src/components/layout/topbar.tsx index 2d4bb7d..49e3538 100644 --- a/apps/web/src/components/layout/topbar.tsx +++ b/apps/web/src/components/layout/topbar.tsx @@ -1,9 +1,16 @@ 'use client'; +import { useRouter } from 'next/navigation'; import { useSession, signOut } from '@/lib/auth-client'; export function Topbar(): React.ReactElement { const { data: session } = useSession(); + const router = useRouter(); + + async function handleSignOut(): Promise { + await signOut(); + router.replace('/login'); + } return (
@@ -17,7 +24,7 @@ export function Topbar(): React.ReactElement {