import React, { useState, useRef, useEffect, type FormEvent } from "react"; import Button from "@mui/joy/Button"; import { toast } from "react-toastify"; import { API_URL } from "@/config"; function getCookie(name: string): string | null { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return decodeURIComponent(parts.pop()!.split(';').shift()!); return null; } function clearCookie(name: string): void { document.cookie = `${name}=; Max-Age=0; path=/;`; } // Trusted domains for redirect validation const TRUSTED_DOMAINS = ['codey.lol', 'boatson.boats']; /** * Parse and decode the redirect URL from query params */ function getRedirectParam(): string | null { const urlParams = new URLSearchParams(window.location.search); let redirectParam = urlParams.get('redirect') || urlParams.get('returnUrl'); if (!redirectParam) return null; // Handle double-encoding if (redirectParam.includes('%')) { try { redirectParam = decodeURIComponent(redirectParam); } catch { // If decoding fails, use the original value } } return redirectParam; } /** * Check if a URL is a valid trusted external URL */ function isTrustedExternalUrl(url: string): boolean { try { const parsed = new URL(url); if (parsed.protocol !== 'https:') return false; return TRUSTED_DOMAINS.some(domain => parsed.hostname === domain || parsed.hostname.endsWith('.' + domain) ); } catch { return false; } } /** * Get validated redirect URL for POST-LOGIN redirect (after form submission) * Allows: relative paths OR trusted external URLs * This is safe because the user just authenticated fresh */ function getPostLoginRedirectUrl(): string { const redirectParam = getRedirectParam(); if (!redirectParam) return '/'; // Relative path: must start with '/' but not '//' (protocol-relative) if (redirectParam.startsWith('/') && !redirectParam.startsWith('//')) { return redirectParam; } // External URL: must be https and trusted domain if (isTrustedExternalUrl(redirectParam)) { return redirectParam; } return '/'; } /** * Get validated redirect URL for AUTO-REDIRECT (when already logged in) * Only allows: relative paths * External URLs are BLOCKED because if a logged-in user was redirected here * from an external resource, nginx denied them access - redirecting back would loop */ function getAutoRedirectUrl(): string | null { const redirectParam = getRedirectParam(); if (!redirectParam) return null; // Only allow relative paths for auto-redirect if (redirectParam.startsWith('/') && !redirectParam.startsWith('//')) { return redirectParam; } // Block external URLs - would cause redirect loop with nginx return null; } /** * Check if the returnUrl is an external trusted domain * Used to detect access denial from nginx-protected external vhosts */ function isExternalReturnUrl(): boolean { const redirectParam = getRedirectParam(); if (!redirectParam) return false; if (redirectParam.startsWith('/')) return false; return isTrustedExternalUrl(redirectParam); } interface LoginPageProps { loggedIn?: boolean; accessDenied?: boolean; requiredRoles?: string[] | string; } export default function LoginPage({ loggedIn = false, accessDenied = false, requiredRoles = [] }: LoginPageProps): React.ReactElement { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); const passwordRef = useRef(null); useEffect(() => { if (passwordRef.current && password === "") { passwordRef.current.value = ""; } }, []); // If user is already logged in and not access denied, redirect them // This handles the case where token was refreshed successfully // NOTE: Only auto-redirect to relative URLs - external URLs mean nginx denied access useEffect(() => { if (loggedIn && !accessDenied) { const returnTo = getAutoRedirectUrl() || '/'; window.location.href = returnTo; } }, [loggedIn, accessDenied]); async function handleSubmit(e: FormEvent): Promise { e.preventDefault(); setLoading(true); try { if (!username || !password) { setLoading(false); if (!toast.isActive("login-missing-data-toast")) { toast.error("Username and password are required", { toastId: "login-missing-data-toast", }); } return; } const formData = new URLSearchParams({ username, password, grant_type: "password", scope: "", client_id: "b8308cf47d424e66", client_secret: "", }); const resp = await fetch(`${API_URL}/auth/login`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, credentials: "include", body: formData.toString(), }); if (resp.status === 401) { toast.error("Invalid username or password", { toastId: "login-error-invalid-toast", }); setLoading(false); return; } if (!resp.ok) { const data = await resp.json().catch(() => ({})); toast.error(data.detail ? `Login failed: ${data.detail}` : "Login failed", { toastId: "login-error-failed-toast", }); setLoading(false); return; } const data = await resp.json(); if (data.access_token) { toast.success("Login successful!", { toastId: "login-success-toast", }); // After fresh login, allow redirect to external trusted URLs const returnTo = getPostLoginRedirectUrl(); window.location.href = returnTo; } else { toast.error("Login failed: no access token received", { toastId: "login-error-no-token-toast", }); setLoading(false); } } catch (error) { toast.error("Network error during login", { toastId: "login-error-network-toast", }); console.error("Login error:", error); setLoading(false); } } if (loggedIn) { const rolesList = Array.isArray(requiredRoles) ? requiredRoles : (requiredRoles ? requiredRoles.split(',') : []); // Check if this is an external resource denial (nginx redirect) const isExternalDenial = rolesList.some(r => r === '(external resource)'); const displayRoles = rolesList.filter(r => r !== '(external resource)'); return (
Logo

Access Denied

You don't have permission to access this resource.

{displayRoles.length > 0 && (

Required role{displayRoles.length > 1 ? 's' : ''}:

{displayRoles.map((role, i) => ( {role} ))}
)} {isExternalDenial && displayRoles.length === 0 && (

This resource requires elevated permissions.

)}

If you believe this is an error, scream at codey.

); } return (
Logo

Log In

Sign in to continue

setUsername(e.target.value)} required disabled={loading} className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 text-neutral-900 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none" placeholder="Enter your username" />
setPassword(e.target.value)} required disabled={loading} className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 text-neutral-900 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none" placeholder="Enter your password" />
); }