diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index c8a6426..5f63651 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,7 +1,7 @@ "use client"; import { useAuth } from "@/components/auth-provider"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; import { Sidebar } from "@/components/sidebar"; import { BrandStripe } from "@/components/brand-stripe"; @@ -9,12 +9,13 @@ import { BrandStripe } from "@/components/brand-stripe"; export default function AppLayout({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); const router = useRouter(); + const pathname = usePathname(); useEffect(() => { if (!loading && !user) { - router.push("/login"); + router.push(`/login?next=${encodeURIComponent(pathname)}`); } - }, [user, loading, router]); + }, [user, loading, router, pathname]); if (loading) { return ( diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 798beca..879a4eb 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,8 +1,9 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/components/auth-provider"; +import { isSafeNextPath } from "@/lib/safe-next-path"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -67,6 +68,7 @@ const labelStyle = { color: BRAND.textSecondary }; export default function AuthPage() { const { login } = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); const [loading, setLoading] = useState(false); const [loginEmail, setLoginEmail] = useState(""); @@ -78,7 +80,8 @@ export default function AuthPage() { try { await login(loginEmail, loginPassword); toast.success("Logged in successfully"); - router.push("/dashboard"); + const next = searchParams.get("next"); + router.push(isSafeNextPath(next) ? next : "/dashboard"); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Login failed"; toast.error(message); diff --git a/src/app/(mobile)/m/auth-gate.tsx b/src/app/(mobile)/m/auth-gate.tsx index 10c4ace..a498a70 100644 --- a/src/app/(mobile)/m/auth-gate.tsx +++ b/src/app/(mobile)/m/auth-gate.tsx @@ -1,18 +1,19 @@ "use client"; import { useAuth } from "@/components/auth-provider"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useEffect, type ReactNode } from "react"; export function MobileAuthGate({ children }: { children: ReactNode }) { const { user, loading } = useAuth(); const router = useRouter(); + const pathname = usePathname(); useEffect(() => { if (!loading && !user) { - router.replace("/login"); + router.replace(`/login?next=${encodeURIComponent(pathname)}`); } - }, [user, loading, router]); + }, [user, loading, router, pathname]); if (loading) { return ( diff --git a/src/app/(mobile)/m/dashboard-view.tsx b/src/app/(mobile)/m/dashboard-view.tsx index a29940b..3730aef 100644 --- a/src/app/(mobile)/m/dashboard-view.tsx +++ b/src/app/(mobile)/m/dashboard-view.tsx @@ -63,7 +63,7 @@ export default function BossDashboardView() { const pb = getPocketBase(); if (!pb.authStore?.isValid) { toast.error("Your session expired", { description: "Sign in again to continue." }); - router.replace("/login"); + router.replace(`/login?next=${encodeURIComponent("/m")}`); return; } @@ -85,7 +85,7 @@ export default function BossDashboardView() { if (!res.ok) { if (res.status === 401) { toast.error("Your session expired", { description: "Sign in again to continue." }); - router.replace("/login"); + router.replace(`/login?next=${encodeURIComponent("/m")}`); return; } throw new Error(`HTTP ${res.status}`); diff --git a/src/lib/safe-next-path.ts b/src/lib/safe-next-path.ts new file mode 100644 index 0000000..d54ce89 --- /dev/null +++ b/src/lib/safe-next-path.ts @@ -0,0 +1,9 @@ +/** + * Validates a `next` redirect target so login can send the user back + * to where they came from without opening a redirect to an external + * site (e.g. `?next=https://evil.example`). Only same-origin, + * root-relative paths are allowed. + */ +export function isSafeNextPath(path: string | null | undefined): path is string { + return !!path && path.startsWith("/") && !path.startsWith("//"); +}