1. refactor navigation + add additional nav items
2. authentication changes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
Reference in New Issue
Block a user