1. refactor navigation + add additional nav items

2. authentication changes
This commit is contained in:
2026-02-07 21:17:41 -05:00
parent 8abb12d369
commit af73e162c5
6 changed files with 369 additions and 55 deletions

View File

@@ -14,6 +14,96 @@ 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;
@@ -33,6 +123,16 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
}
}, []);
// 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<HTMLFormElement>): Promise<void> {
e.preventDefault();
setLoading(true);
@@ -86,11 +186,8 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
toast.success("Login successful!", {
toastId: "login-success-toast",
});
// Check for returnUrl in query params
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get('returnUrl');
// Validate returnUrl is a relative path (security: prevent open redirect)
const returnTo = (returnUrl && returnUrl.startsWith('/')) ? returnUrl : '/';
// 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", {
@@ -109,6 +206,10 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
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 (
<div className="flex items-center justify-center px-4 py-16">
<div className="max-w-md w-full bg-white dark:bg-[#1a1a1a] rounded-2xl shadow-xl shadow-neutral-900/5 dark:shadow-black/30 border border-neutral-200/60 dark:border-neutral-800/60 px-10 py-8 text-center">
@@ -117,11 +218,11 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
You don't have permission to access this resource.
</p>
{rolesList.length > 0 && (
{displayRoles.length > 0 && (
<div className="mb-5 p-3 bg-neutral-100 dark:bg-neutral-800/50 rounded-xl border border-neutral-200/60 dark:border-neutral-700/60">
<p className="text-sm text-neutral-500 dark:text-neutral-500 mb-2 font-medium">Required role{rolesList.length > 1 ? 's' : ''}:</p>
<p className="text-sm text-neutral-500 dark:text-neutral-500 mb-2 font-medium">Required role{displayRoles.length > 1 ? 's' : ''}:</p>
<div className="flex flex-wrap justify-center gap-2">
{rolesList.map((role, i) => (
{displayRoles.map((role, i) => (
<span key={i} className="px-2.5 py-1 text-xs font-semibold bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 rounded-full">
{role}
</span>
@@ -129,6 +230,13 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
</div>
</div>
)}
{isExternalDenial && displayRoles.length === 0 && (
<div className="mb-5 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-xl border border-amber-200/60 dark:border-amber-700/40">
<p className="text-sm text-amber-700 dark:text-amber-300">
This resource requires elevated permissions.
</p>
</div>
)}
<p className="text-xs italic text-neutral-400 dark:text-neutral-500 mb-5">
If you believe this is an error, scream at codey.
</p>

View File

@@ -36,7 +36,7 @@ interface RequestJob {
}
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
const TAR_BASE_URL = "https://kr.codey.lol"; // configurable prefix
export default function RequestManagement() {
const [requests, setRequests] = useState<RequestJob[]>([]);