feat: adapt frontend to oidc flow

This commit is contained in:
Stavros
2026-01-24 15:52:22 +02:00
parent c817e353f6
commit 71bc3966bc
8 changed files with 139 additions and 55 deletions

View File

@@ -159,6 +159,10 @@ code {
@apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold break-all; @apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold break-all;
} }
pre {
@apply bg-accent border border-border rounded-md p-2;
}
.lead { .lead {
@apply text-xl text-muted-foreground; @apply text-xl text-muted-foreground;
} }

View File

@@ -0,0 +1,53 @@
export type OIDCValues = {
scope: string;
response_type: string;
client_id: string;
redirect_uri: string;
state: string;
};
interface IuseOIDCParams {
values: OIDCValues;
compiled: string;
isOidc: boolean;
missingParams: string[];
}
const optionalParams: string[] = ["state"];
export function useOIDCParams(params: URLSearchParams): IuseOIDCParams {
let compiled: string = "";
let isOidc = false;
const missingParams: string[] = [];
const values: OIDCValues = {
scope: params.get("scope") ?? "",
response_type: params.get("response_type") ?? "",
client_id: params.get("client_id") ?? "",
redirect_uri: params.get("redirect_uri") ?? "",
state: params.get("state") ?? "",
};
for (const key of Object.keys(values)) {
if (!values[key as keyof OIDCValues]) {
if (!optionalParams.includes(key)) {
missingParams.push(key);
}
}
}
if (missingParams.length === 0) {
isOidc = true;
}
if (isOidc) {
compiled = new URLSearchParams(values).toString();
}
return {
values,
compiled,
isOidc,
missingParams,
};
}

View File

@@ -13,16 +13,7 @@ import { getOidcClientInfoScehma } from "@/schemas/oidc-schemas";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { useOIDCParams } from "@/lib/hooks/oidc";
type AuthorizePageProps = {
scope: string;
responseType: string;
clientId: string;
redirectUri: string;
state: string;
};
const optionalAuthorizeProps = ["state"];
export const AuthorizePage = () => { export const AuthorizePage = () => {
const { isLoggedIn } = useUserContext(); const { isLoggedIn } = useUserContext();
@@ -30,20 +21,16 @@ export const AuthorizePage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const {
// If there is a better way to do this, please do let me know values: props,
const props: AuthorizePageProps = { missingParams,
scope: searchParams.get("scope") || "", compiled: compiledOIDCParams,
responseType: searchParams.get("response_type") || "", } = useOIDCParams(searchParams);
clientId: searchParams.get("client_id") || "",
redirectUri: searchParams.get("redirect_uri") || "",
state: searchParams.get("state") || "",
};
const getClientInfo = useQuery({ const getClientInfo = useQuery({
queryKey: ["client", props.clientId], queryKey: ["client", props.client_id],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/api/oidc/clients/${props.clientId}`); const res = await fetch(`/api/oidc/clients/${props.client_id}`);
const data = await getOidcClientInfoScehma.parseAsync(await res.json()); const data = await getOidcClientInfoScehma.parseAsync(await res.json());
return data; return data;
}, },
@@ -53,13 +40,13 @@ export const AuthorizePage = () => {
mutationFn: () => { mutationFn: () => {
return axios.post("/api/oidc/authorize", { return axios.post("/api/oidc/authorize", {
scope: props.scope, scope: props.scope,
response_type: props.responseType, response_type: props.response_type,
client_id: props.clientId, client_id: props.client_id,
redirect_uri: props.redirectUri, redirect_uri: props.redirect_uri,
state: props.state, state: props.state,
}); });
}, },
mutationKey: ["authorize", props.clientId], mutationKey: ["authorize", props.client_id],
onSuccess: (data) => { onSuccess: (data) => {
toast.info("Authorized", { toast.info("Authorized", {
description: "You will be soon redirected to your application", description: "You will be soon redirected to your application",
@@ -74,19 +61,17 @@ export const AuthorizePage = () => {
}); });
if (!isLoggedIn) { if (!isLoggedIn) {
// TODO: Pass the params to the login page, so user can login -> authorize return <Navigate to={`/login?${compiledOIDCParams}`} replace />;
return <Navigate to="/login" replace />;
} }
Object.keys(props).forEach((key) => { if (missingParams.length > 0) {
if ( return (
!props[key as keyof AuthorizePageProps] && <Navigate
!optionalAuthorizeProps.includes(key) to={`/error?error=${encodeURIComponent(`Missing parameters: ${missingParams.join(", ")}`)}`}
) { replace
// TODO: Add reason for error />
return <Navigate to="/error" replace />; );
} }
});
if (getClientInfo.isLoading) { if (getClientInfo.isLoading) {
return ( return (
@@ -102,8 +87,12 @@ export const AuthorizePage = () => {
} }
if (getClientInfo.isError) { if (getClientInfo.isError) {
// TODO: Add reason for error return (
return <Navigate to="/error" replace />; <Navigate
to={`/error?error=${encodeURIComponent(`Failed to load client information`)}`}
replace
/>
);
} }
return ( return (

View File

@@ -80,7 +80,7 @@ export const ContinuePage = () => {
clearTimeout(auto); clearTimeout(auto);
clearTimeout(reveal); clearTimeout(reveal);
}; };
}, []); });
if (!isLoggedIn) { if (!isLoggedIn) {
return ( return (

View File

@@ -5,15 +5,30 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
export const ErrorPage = () => { export const ErrorPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { search } = useLocation();
const searchParams = new URLSearchParams(search);
const error = searchParams.get("error") ?? "";
return ( return (
<Card className="min-w-xs sm:min-w-sm"> <Card className="min-w-xs sm:min-w-sm">
<CardHeader> <CardHeader>
<CardTitle className="text-3xl">{t("errorTitle")}</CardTitle> <CardTitle className="text-3xl">{t("errorTitle")}</CardTitle>
<CardDescription>{t("errorSubtitle")}</CardDescription> <CardDescription className="flex flex-col gap-1.5">
{error ? (
<>
<p>The following error occured while processing your request:</p>
<pre>{error}</pre>
</>
) : (
<>
<p>{t("errorSubtitle")}</p>
</>
)}
</CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>
); );

View File

@@ -18,6 +18,7 @@ import { OAuthButton } from "@/components/ui/oauth-button";
import { SeperatorWithChildren } from "@/components/ui/separator"; import { SeperatorWithChildren } from "@/components/ui/separator";
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 { useOIDCParams } from "@/lib/hooks/oidc";
import { LoginSchema } from "@/schemas/login-schema"; import { LoginSchema } from "@/schemas/login-schema";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
@@ -47,7 +48,11 @@ export const LoginPage = () => {
const redirectButtonTimer = useRef<number | null>(null); const redirectButtonTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const redirectUri = searchParams.get("redirect_uri"); const {
values: props,
isOidc,
compiled: compiledOIDCParams,
} = useOIDCParams(searchParams);
const oauthProviders = providers.filter( const oauthProviders = providers.filter(
(provider) => provider.id !== "local" && provider.id !== "ldap", (provider) => provider.id !== "local" && provider.id !== "ldap",
@@ -60,7 +65,7 @@ export const LoginPage = () => {
const oauthMutation = useMutation({ const oauthMutation = useMutation({
mutationFn: (provider: string) => mutationFn: (provider: string) =>
axios.get( axios.get(
`/api/oauth/url/${provider}?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`, `/api/oauth/url/${provider}?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
), ),
mutationKey: ["oauth"], mutationKey: ["oauth"],
onSuccess: (data) => { onSuccess: (data) => {
@@ -85,9 +90,7 @@ export const LoginPage = () => {
mutationKey: ["login"], mutationKey: ["login"],
onSuccess: (data) => { onSuccess: (data) => {
if (data.data.totpPending) { if (data.data.totpPending) {
window.location.replace( window.location.replace(`/totp?${compiledOIDCParams}`);
`/totp?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
);
return; return;
} }
@@ -96,8 +99,12 @@ export const LoginPage = () => {
}); });
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
if (isOidc) {
window.location.replace(`/authorize?${compiledOIDCParams}`);
return;
}
window.location.replace( window.location.replace(
`/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`, `/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
); );
}, 500); }, 500);
}, },
@@ -115,7 +122,7 @@ export const LoginPage = () => {
if ( if (
providers.find((provider) => provider.id === oauthAutoRedirect) && providers.find((provider) => provider.id === oauthAutoRedirect) &&
!isLoggedIn && !isLoggedIn &&
redirectUri props.redirect_uri !== ""
) { ) {
// Not sure of a better way to do this // Not sure of a better way to do this
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
@@ -125,7 +132,13 @@ export const LoginPage = () => {
setShowRedirectButton(true); setShowRedirectButton(true);
}, 5000); }, 5000);
} }
}, []); }, [
providers,
isLoggedIn,
props.redirect_uri,
oauthAutoRedirect,
oauthMutation,
]);
useEffect( useEffect(
() => () => { () => () => {
@@ -136,10 +149,10 @@ export const LoginPage = () => {
[], [],
); );
if (isLoggedIn && redirectUri) { if (isLoggedIn && props.redirect_uri !== "") {
return ( return (
<Navigate <Navigate
to={`/continue?redirect_uri=${encodeURIComponent(redirectUri)}`} to={`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`}
replace replace
/> />
); );

View File

@@ -55,7 +55,7 @@ export const LogoutPage = () => {
<CardHeader> <CardHeader>
<CardTitle className="text-3xl">{t("logoutTitle")}</CardTitle> <CardTitle className="text-3xl">{t("logoutTitle")}</CardTitle>
<CardDescription> <CardDescription>
{provider !== "username" ? ( {provider !== "local" && provider !== "ldap" ? (
<Trans <Trans
i18nKey="logoutOauthSubtitle" i18nKey="logoutOauthSubtitle"
t={t} t={t}

View File

@@ -16,6 +16,7 @@ import { useEffect, useId, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router"; import { Navigate, useLocation } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useOIDCParams } from "@/lib/hooks/oidc";
export const TotpPage = () => { export const TotpPage = () => {
const { totpPending } = useUserContext(); const { totpPending } = useUserContext();
@@ -26,7 +27,11 @@ export const TotpPage = () => {
const redirectTimer = useRef<number | null>(null); const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const redirectUri = searchParams.get("redirect_uri"); const {
values: props,
isOidc,
compiled: compiledOIDCParams,
} = useOIDCParams(searchParams);
const totpMutation = useMutation({ const totpMutation = useMutation({
mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values), mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values),
@@ -37,9 +42,14 @@ export const TotpPage = () => {
}); });
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
if (isOidc) {
window.location.replace(`/authorize?${compiledOIDCParams}`);
return;
} else {
window.location.replace( window.location.replace(
`/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`, `/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
); );
}
}, 500); }, 500);
}, },
onError: () => { onError: () => {