mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-03 08:39:09 +00:00
add server action proxies
This commit is contained in:
@@ -3,9 +3,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { generateOidcUrlProxy } from "@app/actions/server";
|
||||
|
||||
type AutoLoginHandlerProps = {
|
||||
resourceId: number;
|
||||
@@ -40,24 +40,38 @@ export default function AutoLoginHandler({
|
||||
async function initiateAutoLogin() {
|
||||
setLoading(true);
|
||||
|
||||
let doRedirect: string | undefined;
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<GenerateOidcUrlResponse>
|
||||
>(`/auth/idp/${skipToIdpId}/oidc/generate-url`, {
|
||||
const response = await generateOidcUrlProxy(
|
||||
skipToIdpId,
|
||||
redirectUrl
|
||||
});
|
||||
);
|
||||
|
||||
if (res.data.data.redirectUrl) {
|
||||
// Redirect to the IDP for authentication
|
||||
window.location.href = res.data.data.redirectUrl;
|
||||
if (response.error) {
|
||||
setError(response.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
const url = data?.redirectUrl;
|
||||
if (url) {
|
||||
doRedirect = url;
|
||||
} else {
|
||||
setError(t("autoLoginErrorNoRedirectUrl"));
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error("Failed to generate OIDC URL:", e);
|
||||
setError(formatAxiosError(e, t("autoLoginErrorGeneratingUrl")));
|
||||
setError(
|
||||
t("autoLoginErrorGeneratingUrl", {
|
||||
defaultValue: "An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (doRedirect) {
|
||||
redirect(doRedirect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import { AxiosResponse } from "axios";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { LockIcon, FingerprintIcon } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
@@ -42,6 +41,14 @@ import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
import {
|
||||
generateOidcUrlProxy,
|
||||
loginProxy,
|
||||
securityKeyStartProxy,
|
||||
securityKeyVerifyProxy
|
||||
} from "@app/actions/server";
|
||||
import { redirect as redirectTo } from "next/navigation";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
export type LoginFormIDP = {
|
||||
idpId: number;
|
||||
@@ -70,6 +77,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
|
||||
|
||||
const t = useTranslations();
|
||||
const currentHost = typeof window !== "undefined" ? window.location.hostname : "";
|
||||
const expectedHost = new URL(env.app.dashboardUrl).host;
|
||||
const isExpectedHost = currentHost === expectedHost;
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email({ message: t("emailInvalid") }),
|
||||
@@ -102,39 +112,39 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
|
||||
try {
|
||||
// Start WebAuthn authentication without email
|
||||
const startRes = await api.post(
|
||||
"/auth/security-key/authenticate/start",
|
||||
{}
|
||||
);
|
||||
const startResponse = await securityKeyStartProxy({});
|
||||
|
||||
if (!startRes) {
|
||||
setError(
|
||||
t("securityKeyAuthError", {
|
||||
defaultValue:
|
||||
"Failed to start security key authentication"
|
||||
})
|
||||
);
|
||||
if (startResponse.error) {
|
||||
setError(startResponse.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const { tempSessionId, ...options } = startRes.data.data;
|
||||
const { tempSessionId, ...options } = startResponse.data!;
|
||||
|
||||
// Perform WebAuthn authentication
|
||||
try {
|
||||
const credential = await startAuthentication(options);
|
||||
const credential = await startAuthentication({
|
||||
optionsJSON: {
|
||||
...options,
|
||||
userVerification: options.userVerification as
|
||||
| "required"
|
||||
| "preferred"
|
||||
| "discouraged"
|
||||
}
|
||||
});
|
||||
|
||||
// Verify authentication
|
||||
const verifyRes = await api.post(
|
||||
"/auth/security-key/authenticate/verify",
|
||||
const verifyResponse = await securityKeyVerifyProxy(
|
||||
{ credential },
|
||||
{
|
||||
headers: {
|
||||
"X-Temp-Session-Id": tempSessionId
|
||||
}
|
||||
}
|
||||
tempSessionId
|
||||
);
|
||||
|
||||
if (verifyRes) {
|
||||
if (verifyResponse.error) {
|
||||
setError(verifyResponse.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (verifyResponse.success) {
|
||||
if (onLogin) {
|
||||
await onLogin();
|
||||
}
|
||||
@@ -208,30 +218,44 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
setShowSecurityKeyPrompt(false);
|
||||
|
||||
try {
|
||||
const res = await api.post<AxiosResponse<LoginResponse>>(
|
||||
"/auth/login",
|
||||
{
|
||||
email,
|
||||
password,
|
||||
code
|
||||
const response = await loginProxy({
|
||||
email,
|
||||
password,
|
||||
code
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
setError(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
|
||||
// Handle case where data is null (e.g., already logged in)
|
||||
if (!data) {
|
||||
if (onLogin) {
|
||||
await onLogin();
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data.data;
|
||||
|
||||
if (data?.useSecurityKey) {
|
||||
if (data.useSecurityKey) {
|
||||
await initiateSecurityKeyAuth();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.codeRequested) {
|
||||
if (data.codeRequested) {
|
||||
setMfaRequested(true);
|
||||
setLoading(false);
|
||||
mfaForm.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.emailVerificationRequired) {
|
||||
if (data.emailVerificationRequired) {
|
||||
if (!isExpectedHost) {
|
||||
setError(t("emailVerificationRequired", { dashboardUrl: env.app.dashboardUrl }));
|
||||
return;
|
||||
}
|
||||
if (redirect) {
|
||||
router.push(`/auth/verify-email?redirect=${redirect}`);
|
||||
} else {
|
||||
@@ -240,7 +264,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.twoFactorSetupRequired) {
|
||||
if (data.twoFactorSetupRequired) {
|
||||
if (!isExpectedHost) {
|
||||
setError(t("twoFactorSetupRequired", { dashboardUrl: env.app.dashboardUrl }));
|
||||
return;
|
||||
}
|
||||
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`;
|
||||
router.push(setupUrl);
|
||||
return;
|
||||
@@ -275,25 +303,26 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
}
|
||||
|
||||
async function loginWithIdp(idpId: number) {
|
||||
let redirectUrl: string | undefined;
|
||||
try {
|
||||
const res = await api.post<AxiosResponse<GenerateOidcUrlResponse>>(
|
||||
`/auth/idp/${idpId}/oidc/generate-url`,
|
||||
{
|
||||
redirectUrl: redirect || "/"
|
||||
}
|
||||
const data = await generateOidcUrlProxy(
|
||||
idpId,
|
||||
redirect || "/"
|
||||
);
|
||||
|
||||
console.log(res);
|
||||
|
||||
if (!res) {
|
||||
setError(t("loginError"));
|
||||
const url = data.data?.redirectUrl;
|
||||
if (data.error) {
|
||||
setError(data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data.data;
|
||||
window.location.href = data.redirectUrl;
|
||||
} catch (e) {
|
||||
console.error(formatAxiosError(e));
|
||||
if (url) {
|
||||
redirectUrl = url;
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || t("loginError"));
|
||||
console.error(e);
|
||||
}
|
||||
if (redirectUrl) {
|
||||
redirectTo(redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +384,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||
href={`${env.app.dashboardUrl}/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("passwordForgot")}
|
||||
|
||||
@@ -39,6 +39,12 @@ import {
|
||||
AuthWithWhitelistResponse
|
||||
} from "@server/routers/resource";
|
||||
import ResourceAccessDenied from "@app/components/ResourceAccessDenied";
|
||||
import {
|
||||
resourcePasswordProxy,
|
||||
resourcePincodeProxy,
|
||||
resourceWhitelistProxy,
|
||||
resourceAccessProxy,
|
||||
} from "@app/actions/server";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
@@ -173,100 +179,126 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
return fullUrl.toString();
|
||||
}
|
||||
|
||||
const onWhitelistSubmit = (values: any) => {
|
||||
const onWhitelistSubmit = async (values: any) => {
|
||||
setLoadingLogin(true);
|
||||
api.post<AxiosResponse<AuthWithWhitelistResponse>>(
|
||||
`/auth/resource/${props.resource.id}/whitelist`,
|
||||
{ email: values.email, otp: values.otp }
|
||||
)
|
||||
.then((res) => {
|
||||
setWhitelistError(null);
|
||||
setWhitelistError(null);
|
||||
|
||||
if (res.data.data.otpSent) {
|
||||
setOtpState("otp_sent");
|
||||
submitOtpForm.setValue("email", values.email);
|
||||
toast({
|
||||
title: t("otpEmailSent"),
|
||||
description: t("otpEmailSentDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await resourceWhitelistProxy(props.resource.id, {
|
||||
email: values.email,
|
||||
otp: values.otp
|
||||
});
|
||||
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setWhitelistError(
|
||||
formatAxiosError(e, t("otpEmailErrorAuthenticate"))
|
||||
);
|
||||
})
|
||||
.then(() => setLoadingLogin(false));
|
||||
};
|
||||
|
||||
const onPinSubmit = (values: z.infer<typeof pinSchema>) => {
|
||||
setLoadingLogin(true);
|
||||
api.post<AxiosResponse<AuthWithPasswordResponse>>(
|
||||
`/auth/resource/${props.resource.id}/pincode`,
|
||||
{ pincode: values.pin }
|
||||
)
|
||||
.then((res) => {
|
||||
setPincodeError(null);
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setPincodeError(
|
||||
formatAxiosError(e, t("pincodeErrorAuthenticate"))
|
||||
);
|
||||
})
|
||||
.then(() => setLoadingLogin(false));
|
||||
};
|
||||
|
||||
const onPasswordSubmit = (values: z.infer<typeof passwordSchema>) => {
|
||||
setLoadingLogin(true);
|
||||
|
||||
api.post<AxiosResponse<AuthWithPasswordResponse>>(
|
||||
`/auth/resource/${props.resource.id}/password`,
|
||||
{
|
||||
password: values.password
|
||||
if (response.error) {
|
||||
setWhitelistError(response.message);
|
||||
return;
|
||||
}
|
||||
)
|
||||
.then((res) => {
|
||||
setPasswordError(null);
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setPasswordError(
|
||||
formatAxiosError(e, t("passwordErrorAuthenticate"))
|
||||
|
||||
const data = response.data!;
|
||||
if (data.otpSent) {
|
||||
setOtpState("otp_sent");
|
||||
submitOtpForm.setValue("email", values.email);
|
||||
toast({
|
||||
title: t("otpEmailSent"),
|
||||
description: t("otpEmailSentDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = data.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
})
|
||||
.finally(() => setLoadingLogin(false));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setWhitelistError(
|
||||
t("otpEmailErrorAuthenticate", {
|
||||
defaultValue: "An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setLoadingLogin(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPinSubmit = async (values: z.infer<typeof pinSchema>) => {
|
||||
setLoadingLogin(true);
|
||||
setPincodeError(null);
|
||||
|
||||
try {
|
||||
const response = await resourcePincodeProxy(props.resource.id, {
|
||||
pincode: values.pin
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
setPincodeError(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = response.data!.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setPincodeError(
|
||||
t("pincodeErrorAuthenticate", {
|
||||
defaultValue: "An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setLoadingLogin(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPasswordSubmit = async (values: z.infer<typeof passwordSchema>) => {
|
||||
setLoadingLogin(true);
|
||||
setPasswordError(null);
|
||||
|
||||
try {
|
||||
const response = await resourcePasswordProxy(props.resource.id, {
|
||||
password: values.password
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
setPasswordError(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = response.data!.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setPasswordError(
|
||||
t("passwordErrorAuthenticate", {
|
||||
defaultValue: "An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setLoadingLogin(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function handleSSOAuth() {
|
||||
let isAllowed = false;
|
||||
try {
|
||||
await api.get(`/resource/${props.resource.id}`);
|
||||
isAllowed = true;
|
||||
const response = await resourceAccessProxy(props.resource.id);
|
||||
if (response.error) {
|
||||
setAccessDenied(true);
|
||||
} else {
|
||||
isAllowed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
setAccessDenied(true);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { validateOidcUrlCallbackProxy } from "@app/actions/server";
|
||||
|
||||
type ValidateOidcTokenParams = {
|
||||
orgId: string;
|
||||
@@ -54,17 +55,27 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<ValidateOidcUrlCallbackResponse>
|
||||
>(`/auth/idp/${props.idpId}/oidc/validate-callback`, {
|
||||
code: props.code,
|
||||
state: props.expectedState,
|
||||
storedState: props.stateCookie
|
||||
});
|
||||
const response = await validateOidcUrlCallbackProxy(
|
||||
props.idpId,
|
||||
props.code || "",
|
||||
props.expectedState || "",
|
||||
props.stateCookie || ""
|
||||
);
|
||||
|
||||
console.log(t('idpOidcTokenResponse'), res.data);
|
||||
if (response.error) {
|
||||
setError(response.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectUrl = res.data.data.redirectUrl;
|
||||
const data = response.data;
|
||||
if (!data) {
|
||||
setError("Unable to validate OIDC token");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectUrl = data.redirectUrl;
|
||||
|
||||
if (!redirectUrl) {
|
||||
router.push("/");
|
||||
@@ -74,9 +85,9 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (redirectUrl.startsWith("http")) {
|
||||
window.location.href = res.data.data.redirectUrl; // this is validated by the parent using this component
|
||||
window.location.href = data.redirectUrl; // this is validated by the parent using this component
|
||||
} else {
|
||||
router.push(res.data.data.redirectUrl);
|
||||
router.push(data.redirectUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(formatAxiosError(e, t('idpErrorOidcTokenValidating')));
|
||||
|
||||
Reference in New Issue
Block a user