mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-26 14:56:39 +00:00
add server action proxies
This commit is contained in:
@@ -1519,5 +1519,7 @@
|
|||||||
"domainPickerSubdomainSanitized": "Subdomain sanitized",
|
"domainPickerSubdomainSanitized": "Subdomain sanitized",
|
||||||
"domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"",
|
"domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"",
|
||||||
"resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml",
|
"resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml",
|
||||||
"resourceExposePortsEditFile": "Edit file: docker-compose.yml"
|
"resourceExposePortsEditFile": "Edit file: docker-compose.yml",
|
||||||
|
"emailVerificationRequired": "Email verification is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.",
|
||||||
|
"twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here."
|
||||||
}
|
}
|
||||||
|
|||||||
394
src/actions/server.ts
Normal file
394
src/actions/server.ts
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { ResponseT } from "@server/types/Response";
|
||||||
|
|
||||||
|
type CookieOptions = {
|
||||||
|
path?: string;
|
||||||
|
httpOnly?: boolean;
|
||||||
|
secure?: boolean;
|
||||||
|
sameSite?: "lax" | "strict" | "none";
|
||||||
|
expires?: Date;
|
||||||
|
maxAge?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseSetCookieString(setCookie: string): {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
options: CookieOptions;
|
||||||
|
} {
|
||||||
|
const parts = setCookie.split(";").map((p) => p.trim());
|
||||||
|
const [nameValue, ...attrParts] = parts;
|
||||||
|
const [name, ...valParts] = nameValue.split("=");
|
||||||
|
const value = valParts.join("="); // handles '=' inside JWT
|
||||||
|
|
||||||
|
const options: CookieOptions = {};
|
||||||
|
|
||||||
|
for (const attr of attrParts) {
|
||||||
|
const [k, v] = attr.split("=").map((s) => s.trim());
|
||||||
|
switch (k.toLowerCase()) {
|
||||||
|
case "path":
|
||||||
|
options.path = v;
|
||||||
|
break;
|
||||||
|
case "httponly":
|
||||||
|
options.httpOnly = true;
|
||||||
|
break;
|
||||||
|
case "secure":
|
||||||
|
options.secure = true;
|
||||||
|
break;
|
||||||
|
case "samesite":
|
||||||
|
options.sameSite =
|
||||||
|
v?.toLowerCase() as CookieOptions["sameSite"];
|
||||||
|
break;
|
||||||
|
case "expires":
|
||||||
|
options.expires = new Date(v);
|
||||||
|
break;
|
||||||
|
case "max-age":
|
||||||
|
options.maxAge = parseInt(v, 10);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, value, options };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeApiRequest<T>(
|
||||||
|
url: string,
|
||||||
|
method: "GET" | "POST",
|
||||||
|
body?: any,
|
||||||
|
additionalHeaders: Record<string, string> = {}
|
||||||
|
): Promise<ResponseT<T>> {
|
||||||
|
// Get existing cookies to forward
|
||||||
|
const allCookies = await cookies();
|
||||||
|
const cookieHeader = allCookies.toString();
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": "x-csrf-protection",
|
||||||
|
...(cookieHeader && { Cookie: cookieHeader }),
|
||||||
|
...additionalHeaders
|
||||||
|
};
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
});
|
||||||
|
} catch (fetchError) {
|
||||||
|
console.error("API request failed:", fetchError);
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
error: true,
|
||||||
|
message: "Failed to connect to server. Please try again.",
|
||||||
|
status: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Set-Cookie header
|
||||||
|
const rawSetCookie = res.headers.get("set-cookie");
|
||||||
|
if (rawSetCookie) {
|
||||||
|
try {
|
||||||
|
const { name, value, options } = parseSetCookieString(rawSetCookie);
|
||||||
|
const allCookies = await cookies();
|
||||||
|
allCookies.set(name, value, options);
|
||||||
|
} catch (cookieError) {
|
||||||
|
console.error("Failed to parse Set-Cookie header:", cookieError);
|
||||||
|
// Continue without setting cookies rather than failing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let responseData;
|
||||||
|
try {
|
||||||
|
responseData = await res.json();
|
||||||
|
} catch (jsonError) {
|
||||||
|
console.error("Failed to parse response JSON:", jsonError);
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
error: true,
|
||||||
|
message: "Invalid response format from server. Please try again.",
|
||||||
|
status: res.status
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseData) {
|
||||||
|
console.error("Invalid response structure:", responseData);
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
error: true,
|
||||||
|
message:
|
||||||
|
"Invalid response structure from server. Please try again.",
|
||||||
|
status: res.status
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the API returned an error, return the error message
|
||||||
|
if (!res.ok || responseData.error) {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
error: true,
|
||||||
|
message:
|
||||||
|
responseData.message ||
|
||||||
|
`Server responded with ${res.status}: ${res.statusText}`,
|
||||||
|
status: res.status
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle successful responses where data can be null
|
||||||
|
if (responseData.success && responseData.data === null) {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: responseData.message || "Success",
|
||||||
|
status: res.status
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseData.data) {
|
||||||
|
console.error("Invalid response structure:", responseData);
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
error: true,
|
||||||
|
message:
|
||||||
|
"Invalid response structure from server. Please try again.",
|
||||||
|
status: res.status
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: responseData.data,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: responseData.message || "Success",
|
||||||
|
status: res.status
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AUTH TYPES AND FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type LoginRequest = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginResponse = {
|
||||||
|
useSecurityKey?: boolean;
|
||||||
|
codeRequested?: boolean;
|
||||||
|
emailVerificationRequired?: boolean;
|
||||||
|
twoFactorSetupRequired?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SecurityKeyStartRequest = {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SecurityKeyStartResponse = {
|
||||||
|
tempSessionId: string;
|
||||||
|
challenge: string;
|
||||||
|
allowCredentials: any[];
|
||||||
|
timeout: number;
|
||||||
|
rpId: string;
|
||||||
|
userVerification: "required" | "preferred" | "discouraged";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SecurityKeyVerifyRequest = {
|
||||||
|
credential: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SecurityKeyVerifyResponse = {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loginProxy(
|
||||||
|
request: LoginRequest
|
||||||
|
): Promise<ResponseT<LoginResponse>> {
|
||||||
|
const serverPort = process.env.SERVER_EXTERNAL_PORT;
|
||||||
|
const url = `http://localhost:${serverPort}/api/v1/auth/login`;
|
||||||
|
|
||||||
|
console.log("Making login request to:", url);
|
||||||
|
|
||||||
|
return await makeApiRequest<LoginResponse>(url, "POST", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function securityKeyStartProxy(
|
||||||
|
request: SecurityKeyStartRequest
|
||||||
|
): Promise<ResponseT<SecurityKeyStartResponse>> {
|
||||||
|
const serverPort = process.env.SERVER_EXTERNAL_PORT;
|
||||||
|
const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/start`;
|
||||||
|
|
||||||
|
console.log("Making security key start request to:", url);
|
||||||
|
|
||||||
|
return await makeApiRequest<SecurityKeyStartResponse>(url, "POST", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function securityKeyVerifyProxy(
|
||||||
|
request: SecurityKeyVerifyRequest,
|
||||||
|
tempSessionId: string
|
||||||
|
): Promise<ResponseT<SecurityKeyVerifyResponse>> {
|
||||||
|
const serverPort = process.env.SERVER_EXTERNAL_PORT;
|
||||||
|
const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/verify`;
|
||||||
|
|
||||||
|
console.log("Making security key verify request to:", url);
|
||||||
|
|
||||||
|
return await makeApiRequest<SecurityKeyVerifyResponse>(
|
||||||
|
url,
|
||||||
|
"POST",
|
||||||
|
request,
|
||||||
|
{
|
||||||
|
"X-Temp-Session-Id": tempSessionId
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RESOURCE TYPES AND FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ResourcePasswordRequest = {
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourcePasswordResponse = {
|
||||||
|
session?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourcePincodeRequest = {
|
||||||
|
pincode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourcePincodeResponse = {
|
||||||
|
session?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourceWhitelistRequest = {
|
||||||
|
email: string;
|
||||||
|
otp?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourceWhitelistResponse = {
|
||||||
|
otpSent?: boolean;
|
||||||
|
session?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourceAccessResponse = {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resourcePasswordProxy(
|
||||||
|
resourceId: number,
|
||||||
|
request: ResourcePasswordRequest
|
||||||
|
): Promise<ResponseT<ResourcePasswordResponse>> {
|
||||||
|
const serverPort = process.env.SERVER_EXTERNAL_PORT;
|
||||||
|
const url = `http://localhost:${serverPort}/api/v1/auth/resource/${resourceId}/password`;
|
||||||
|
|
||||||
|
console.log("Making resource password request to:", url);
|
||||||
|
|
||||||
|
return await makeApiRequest<ResourcePasswordResponse>(url, "POST", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resourcePincodeProxy(
|
||||||
|
resourceId: number,
|
||||||
|
request: ResourcePincodeRequest
|
||||||
|
): Promise<ResponseT<ResourcePincodeResponse>> {
|
||||||
|
const serverPort = process.env.SERVER_EXTERNAL_PORT;
|
||||||
|
const url = `http://localhost:${serverPort}/api/v1/auth/resource/${resourceId}/pincode`;
|
||||||
|
|
||||||
|
console.log("Making resource pincode request to:", url);
|
||||||
|
|
||||||
|
return await makeApiRequest<ResourcePincodeResponse>(url, "POST", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resourceWhitelistProxy(
|
||||||
|
resourceId: number,
|
||||||
|
request: ResourceWhitelistRequest
|
||||||
|
): Promise<ResponseT<ResourceWhitelistResponse>> {
|
||||||
|
const serverPort = process.env.SERVER_EXTERNAL_PORT;
|
||||||
|
const url = `http://localhost:${serverPort}/api/v1/auth/resource/${resourceId}/whitelist`;
|
||||||
|
|
||||||
|
console.log("Making resource whitelist request to:", url);
|
||||||
|
|
||||||
|
return await makeApiRequest<ResourceWhitelistResponse>(
|
||||||
|
url,
|
||||||
|
"POST",
|
||||||
|
request
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resourceAccessProxy(
|
||||||
|
resourceId: number
|
||||||
|
): Promise<ResponseT<ResourceAccessResponse>> {
|
||||||
|
const serverPort = process.env.SERVER_EXTERNAL_PORT;
|
||||||
|
const url = `http://localhost:${serverPort}/api/v1/resource/${resourceId}`;
|
||||||
|
|
||||||
|
console.log("Making resource access request to:", url);
|
||||||
|
|
||||||
|
return await makeApiRequest<ResourceAccessResponse>(url, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// IDP TYPES AND FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type GenerateOidcUrlRequest = {
|
||||||
|
redirectUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateOidcUrlResponse = {
|
||||||
|
redirectUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidateOidcUrlCallbackRequest = {
|
||||||
|
code: string;
|
||||||
|
state: string;
|
||||||
|
storedState: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidateOidcUrlCallbackResponse = {
|
||||||
|
redirectUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function validateOidcUrlCallbackProxy(
|
||||||
|
idpId: string,
|
||||||
|
code: string,
|
||||||
|
expectedState: string,
|
||||||
|
stateCookie: string,
|
||||||
|
loginPageId?: number
|
||||||
|
): Promise<ResponseT<ValidateOidcUrlCallbackResponse>> {
|
||||||
|
const serverPort = process.env.SERVER_EXTERNAL_PORT;
|
||||||
|
const url = `http://localhost:${serverPort}/api/v1/auth/idp/${idpId}/oidc/validate-callback${loginPageId ? "?loginPageId=" + loginPageId : ""}`;
|
||||||
|
|
||||||
|
console.log("Making OIDC callback validation request to:", url);
|
||||||
|
|
||||||
|
return await makeApiRequest<ValidateOidcUrlCallbackResponse>(url, "POST", {
|
||||||
|
code: code,
|
||||||
|
state: expectedState,
|
||||||
|
storedState: stateCookie
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateOidcUrlProxy(
|
||||||
|
idpId: number,
|
||||||
|
redirect: string,
|
||||||
|
orgId?: string
|
||||||
|
): Promise<ResponseT<GenerateOidcUrlResponse>> {
|
||||||
|
const serverPort = process.env.SERVER_EXTERNAL_PORT;
|
||||||
|
const url = `http://localhost:${serverPort}/api/v1/auth/idp/${idpId}/oidc/generate-url${orgId ? `?orgId=${orgId}` : ""}`;
|
||||||
|
|
||||||
|
console.log("Making OIDC URL generation request to:", url);
|
||||||
|
|
||||||
|
return await makeApiRequest<GenerateOidcUrlResponse>(url, "POST", {
|
||||||
|
redirectUrl: redirect || "/"
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import ResetPasswordForm from "@app/components/ResetPasswordForm";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -22,7 +24,19 @@ export default async function Page(props: {
|
|||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
redirect("/");
|
let loggedOut = false;
|
||||||
|
try {
|
||||||
|
// log out the user if they are logged in
|
||||||
|
await internal.post(
|
||||||
|
"/auth/logout",
|
||||||
|
undefined,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
loggedOut = true;
|
||||||
|
} catch (e) {}
|
||||||
|
if (!loggedOut) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let redirectUrl: string | undefined = undefined;
|
let redirectUrl: string | undefined = undefined;
|
||||||
@@ -45,8 +59,8 @@ export default async function Page(props: {
|
|||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
!searchParams.redirect
|
!searchParams.redirect
|
||||||
? `/auth/signup`
|
? `/auth/login`
|
||||||
: `/auth/signup?redirect=${redirectUrl}`
|
: `/auth/login?redirect=${redirectUrl}`
|
||||||
}
|
}
|
||||||
className="underline"
|
className="underline"
|
||||||
>
|
>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -29,10 +29,13 @@ export default async function Page(props: {
|
|||||||
const getUser = cache(verifySession);
|
const getUser = cache(verifySession);
|
||||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||||
|
|
||||||
const setupRes = await internal.get<
|
let complete = false;
|
||||||
|
try {
|
||||||
|
const setupRes = await internal.get<
|
||||||
AxiosResponse<InitialSetupCompleteResponse>
|
AxiosResponse<InitialSetupCompleteResponse>
|
||||||
>(`/auth/initial-setup-complete`, await authCookieHeader());
|
>(`/auth/initial-setup-complete`, await authCookieHeader());
|
||||||
const complete = setupRes.data.data.complete;
|
complete = setupRes.data.data.complete;
|
||||||
|
} catch (e) {}
|
||||||
if (!complete) {
|
if (!complete) {
|
||||||
redirect("/auth/initial-setup");
|
redirect("/auth/initial-setup");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useRouter } from "next/navigation";
|
import { redirect, useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
@@ -16,6 +15,7 @@ import {
|
|||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { generateOidcUrlProxy } from "@app/actions/server";
|
||||||
|
|
||||||
type AutoLoginHandlerProps = {
|
type AutoLoginHandlerProps = {
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
@@ -40,24 +40,38 @@ export default function AutoLoginHandler({
|
|||||||
async function initiateAutoLogin() {
|
async function initiateAutoLogin() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
let doRedirect: string | undefined;
|
||||||
try {
|
try {
|
||||||
const res = await api.post<
|
const response = await generateOidcUrlProxy(
|
||||||
AxiosResponse<GenerateOidcUrlResponse>
|
skipToIdpId,
|
||||||
>(`/auth/idp/${skipToIdpId}/oidc/generate-url`, {
|
|
||||||
redirectUrl
|
redirectUrl
|
||||||
});
|
);
|
||||||
|
|
||||||
if (res.data.data.redirectUrl) {
|
if (response.error) {
|
||||||
// Redirect to the IDP for authentication
|
setError(response.message);
|
||||||
window.location.href = res.data.data.redirectUrl;
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
const url = data?.redirectUrl;
|
||||||
|
if (url) {
|
||||||
|
doRedirect = url;
|
||||||
} else {
|
} else {
|
||||||
setError(t("autoLoginErrorNoRedirectUrl"));
|
setError(t("autoLoginErrorNoRedirectUrl"));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error("Failed to generate OIDC URL:", e);
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
if (doRedirect) {
|
||||||
|
redirect(doRedirect);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { AxiosResponse } from "axios";
|
|||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { LockIcon, FingerprintIcon } from "lucide-react";
|
import { LockIcon, FingerprintIcon } from "lucide-react";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import {
|
import {
|
||||||
InputOTP,
|
InputOTP,
|
||||||
InputOTPGroup,
|
InputOTPGroup,
|
||||||
@@ -42,6 +41,14 @@ import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
|||||||
import { Separator } from "./ui/separator";
|
import { Separator } from "./ui/separator";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { startAuthentication } from "@simplewebauthn/browser";
|
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 = {
|
export type LoginFormIDP = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
@@ -70,6 +77,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||||||
const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
|
const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
|
||||||
|
|
||||||
const t = useTranslations();
|
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({
|
const formSchema = z.object({
|
||||||
email: z.string().email({ message: t("emailInvalid") }),
|
email: z.string().email({ message: t("emailInvalid") }),
|
||||||
@@ -102,39 +112,39 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Start WebAuthn authentication without email
|
// Start WebAuthn authentication without email
|
||||||
const startRes = await api.post(
|
const startResponse = await securityKeyStartProxy({});
|
||||||
"/auth/security-key/authenticate/start",
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!startRes) {
|
if (startResponse.error) {
|
||||||
setError(
|
setError(startResponse.message);
|
||||||
t("securityKeyAuthError", {
|
|
||||||
defaultValue:
|
|
||||||
"Failed to start security key authentication"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tempSessionId, ...options } = startRes.data.data;
|
const { tempSessionId, ...options } = startResponse.data!;
|
||||||
|
|
||||||
// Perform WebAuthn authentication
|
// Perform WebAuthn authentication
|
||||||
try {
|
try {
|
||||||
const credential = await startAuthentication(options);
|
const credential = await startAuthentication({
|
||||||
|
optionsJSON: {
|
||||||
|
...options,
|
||||||
|
userVerification: options.userVerification as
|
||||||
|
| "required"
|
||||||
|
| "preferred"
|
||||||
|
| "discouraged"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Verify authentication
|
// Verify authentication
|
||||||
const verifyRes = await api.post(
|
const verifyResponse = await securityKeyVerifyProxy(
|
||||||
"/auth/security-key/authenticate/verify",
|
|
||||||
{ credential },
|
{ credential },
|
||||||
{
|
tempSessionId
|
||||||
headers: {
|
|
||||||
"X-Temp-Session-Id": tempSessionId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (verifyRes) {
|
if (verifyResponse.error) {
|
||||||
|
setError(verifyResponse.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyResponse.success) {
|
||||||
if (onLogin) {
|
if (onLogin) {
|
||||||
await onLogin();
|
await onLogin();
|
||||||
}
|
}
|
||||||
@@ -208,30 +218,44 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||||||
setShowSecurityKeyPrompt(false);
|
setShowSecurityKeyPrompt(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.post<AxiosResponse<LoginResponse>>(
|
const response = await loginProxy({
|
||||||
"/auth/login",
|
email,
|
||||||
{
|
password,
|
||||||
email,
|
code
|
||||||
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();
|
await initiateSecurityKeyAuth();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.codeRequested) {
|
if (data.codeRequested) {
|
||||||
setMfaRequested(true);
|
setMfaRequested(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
mfaForm.reset();
|
mfaForm.reset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.emailVerificationRequired) {
|
if (data.emailVerificationRequired) {
|
||||||
|
if (!isExpectedHost) {
|
||||||
|
setError(t("emailVerificationRequired", { dashboardUrl: env.app.dashboardUrl }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
router.push(`/auth/verify-email?redirect=${redirect}`);
|
router.push(`/auth/verify-email?redirect=${redirect}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -240,7 +264,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||||||
return;
|
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)}` : ""}`;
|
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`;
|
||||||
router.push(setupUrl);
|
router.push(setupUrl);
|
||||||
return;
|
return;
|
||||||
@@ -275,25 +303,26 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loginWithIdp(idpId: number) {
|
async function loginWithIdp(idpId: number) {
|
||||||
|
let redirectUrl: string | undefined;
|
||||||
try {
|
try {
|
||||||
const res = await api.post<AxiosResponse<GenerateOidcUrlResponse>>(
|
const data = await generateOidcUrlProxy(
|
||||||
`/auth/idp/${idpId}/oidc/generate-url`,
|
idpId,
|
||||||
{
|
redirect || "/"
|
||||||
redirectUrl: redirect || "/"
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
const url = data.data?.redirectUrl;
|
||||||
console.log(res);
|
if (data.error) {
|
||||||
|
setError(data.message);
|
||||||
if (!res) {
|
|
||||||
setError(t("loginError"));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (url) {
|
||||||
const data = res.data.data;
|
redirectUrl = url;
|
||||||
window.location.href = data.redirectUrl;
|
}
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error(formatAxiosError(e));
|
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">
|
<div className="text-center">
|
||||||
<Link
|
<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"
|
className="text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t("passwordForgot")}
|
{t("passwordForgot")}
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ import {
|
|||||||
AuthWithWhitelistResponse
|
AuthWithWhitelistResponse
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
import ResourceAccessDenied from "@app/components/ResourceAccessDenied";
|
import ResourceAccessDenied from "@app/components/ResourceAccessDenied";
|
||||||
|
import {
|
||||||
|
resourcePasswordProxy,
|
||||||
|
resourcePincodeProxy,
|
||||||
|
resourceWhitelistProxy,
|
||||||
|
resourceAccessProxy,
|
||||||
|
} from "@app/actions/server";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
@@ -173,100 +179,126 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
return fullUrl.toString();
|
return fullUrl.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWhitelistSubmit = (values: any) => {
|
const onWhitelistSubmit = async (values: any) => {
|
||||||
setLoadingLogin(true);
|
setLoadingLogin(true);
|
||||||
api.post<AxiosResponse<AuthWithWhitelistResponse>>(
|
setWhitelistError(null);
|
||||||
`/auth/resource/${props.resource.id}/whitelist`,
|
|
||||||
{ email: values.email, otp: values.otp }
|
|
||||||
)
|
|
||||||
.then((res) => {
|
|
||||||
setWhitelistError(null);
|
|
||||||
|
|
||||||
if (res.data.data.otpSent) {
|
try {
|
||||||
setOtpState("otp_sent");
|
const response = await resourceWhitelistProxy(props.resource.id, {
|
||||||
submitOtpForm.setValue("email", values.email);
|
email: values.email,
|
||||||
toast({
|
otp: values.otp
|
||||||
title: t("otpEmailSent"),
|
});
|
||||||
description: t("otpEmailSentDescription")
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = res.data.data.session;
|
if (response.error) {
|
||||||
if (session) {
|
setWhitelistError(response.message);
|
||||||
window.location.href = appendRequestToken(
|
return;
|
||||||
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
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
.then((res) => {
|
const data = response.data!;
|
||||||
setPasswordError(null);
|
if (data.otpSent) {
|
||||||
const session = res.data.data.session;
|
setOtpState("otp_sent");
|
||||||
if (session) {
|
submitOtpForm.setValue("email", values.email);
|
||||||
window.location.href = appendRequestToken(
|
toast({
|
||||||
props.redirect,
|
title: t("otpEmailSent"),
|
||||||
session
|
description: t("otpEmailSentDescription")
|
||||||
);
|
});
|
||||||
}
|
return;
|
||||||
})
|
}
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
const session = data.session;
|
||||||
setPasswordError(
|
if (session) {
|
||||||
formatAxiosError(e, t("passwordErrorAuthenticate"))
|
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() {
|
async function handleSSOAuth() {
|
||||||
let isAllowed = false;
|
let isAllowed = false;
|
||||||
try {
|
try {
|
||||||
await api.get(`/resource/${props.resource.id}`);
|
const response = await resourceAccessProxy(props.resource.id);
|
||||||
isAllowed = true;
|
if (response.error) {
|
||||||
|
setAccessDenied(true);
|
||||||
|
} else {
|
||||||
|
isAllowed = true;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setAccessDenied(true);
|
setAccessDenied(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
|||||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { validateOidcUrlCallbackProxy } from "@app/actions/server";
|
||||||
|
|
||||||
type ValidateOidcTokenParams = {
|
type ValidateOidcTokenParams = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -54,17 +55,27 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.post<
|
const response = await validateOidcUrlCallbackProxy(
|
||||||
AxiosResponse<ValidateOidcUrlCallbackResponse>
|
props.idpId,
|
||||||
>(`/auth/idp/${props.idpId}/oidc/validate-callback`, {
|
props.code || "",
|
||||||
code: props.code,
|
props.expectedState || "",
|
||||||
state: props.expectedState,
|
props.stateCookie || ""
|
||||||
storedState: 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) {
|
if (!redirectUrl) {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
@@ -74,9 +85,9 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
if (redirectUrl.startsWith("http")) {
|
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 {
|
} else {
|
||||||
router.push(res.data.data.redirectUrl);
|
router.push(data.redirectUrl);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(formatAxiosError(e, t('idpErrorOidcTokenValidating')));
|
setError(formatAxiosError(e, t('idpErrorOidcTokenValidating')));
|
||||||
|
|||||||
Reference in New Issue
Block a user