Skip to content
Merged
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: 13 additions & 6 deletions apps/dashboard/src/hooks/use-auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
useCallback,
type ReactNode,
} from "react"
import { useNavigate, useLocation } from "react-router-dom"
import { useLocation } from "react-router-dom"
import type { Session, User } from "@supabase/supabase-js"
import { supabase } from "@/lib/supabase"
import { fetchMe } from "@/lib/api"
Expand All @@ -23,13 +23,17 @@ interface AuthContextValue extends AuthState {
}

const APP_URL = import.meta.env.VITE_APP_URL ?? "http://localhost:5173"
const WEB_LOGIN_URL = APP_URL && !APP_URL.startsWith(window.location.origin)
? APP_URL
: "http://localhost:5173"

const AuthContext = createContext<AuthContextValue | undefined>(undefined)

const PUBLIC_PATHS = ["/auth/callback"]
function isPublicPath(pathname: string): boolean {
return pathname === "/auth/callback" || pathname.startsWith("/auth/callback/")
}

export function AuthProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate()
const location = useLocation()
const [state, setState] = useState<AuthState>({
user: null,
Expand All @@ -48,8 +52,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [])

const redirectToLogin = useCallback(() => {
const currentPath = location.pathname + location.search
window.location.replace(`${APP_URL}/login?redirectTo=${encodeURIComponent(currentPath)}`)
const params = new URLSearchParams(location.search)
params.delete("redirectTo")
const cleanSearch = params.toString()
const currentPath = location.pathname + (cleanSearch ? `?${cleanSearch}` : "")
window.location.replace(`${WEB_LOGIN_URL}/login?redirectTo=${encodeURIComponent(currentPath)}`)
}, [location.pathname, location.search])

useEffect(() => {
Expand Down Expand Up @@ -89,7 +96,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (state.isLoading) return
if (state.session) return
if (PUBLIC_PATHS.includes(location.pathname)) return
if (isPublicPath(location.pathname)) return

redirectToLogin()
}, [state.isLoading, state.session, location.pathname, redirectToLogin])
Expand Down
38 changes: 31 additions & 7 deletions apps/dashboard/src/pages/AuthCallback.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,33 @@
import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { supabase } from "@/lib/supabase"

const APP_URL = import.meta.env.VITE_APP_URL ?? "http://localhost:5173"
const WEB_LOGIN_URL = APP_URL && !APP_URL.startsWith(window.location.origin)
? APP_URL
: "http://localhost:5173"
const DEFAULT_REDIRECT = "/overview"

const AUTH_PATHS = new Set(["/login", "/signup", "/forgot-password", "/reset-password", "/auth/callback"])

function sanitizeRedirectPath(hashRedirect: string | null): string {
if (!hashRedirect) return DEFAULT_REDIRECT
if (!hashRedirect.startsWith("/")) return DEFAULT_REDIRECT
if (hashRedirect.startsWith("//")) return DEFAULT_REDIRECT

const questionIndex = hashRedirect.indexOf("?")
if (questionIndex !== -1) {
const searchParams = new URLSearchParams(hashRedirect.slice(questionIndex))
if (searchParams.has("redirectTo")) {
hashRedirect = hashRedirect.slice(0, questionIndex)
}
}

if (AUTH_PATHS.has(hashRedirect)) return DEFAULT_REDIRECT

return hashRedirect
}

export default function AuthCallback() {
const navigate = useNavigate()
const [error, setError] = useState<string | null>(null)

useEffect(() => {
Expand All @@ -17,14 +41,14 @@ export default function AuthCallback() {
const params = new URLSearchParams(hash)
const accessToken = params.get("access_token")
const refreshToken = params.get("refresh_token")
const redirectTo = params.get("redirectTo") || "/overview"
const redirectTo = sanitizeRedirectPath(params.get("redirectTo"))

if (!accessToken || !refreshToken) {
setError("Invalid authentication data")
return
}

window.location.hash = ""
history.replaceState(null, "", window.location.pathname)

const { error: setSessionError } = await supabase.auth.setSession({
access_token: accessToken,
Expand All @@ -36,11 +60,11 @@ export default function AuthCallback() {
return
}

navigate(redirectTo, { replace: true })
window.location.replace(redirectTo)
}

handleCallback()
}, [navigate])
}, [])

if (error) {
return (
Expand All @@ -49,7 +73,7 @@ export default function AuthCallback() {
{error}
</div>
<a
href="/login"
href={`${WEB_LOGIN_URL}/login`}
className="text-sm font-medium text-foreground hover:underline"
>
Back to sign in
Expand Down
53 changes: 50 additions & 3 deletions apps/web/src/components/auth/AuthRouteGuard.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,54 @@
import { type ReactNode } from "react"
import { Navigate } from "react-router-dom"
import { useEffect, useState, type ReactNode } from "react"
import { Navigate, useSearchParams } from "react-router-dom"
import { useAuth } from "@/hooks/use-auth"
import { supabase } from "@/lib/supabase"
import { DASHBOARD_URL } from "@/lib/config"

const AUTH_PATHS = new Set(["/login", "/signup", "/forgot-password", "/reset-password", "/auth/callback"])

function isSafeRedirectPath(raw: string | null): boolean {
if (!raw) return false
if (!raw.startsWith("/")) return false
if (raw.startsWith("//")) return false

const questionIndex = raw.indexOf("?")
if (questionIndex !== -1) {
const searchParams = new URLSearchParams(raw.slice(questionIndex))
if (searchParams.has("redirectTo")) {
raw = raw.slice(0, questionIndex)
}
}

if (AUTH_PATHS.has(raw)) return false

return true
}

export default function AuthRouteGuard({ children }: { children: ReactNode }) {
const { user, isLoading } = useAuth()
const [searchParams] = useSearchParams()
const [isRedirecting, setIsRedirecting] = useState(false)

useEffect(() => {
if (isLoading || !user) return

const redirectTo = searchParams.get("redirectTo")
if (!isSafeRedirectPath(redirectTo)) return

const target = redirectTo as string
setIsRedirecting(true)
supabase.auth.getSession().then(({ data: { session } }) => {
if (!session) {
window.location.href = "/"
return
}
const { access_token, refresh_token } = session
const hash = `#access_token=${encodeURIComponent(access_token)}&refresh_token=${encodeURIComponent(refresh_token)}&redirectTo=${encodeURIComponent(target)}`
window.location.replace(`${DASHBOARD_URL}/auth/callback${hash}`)
})
}, [isLoading, user, searchParams])

if (isLoading) {
if (isLoading || isRedirecting) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
Expand All @@ -14,6 +57,10 @@ export default function AuthRouteGuard({ children }: { children: ReactNode }) {
}

if (user) {
const redirectTo = searchParams.get("redirectTo")
if (isSafeRedirectPath(redirectTo)) {
return null
}
return <Navigate to="/" replace />
}

Expand Down
26 changes: 18 additions & 8 deletions apps/web/src/components/landing/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { Menu } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { cn } from "@/lib/utils"
import { supabase } from "@/lib/supabase"
import { useAuth } from "@/hooks/use-auth"

const APP_URL = import.meta.env.VITE_APP_URL ?? "http://localhost:3000"
import { DASHBOARD_URL } from "@/lib/config"

const navLinks = [
{ label: "Features", href: "#features" },
Expand Down Expand Up @@ -55,9 +55,14 @@ export default function Navbar() {

<div className="flex items-center gap-2">
{isLoading ? null : user ? (
<Button size="sm" render={<a href={APP_URL} />}>
Dashboard
</Button>
<>
<Button size="sm" render={<a href={DASHBOARD_URL} />}>
Dashboard
</Button>
<Button variant="ghost" size="sm" onClick={() => supabase.auth.signOut()}>
Sign Out
</Button>
</>
) : (
<>
<Button variant="ghost" size="sm" className="hidden sm:inline-flex" render={<Link to="/login" />}>
Expand Down Expand Up @@ -87,9 +92,14 @@ export default function Navbar() {
</nav>
<div className="mt-6 flex flex-col gap-2">
{isLoading ? null : user ? (
<Button className="w-full" render={<a href={APP_URL} />}>
Dashboard
</Button>
<>
<Button className="w-full" render={<a href={DASHBOARD_URL} />}>
Dashboard
</Button>
<Button variant="outline" className="w-full" onClick={() => supabase.auth.signOut()}>
Sign Out
</Button>
</>
) : (
<>
<Button variant="outline" className="w-full" render={<Link to="/login" />}>
Expand Down
45 changes: 31 additions & 14 deletions apps/web/src/hooks/use-auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,37 @@ interface AuthContextValue extends AuthState {

const AuthContext = createContext<AuthContextValue | undefined>(undefined)

const AUTH_PATHS = new Set(["/login", "/signup", "/forgot-password", "/reset-password", "/auth/callback"])

function sanitizeRedirectPath(redirectTo: string | null): string | null {
if (!redirectTo) return null
if (!redirectTo.startsWith("/")) return null
if (redirectTo.startsWith("//")) return null

const questionIndex = redirectTo.indexOf("?")
if (questionIndex !== -1) {
const searchParams = new URLSearchParams(redirectTo.slice(questionIndex))
if (searchParams.has("redirectTo")) {
redirectTo = redirectTo.slice(0, questionIndex)
}
}

if (AUTH_PATHS.has(redirectTo)) return null

return redirectTo
}

function getRedirectTo(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get("redirectTo")
return sanitizeRedirectPath(
new URLSearchParams(window.location.search).get("redirectTo"),
)
}

function relaySessionToDashboard() {
supabase.auth.getSession().then(({ data: { session } }) => {
if (!session) return
const { access_token, refresh_token } = session
const redirectTo = getRedirectTo() || "/overview"
const hash = `#access_token=${encodeURIComponent(access_token)}&refresh_token=${encodeURIComponent(refresh_token)}&redirectTo=${encodeURIComponent(redirectTo)}`
window.location.replace(`${DASHBOARD_URL}/auth/callback${hash}`)
})
function relaySessionToDashboard(session: Session) {
const { access_token, refresh_token } = session
const redirectTo = getRedirectTo() || "/overview"
const hash = `#access_token=${encodeURIComponent(access_token)}&refresh_token=${encodeURIComponent(refresh_token)}&redirectTo=${encodeURIComponent(redirectTo)}`
window.location.replace(`${DASHBOARD_URL}/auth/callback${hash}`)
}

export function AuthProvider({ children }: { children: ReactNode }) {
Expand Down Expand Up @@ -113,9 +131,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return { error: "Please verify your email before signing in. Check your inbox." }
}

const redirectTo = getRedirectTo()
if (redirectTo) {
relaySessionToDashboard()
if (data.session && getRedirectTo()) {
relaySessionToDashboard(data.session)
} else {
navigate("/")
}
Expand Down Expand Up @@ -144,7 +161,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {

if (!needsEmailVerification && data.session) {
if (redirectTo) {
relaySessionToDashboard()
relaySessionToDashboard(data.session)
} else {
navigate("/")
}
Expand Down
22 changes: 21 additions & 1 deletion apps/web/src/pages/AuthCallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@ import { useNavigate } from "react-router-dom"
import { supabase } from "@/lib/supabase"
import { DASHBOARD_URL } from "@/lib/config"

const AUTH_PATHS = new Set(["/login", "/signup", "/forgot-password", "/reset-password", "/auth/callback"])

function sanitizeRedirectPath(redirectTo: string | null): string | null {
if (!redirectTo) return null
if (!redirectTo.startsWith("/")) return null
if (redirectTo.startsWith("//")) return null

const questionIndex = redirectTo.indexOf("?")
if (questionIndex !== -1) {
const searchParams = new URLSearchParams(redirectTo.slice(questionIndex))
if (searchParams.has("redirectTo")) {
redirectTo = redirectTo.slice(0, questionIndex)
}
}

if (AUTH_PATHS.has(redirectTo)) return null

return redirectTo
}

export default function AuthCallback() {
const navigate = useNavigate()
const [error, setError] = useState<string | null>(null)
Expand All @@ -13,7 +33,7 @@ export default function AuthCallback() {
const code = params.get("code")
const errorParam = params.get("error")
const errorDescription = params.get("error_description")
const redirectTo = params.get("redirectTo")
const redirectTo = sanitizeRedirectPath(params.get("redirectTo"))

if (errorParam) {
setError(errorDescription ?? "Authentication failed")
Expand Down
Loading