Compare commits

...

3 Commits

Author SHA1 Message Date
Stavros
089f926001 fix: handle empty oauth url in login page 2026-02-07 11:54:23 +02:00
Stavros
5932221d4d fix: rabbit comments 2026-02-06 18:01:47 +02:00
Stavros
a2ac5e2498 refactor: rework frontend use effect calls 2026-02-06 17:45:14 +02:00
6 changed files with 187 additions and 107 deletions

View File

@@ -0,0 +1,64 @@
type IuseRedirectUri = {
url?: URL;
valid: boolean;
trusted: boolean;
allowedProto: boolean;
httpsDowngrade: boolean;
};
export const useRedirectUri = (
redirect_uri: string | null,
cookieDomain: string,
): IuseRedirectUri => {
let isValid = false;
let isTrusted = false;
let isAllowedProto = false;
let isHttpsDowngrade = false;
if (!redirect_uri) {
return {
valid: false,
trusted: false,
allowedProto: false,
httpsDowngrade: false,
};
}
let url: URL;
try {
url = new URL(redirect_uri);
} catch {
return {
valid: false,
trusted: false,
allowedProto: false,
httpsDowngrade: false,
};
}
isValid = true;
if (
url.hostname == cookieDomain ||
url.hostname.endsWith(`.${cookieDomain}`)
) {
isTrusted = true;
}
if (url.protocol == "http:" || url.protocol == "https:") {
isAllowedProto = true;
}
if (window.location.protocol == "https:" && url.protocol == "http:") {
isHttpsDowngrade = true;
}
return {
url,
valid: isValid,
trusted: isTrusted,
allowedProto: isAllowedProto,
httpsDowngrade: isHttpsDowngrade,
};
};

View File

@@ -5,15 +5,6 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export const isValidUrl = (url: string) => {
try {
new URL(url);
return true;
} catch {
return false;
}
};
export const capitalize = (str: string) => { export const capitalize = (str: string) => {
return str.charAt(0).toUpperCase() + str.slice(1); return str.charAt(0).toUpperCase() + str.slice(1);
}; };

View File

@@ -8,10 +8,10 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { useAppContext } from "@/context/app-context"; import { useAppContext } from "@/context/app-context";
import { useUserContext } from "@/context/user-context"; import { useUserContext } from "@/context/user-context";
import { isValidUrl } from "@/lib/utils";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Navigate, useLocation, useNavigate } from "react-router"; import { Navigate, useLocation, useNavigate } from "react-router";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useRedirectUri } from "@/lib/hooks/redirect-uri";
export const ContinuePage = () => { export const ContinuePage = () => {
const { cookieDomain, disableUiWarnings } = useAppContext(); const { cookieDomain, disableUiWarnings } = useAppContext();
@@ -20,48 +20,35 @@ export const ContinuePage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [showRedirectButton, setShowRedirectButton] = useState(false); const [showRedirectButton, setShowRedirectButton] = useState(false);
const hasRedirected = useRef(false);
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const redirectUri = searchParams.get("redirect_uri"); const redirectUri = searchParams.get("redirect_uri");
const isValidRedirectUri = const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
redirectUri !== null ? isValidUrl(redirectUri) : false; redirectUri,
const redirectUriObj = isValidRedirectUri cookieDomain,
? new URL(redirectUri as string) );
: null;
const isTrustedRedirectUri =
redirectUriObj !== null
? redirectUriObj.hostname === cookieDomain ||
redirectUriObj.hostname.endsWith(`.${cookieDomain}`)
: false;
const isAllowedRedirectProto =
redirectUriObj !== null
? redirectUriObj.protocol === "https:" ||
redirectUriObj.protocol === "http:"
: false;
const isHttpsDowngrade =
redirectUriObj !== null
? redirectUriObj.protocol === "http:" &&
window.location.protocol === "https:"
: false;
const handleRedirect = () => { const handleRedirect = useCallback(() => {
setLoading(true); hasRedirected.current = true;
window.location.assign(redirectUriObj!.toString()); setIsLoading(true);
}; window.location.assign(url!);
}, [url]);
useEffect(() => { useEffect(() => {
if (!isLoggedIn) { if (!isLoggedIn) {
return; return;
} }
if (hasRedirected.current) {
return;
}
if ( if (
(!isValidRedirectUri || (!valid || !allowedProto || !trusted || httpsDowngrade) &&
!isAllowedRedirectProto ||
!isTrustedRedirectUri ||
isHttpsDowngrade) &&
!disableUiWarnings !disableUiWarnings
) { ) {
return; return;
@@ -72,7 +59,7 @@ export const ContinuePage = () => {
}, 100); }, 100);
const reveal = setTimeout(() => { const reveal = setTimeout(() => {
setLoading(false); setIsLoading(false);
setShowRedirectButton(true); setShowRedirectButton(true);
}, 5000); }, 5000);
@@ -80,22 +67,33 @@ export const ContinuePage = () => {
clearTimeout(auto); clearTimeout(auto);
clearTimeout(reveal); clearTimeout(reveal);
}; };
}); }, [
isLoggedIn,
hasRedirected,
valid,
allowedProto,
trusted,
httpsDowngrade,
disableUiWarnings,
setIsLoading,
handleRedirect,
setShowRedirectButton,
]);
if (!isLoggedIn) { if (!isLoggedIn) {
return ( return (
<Navigate <Navigate
to={`/login?redirect_uri=${encodeURIComponent(redirectUri || "")}`} to={`/login${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
replace replace
/> />
); );
} }
if (!isValidRedirectUri || !isAllowedRedirectProto) { if (!valid || !allowedProto) {
return <Navigate to="/logout" replace />; return <Navigate to="/logout" replace />;
} }
if (!isTrustedRedirectUri && !disableUiWarnings) { if (!trusted && !disableUiWarnings) {
return ( return (
<Card role="alert" aria-live="assertive" className="min-w-xs sm:min-w-sm"> <Card role="alert" aria-live="assertive" className="min-w-xs sm:min-w-sm">
<CardHeader> <CardHeader>
@@ -115,8 +113,8 @@ export const ContinuePage = () => {
</CardHeader> </CardHeader>
<CardFooter className="flex flex-col items-stretch gap-2"> <CardFooter className="flex flex-col items-stretch gap-2">
<Button <Button
onClick={handleRedirect} onClick={() => handleRedirect()}
loading={loading} loading={isLoading}
variant="destructive" variant="destructive"
> >
{t("continueTitle")} {t("continueTitle")}
@@ -124,7 +122,7 @@ export const ContinuePage = () => {
<Button <Button
onClick={() => navigate("/logout")} onClick={() => navigate("/logout")}
variant="outline" variant="outline"
disabled={loading} disabled={isLoading}
> >
{t("cancelTitle")} {t("cancelTitle")}
</Button> </Button>
@@ -133,7 +131,7 @@ export const ContinuePage = () => {
); );
} }
if (isHttpsDowngrade && !disableUiWarnings) { if (httpsDowngrade && !disableUiWarnings) {
return ( return (
<Card role="alert" aria-live="assertive" className="min-w-xs sm:min-w-sm"> <Card role="alert" aria-live="assertive" className="min-w-xs sm:min-w-sm">
<CardHeader> <CardHeader>
@@ -151,13 +149,17 @@ export const ContinuePage = () => {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardFooter className="flex flex-col items-stretch gap-2"> <CardFooter className="flex flex-col items-stretch gap-2">
<Button onClick={handleRedirect} loading={loading} variant="warning"> <Button
onClick={() => handleRedirect()}
loading={isLoading}
variant="warning"
>
{t("continueTitle")} {t("continueTitle")}
</Button> </Button>
<Button <Button
onClick={() => navigate("/logout")} onClick={() => navigate("/logout")}
variant="outline" variant="outline"
disabled={loading} disabled={isLoading}
> >
{t("cancelTitle")} {t("cancelTitle")}
</Button> </Button>
@@ -176,7 +178,7 @@ export const ContinuePage = () => {
</CardHeader> </CardHeader>
{showRedirectButton && ( {showRedirectButton && (
<CardFooter className="flex flex-col items-stretch"> <CardFooter className="flex flex-col items-stretch">
<Button onClick={handleRedirect}> <Button onClick={() => handleRedirect()}>
{t("continueRedirectManually")} {t("continueRedirectManually")}
</Button> </Button>
</CardFooter> </CardFooter>

View File

@@ -40,10 +40,11 @@ export const LoginPage = () => {
const { providers, title, oauthAutoRedirect } = useAppContext(); const { providers, title, oauthAutoRedirect } = useAppContext();
const { search } = useLocation(); const { search } = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const [oauthAutoRedirectHandover, setOauthAutoRedirectHandover] =
useState(false);
const [showRedirectButton, setShowRedirectButton] = useState(false); const [showRedirectButton, setShowRedirectButton] = useState(false);
const hasAutoRedirectedRef = useRef(false);
const redirectTimer = useRef<number | null>(null); const redirectTimer = useRef<number | null>(null);
const redirectButtonTimer = useRef<number | null>(null); const redirectButtonTimer = useRef<number | null>(null);
@@ -54,6 +55,11 @@ export const LoginPage = () => {
compiled: compiledOIDCParams, compiled: compiledOIDCParams,
} = useOIDCParams(searchParams); } = useOIDCParams(searchParams);
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
providers.find((provider) => provider.id === oauthAutoRedirect) !==
undefined && props.redirect_uri,
);
const oauthProviders = providers.filter( const oauthProviders = providers.filter(
(provider) => provider.id !== "local" && provider.id !== "ldap", (provider) => provider.id !== "local" && provider.id !== "ldap",
); );
@@ -62,10 +68,15 @@ export const LoginPage = () => {
(provider) => provider.id === "local" || provider.id === "ldap", (provider) => provider.id === "local" || provider.id === "ldap",
) !== undefined; ) !== undefined;
const oauthMutation = useMutation({ const {
mutate: oauthMutate,
data: oauthData,
isPending: oauthIsPending,
variables: oauthVariables,
} = useMutation({
mutationFn: (provider: string) => mutationFn: (provider: string) =>
axios.get( axios.get(
`/api/oauth/url/${provider}?redirect_uri=${encodeURIComponent(props.redirect_uri)}`, `/api/oauth/url/${provider}${props.redirect_uri ? `?redirect_uri=${encodeURIComponent(props.redirect_uri)}` : ""}`,
), ),
mutationKey: ["oauth"], mutationKey: ["oauth"],
onSuccess: (data) => { onSuccess: (data) => {
@@ -76,22 +87,28 @@ export const LoginPage = () => {
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
window.location.replace(data.data.url); window.location.replace(data.data.url);
}, 500); }, 500);
if (isOauthAutoRedirect) {
redirectButtonTimer.current = window.setTimeout(() => {
setShowRedirectButton(true);
}, 5000);
}
}, },
onError: () => { onError: () => {
setOauthAutoRedirectHandover(false); setIsOauthAutoRedirect(false);
toast.error(t("loginOauthFailTitle"), { toast.error(t("loginOauthFailTitle"), {
description: t("loginOauthFailSubtitle"), description: t("loginOauthFailSubtitle"),
}); });
}, },
}); });
const loginMutation = useMutation({ const { mutate: loginMutate, isPending: loginIsPending } = useMutation({
mutationFn: (values: LoginSchema) => axios.post("/api/user/login", values), mutationFn: (values: LoginSchema) => axios.post("/api/user/login", values),
mutationKey: ["login"], mutationKey: ["login"],
onSuccess: (data) => { onSuccess: (data) => {
if (data.data.totpPending) { if (data.data.totpPending) {
window.location.replace( window.location.replace(
`/totp?redirect_uri=${encodeURIComponent(props.redirect_uri)}`, `/totp${props.redirect_uri ? `?redirect_uri=${encodeURIComponent(props.redirect_uri)}` : ""}`,
); );
return; return;
} }
@@ -106,7 +123,7 @@ export const LoginPage = () => {
return; return;
} }
window.location.replace( window.location.replace(
`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`, `/continue${props.redirect_uri ? `?redirect_uri=${encodeURIComponent(props.redirect_uri)}` : ""}`,
); );
}, 500); }, 500);
}, },
@@ -122,34 +139,34 @@ export const LoginPage = () => {
useEffect(() => { useEffect(() => {
if ( if (
providers.find((provider) => provider.id === oauthAutoRedirect) &&
!isLoggedIn && !isLoggedIn &&
props.redirect_uri !== "" isOauthAutoRedirect &&
!hasAutoRedirectedRef.current &&
props.redirect_uri
) { ) {
// Not sure of a better way to do this hasAutoRedirectedRef.current = true;
// eslint-disable-next-line react-hooks/set-state-in-effect oauthMutate(oauthAutoRedirect);
setOauthAutoRedirectHandover(true);
oauthMutation.mutate(oauthAutoRedirect);
redirectButtonTimer.current = window.setTimeout(() => {
setShowRedirectButton(true);
}, 5000);
} }
}, [ }, [
providers,
isLoggedIn, isLoggedIn,
props.redirect_uri, oauthMutate,
hasAutoRedirectedRef,
oauthAutoRedirect, oauthAutoRedirect,
oauthMutation, isOauthAutoRedirect,
props.redirect_uri,
]); ]);
useEffect( useEffect(() => {
() => () => { return () => {
if (redirectTimer.current) clearTimeout(redirectTimer.current); if (redirectTimer.current) {
if (redirectButtonTimer.current) clearTimeout(redirectTimer.current);
}
if (redirectButtonTimer.current) {
clearTimeout(redirectButtonTimer.current); clearTimeout(redirectButtonTimer.current);
}, }
[], };
); }, [redirectTimer, redirectButtonTimer]);
if (isLoggedIn && isOidc) { if (isLoggedIn && isOidc) {
return <Navigate to={`/authorize?${compiledOIDCParams}`} replace />; return <Navigate to={`/authorize?${compiledOIDCParams}`} replace />;
@@ -158,7 +175,7 @@ export const LoginPage = () => {
if (isLoggedIn && props.redirect_uri !== "") { if (isLoggedIn && props.redirect_uri !== "") {
return ( return (
<Navigate <Navigate
to={`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`} to={`/continue${props.redirect_uri ? `?redirect_uri=${encodeURIComponent(props.redirect_uri)}` : ""}`}
replace replace
/> />
); );
@@ -168,7 +185,7 @@ export const LoginPage = () => {
return <Navigate to="/logout" replace />; return <Navigate to="/logout" replace />;
} }
if (oauthAutoRedirectHandover) { if (isOauthAutoRedirect) {
return ( return (
<Card className="min-w-xs sm:min-w-sm"> <Card className="min-w-xs sm:min-w-sm">
<CardHeader> <CardHeader>
@@ -183,7 +200,14 @@ export const LoginPage = () => {
<CardFooter className="flex flex-col items-stretch"> <CardFooter className="flex flex-col items-stretch">
<Button <Button
onClick={() => { onClick={() => {
window.location.replace(oauthMutation.data?.data.url); if (oauthData?.data.url) {
window.location.replace(oauthData.data.url);
} else {
setIsOauthAutoRedirect(false);
toast.error(t("loginOauthFailTitle"), {
description: t("loginOauthFailSubtitle"),
});
}
}} }}
> >
{t("loginOauthAutoRedirectButton")} {t("loginOauthAutoRedirectButton")}
@@ -214,12 +238,9 @@ export const LoginPage = () => {
title={provider.name} title={provider.name}
icon={iconMap[provider.id] ?? <OAuthIcon />} icon={iconMap[provider.id] ?? <OAuthIcon />}
className="w-full" className="w-full"
onClick={() => oauthMutation.mutate(provider.id)} onClick={() => oauthMutate(provider.id)}
loading={ loading={oauthIsPending && oauthVariables === provider.id}
oauthMutation.isPending && disabled={oauthIsPending || loginIsPending}
oauthMutation.variables === provider.id
}
disabled={oauthMutation.isPending || loginMutation.isPending}
/> />
))} ))}
</div> </div>
@@ -229,8 +250,8 @@ export const LoginPage = () => {
)} )}
{userAuthConfigured && ( {userAuthConfigured && (
<LoginForm <LoginForm
onSubmit={(values) => loginMutation.mutate(values)} onSubmit={(values) => loginMutate(values)}
loading={loginMutation.isPending || oauthMutation.isPending} loading={loginIsPending || oauthIsPending}
/> />
)} )}
{providers.length == 0 && ( {providers.length == 0 && (

View File

@@ -29,7 +29,7 @@ export const LogoutPage = () => {
}); });
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
window.location.assign("/login"); window.location.replace("/login");
}, 500); }, 500);
}, },
onError: () => { onError: () => {
@@ -39,12 +39,13 @@ export const LogoutPage = () => {
}, },
}); });
useEffect( useEffect(() => {
() => () => { return () => {
if (redirectTimer.current) clearTimeout(redirectTimer.current); if (redirectTimer.current) {
}, clearTimeout(redirectTimer.current);
[], }
); };
}, [redirectTimer]);
if (!isLoggedIn) { if (!isLoggedIn) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;

View File

@@ -45,11 +45,11 @@ export const TotpPage = () => {
if (isOidc) { if (isOidc) {
window.location.replace(`/authorize?${compiledOIDCParams}`); window.location.replace(`/authorize?${compiledOIDCParams}`);
return; return;
} else {
window.location.replace(
`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
);
} }
window.location.replace(
`/continue${props.redirect_uri ? `?redirect_uri=${encodeURIComponent(props.redirect_uri)}` : ""}`,
);
}, 500); }, 500);
}, },
onError: () => { onError: () => {
@@ -59,12 +59,13 @@ export const TotpPage = () => {
}, },
}); });
useEffect( useEffect(() => {
() => () => { return () => {
if (redirectTimer.current) clearTimeout(redirectTimer.current); if (redirectTimer.current) {
}, clearTimeout(redirectTimer.current);
[], }
); };
}, [redirectTimer]);
if (!totpPending) { if (!totpPending) {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;