mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(ui): invitation flow smart routing (#10589)
Co-authored-by: Pablo Fernandez Guerra (PFE) <148432447+pfe-nazaries@users.noreply.github.com> Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Pablo Fernandez Guerra
Pablo F.G
Claude Opus 4.6
parent
b9270df3e6
commit
baf1194824
@@ -6,6 +6,9 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Invitation accept smart router for handling invitation flow routing [(#10573)](https://github.com/prowler-cloud/prowler/pull/10573)
|
||||
- Invitation link backward compatibility [(#10583)](https://github.com/prowler-cloud/prowler/pull/10583)
|
||||
- Updated invitation link to use smart router [(#10575)](https://github.com/prowler-cloud/prowler/pull/10575)
|
||||
- Multi-tenant organization management: create, switch, edit, and delete organizations from the profile page [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491)
|
||||
- Findings grouped view with drill-down table showing resources per check, resource detail drawer, infinite scroll pagination, and bulk mute support [(#10425)](https://github.com/prowler-cloud/prowler/pull/10425)
|
||||
- Resource events tool to Lighthouse AI [(#10412)](https://github.com/prowler-cloud/prowler/pull/10412)
|
||||
@@ -18,6 +21,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Preserve query parameters in callbackUrl during invitation flow [(#10571)](https://github.com/prowler-cloud/prowler/pull/10571)
|
||||
- Deleting the active organization now switches to the target org before deleting, preventing JWT rejection from the backend [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491)
|
||||
- Clear Filters now resets all filters including muted findings and auto-applies, Clear all in pills only removes pill-visible sub-filters, and the discard icon is now an Undo text button [(#10446)](https://github.com/prowler-cloud/prowler/pull/10446)
|
||||
- Send to Jira modal now dynamically fetches and displays available issue types per project instead of hardcoding `"Task"`, fixing failures on non-English Jira instances [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534)
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const invitationTokenSchema = z.string().min(1).max(500);
|
||||
|
||||
export const getInvitations = async ({
|
||||
page = 1,
|
||||
query = "",
|
||||
@@ -195,3 +198,35 @@ export const revokeInvite = async (formData: FormData) => {
|
||||
handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const acceptInvitation = async (token: string) => {
|
||||
const parsed = invitationTokenSchema.safeParse(token);
|
||||
if (!parsed.success) {
|
||||
return { error: "Invalid invitation token" };
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/invitations/accept`);
|
||||
|
||||
const body = JSON.stringify({
|
||||
data: {
|
||||
type: "invitations",
|
||||
attributes: {
|
||||
invitation_token: parsed.data,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
return handleApiResponse(response);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
|
||||
export default async function GuestOnlyLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
|
||||
if (session?.user) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { acceptInvitation } from "@/actions/invitations";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
INVITATION_ACTION_PARAM,
|
||||
INVITATION_SIGNUP_ACTION,
|
||||
} from "@/lib/invitation-routing";
|
||||
|
||||
type AcceptState =
|
||||
| { kind: "no-token" }
|
||||
| { kind: "accepting" }
|
||||
| { kind: "error"; message: string; canRetry: boolean; needsSignOut: boolean }
|
||||
| { kind: "choose" };
|
||||
|
||||
function mapApiError(status: number | undefined): {
|
||||
message: string;
|
||||
canRetry: boolean;
|
||||
needsSignOut: boolean;
|
||||
} {
|
||||
switch (status) {
|
||||
case 410:
|
||||
return {
|
||||
message:
|
||||
"This invitation has expired. Please contact your administrator for a new one.",
|
||||
canRetry: false,
|
||||
needsSignOut: false,
|
||||
};
|
||||
case 400:
|
||||
return {
|
||||
message: "This invitation has already been used.",
|
||||
canRetry: false,
|
||||
needsSignOut: false,
|
||||
};
|
||||
case 404:
|
||||
return {
|
||||
message:
|
||||
"This invitation was sent to a different email address. Please sign in with the correct account.",
|
||||
canRetry: false,
|
||||
needsSignOut: true,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
message: "Something went wrong while accepting the invitation.",
|
||||
canRetry: true,
|
||||
needsSignOut: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function AcceptInvitationClient({
|
||||
isAuthenticated,
|
||||
token,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
token: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [state, setState] = useState<AcceptState>(() => {
|
||||
if (!token) return { kind: "no-token" };
|
||||
if (!isAuthenticated) return { kind: "choose" };
|
||||
return { kind: "accepting" };
|
||||
});
|
||||
const hasStartedRef = useRef(false);
|
||||
|
||||
async function doAccept() {
|
||||
if (!token) return;
|
||||
setState({ kind: "accepting" });
|
||||
|
||||
const result = await acceptInvitation(token);
|
||||
|
||||
if (result?.error) {
|
||||
const { message, canRetry, needsSignOut } = mapApiError(result.status);
|
||||
setState({ kind: "error", message, canRetry, needsSignOut });
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignOutAndRedirect() {
|
||||
if (!token) return;
|
||||
const callbackPath = `/invitation/accept?invitation_token=${encodeURIComponent(token)}`;
|
||||
await signOut({ redirect: false });
|
||||
router.push(`/sign-in?callbackUrl=${encodeURIComponent(callbackPath)}`);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasStartedRef.current) return;
|
||||
hasStartedRef.current = true;
|
||||
|
||||
if (!token) {
|
||||
setState({ kind: "no-token" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
doAccept();
|
||||
} else {
|
||||
setState({ kind: "choose" });
|
||||
}
|
||||
}, [token, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-6 text-center">
|
||||
{/* No token */}
|
||||
{state.kind === "no-token" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Icon
|
||||
icon="solar:danger-triangle-bold"
|
||||
className="text-warning"
|
||||
width={48}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold">Invalid Invitation Link</h1>
|
||||
<p className="text-default-500">
|
||||
No invitation token was provided. Please check the link you
|
||||
received.
|
||||
</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/sign-in">Go to Sign In</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accepting */}
|
||||
{state.kind === "accepting" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Icon
|
||||
icon="eos-icons:loading"
|
||||
className="text-default-500"
|
||||
width={48}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold">Accepting Invitation...</h1>
|
||||
<p className="text-default-500">
|
||||
Please wait while we process your invitation.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{state.kind === "error" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Icon
|
||||
icon="solar:danger-triangle-bold"
|
||||
className="text-danger"
|
||||
width={48}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold">
|
||||
Could Not Accept Invitation
|
||||
</h1>
|
||||
<p className="text-default-500">{state.message}</p>
|
||||
<div className="flex gap-3">
|
||||
{state.canRetry && <Button onClick={doAccept}>Retry</Button>}
|
||||
{state.needsSignOut ? (
|
||||
<Button variant="outline" onClick={handleSignOutAndRedirect}>
|
||||
Sign in with a different account
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/sign-in">Go to Sign In</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Choice page for unauthenticated users */}
|
||||
{state.kind === "choose" && (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<Icon
|
||||
icon="solar:letter-bold"
|
||||
className="text-primary"
|
||||
width={48}
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">
|
||||
You've Been Invited
|
||||
</h1>
|
||||
<p className="text-default-500 mt-2">
|
||||
You've been invited to join a tenant on Prowler. How would
|
||||
you like to continue?
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
const callbackPath = `/invitation/accept?invitation_token=${encodeURIComponent(token!)}`;
|
||||
router.push(
|
||||
`/sign-in?callbackUrl=${encodeURIComponent(callbackPath)}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
I have an account — Sign in
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/sign-up?invitation_token=${encodeURIComponent(token!)}&${INVITATION_ACTION_PARAM}=${INVITATION_SIGNUP_ACTION}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
I'm new — Create an account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { auth } from "@/auth.config";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { AcceptInvitationClient } from "./accept-invitation-client";
|
||||
|
||||
export default async function AcceptInvitationPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const resolvedSearchParams = await searchParams;
|
||||
|
||||
const token =
|
||||
typeof resolvedSearchParams?.invitation_token === "string"
|
||||
? resolvedSearchParams.invitation_token
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AcceptInvitationClient isAuthenticated={!!session?.user} token={token} />
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,8 @@ import "@/styles/globals.css";
|
||||
|
||||
import { GoogleTagManager } from "@next/third-parties/google";
|
||||
import { Metadata, Viewport } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, Suspense } from "react";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
import { NavigationProgress, Toaster } from "@/components/ui";
|
||||
import { fontSans } from "@/config/fonts";
|
||||
import { siteConfig } from "@/config/site";
|
||||
@@ -31,17 +29,7 @@ export const viewport: Viewport = {
|
||||
],
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
|
||||
if (session?.user) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html suppressHydrationWarning lang="en">
|
||||
<head />
|
||||
@@ -53,7 +41,9 @@ export default async function RootLayout({
|
||||
)}
|
||||
>
|
||||
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
|
||||
<NavigationProgress />
|
||||
<Suspense>
|
||||
<NavigationProgress />
|
||||
</Suspense>
|
||||
{children}
|
||||
<Toaster />
|
||||
<GoogleTagManager
|
||||
|
||||
+8
-3
@@ -281,15 +281,20 @@ export const authConfig = {
|
||||
const sessionError = auth?.error;
|
||||
const isSignUpPage = nextUrl.pathname === "/sign-up";
|
||||
const isSignInPage = nextUrl.pathname === "/sign-in";
|
||||
const isInvitationPage =
|
||||
nextUrl.pathname.startsWith("/invitation/accept");
|
||||
|
||||
// Allow access to sign-up and sign-in pages
|
||||
if (isSignUpPage || isSignInPage) return true;
|
||||
// Allow access to sign-up, sign-in, and invitation pages
|
||||
if (isSignUpPage || isSignInPage || isInvitationPage) return true;
|
||||
|
||||
// For all other routes, require authentication
|
||||
// Return NextResponse.redirect to preserve callbackUrl for post-login redirect
|
||||
if (!isLoggedIn) {
|
||||
const signInUrl = new URL("/sign-in", nextUrl.origin);
|
||||
signInUrl.searchParams.set("callbackUrl", nextUrl.pathname);
|
||||
signInUrl.searchParams.set(
|
||||
"callbackUrl",
|
||||
nextUrl.pathname + nextUrl.search,
|
||||
);
|
||||
// Include session error if present (e.g., RefreshAccessTokenError)
|
||||
if (sessionError) {
|
||||
signInUrl.searchParams.set("error", sessionError);
|
||||
|
||||
@@ -53,7 +53,7 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
|
||||
? window.location.origin
|
||||
: "http://localhost:3000";
|
||||
|
||||
const invitationLink = `${baseUrl}/sign-up?invitation_token=${attributes.token}`;
|
||||
const invitationLink = `${baseUrl}/invitation/accept?invitation_token=${attributes.token}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-x-4 gap-y-8">
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Query param name + value used to bypass the backward-compat redirect
|
||||
* in proxy.ts when the user explicitly chose "Create an account"
|
||||
* from the invitation smart router.
|
||||
*
|
||||
* Client sends: /sign-up?invitation_token=…&action=signup
|
||||
* Proxy skips redirect when "action" param is present.
|
||||
*/
|
||||
export const INVITATION_ACTION_PARAM = "action";
|
||||
export const INVITATION_SIGNUP_ACTION = "signup";
|
||||
+20
-2
@@ -1,10 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
import { INVITATION_ACTION_PARAM } from "@/lib/invitation-routing";
|
||||
|
||||
const publicRoutes = [
|
||||
"/sign-in",
|
||||
"/sign-up",
|
||||
"/invitation/accept",
|
||||
// In Cloud uncomment the following lines:
|
||||
// "/reset-password",
|
||||
// "/email-verification",
|
||||
@@ -18,6 +20,22 @@ const isPublicRoute = (pathname: string): boolean => {
|
||||
// NextAuth's auth() wrapper - renamed from middleware to proxy
|
||||
export default auth((req: NextRequest & { auth: any }) => {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// Backward compatibility: redirect old invitation links to new smart router
|
||||
// Skip redirect when the user explicitly chose "Create an account" from the smart router
|
||||
if (
|
||||
pathname === "/sign-up" &&
|
||||
req.nextUrl.searchParams.has("invitation_token") &&
|
||||
!req.nextUrl.searchParams.has(INVITATION_ACTION_PARAM)
|
||||
) {
|
||||
const acceptUrl = new URL("/invitation/accept", req.url);
|
||||
acceptUrl.searchParams.set(
|
||||
"invitation_token",
|
||||
req.nextUrl.searchParams.get("invitation_token")!,
|
||||
);
|
||||
return NextResponse.redirect(acceptUrl);
|
||||
}
|
||||
|
||||
const user = req.auth?.user;
|
||||
const sessionError = req.auth?.error;
|
||||
|
||||
@@ -25,13 +43,13 @@ export default auth((req: NextRequest & { auth: any }) => {
|
||||
if (sessionError && !isPublicRoute(pathname)) {
|
||||
const signInUrl = new URL("/sign-in", req.url);
|
||||
signInUrl.searchParams.set("error", sessionError);
|
||||
signInUrl.searchParams.set("callbackUrl", pathname);
|
||||
signInUrl.searchParams.set("callbackUrl", pathname + req.nextUrl.search);
|
||||
return NextResponse.redirect(signInUrl);
|
||||
}
|
||||
|
||||
if (!user && !isPublicRoute(pathname)) {
|
||||
const signInUrl = new URL("/sign-in", req.url);
|
||||
signInUrl.searchParams.set("callbackUrl", pathname);
|
||||
signInUrl.searchParams.set("callbackUrl", pathname + req.nextUrl.search);
|
||||
return NextResponse.redirect(signInUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,9 @@ test.describe("Middleware Error Handling", () => {
|
||||
await freshPage.goto(`/scans?e2e_mw=${cacheBuster}`, {
|
||||
waitUntil: "commit",
|
||||
});
|
||||
await freshSignInPage.verifyRedirectWithCallback("/scans");
|
||||
await freshSignInPage.verifyRedirectWithCallback(
|
||||
`/scans?e2e_mw=${cacheBuster}`,
|
||||
);
|
||||
} finally {
|
||||
await invalidSessionContext.close();
|
||||
}
|
||||
|
||||
@@ -69,4 +69,19 @@ test.describe("Session Error Messages", () => {
|
||||
await signInPage.verifyRedirectWithCallback("/providers");
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should preserve query parameters in callbackUrl",
|
||||
{ tag: ["@e2e", "@auth", "@session", "@AUTH-SESSION-E2E-005"] },
|
||||
async ({ page, context }) => {
|
||||
const signInPage = new SignInPage(page);
|
||||
await context.clearCookies();
|
||||
|
||||
// Navigate to a protected route with query params and assert they are preserved.
|
||||
await page.goto("/providers?ref=test", {
|
||||
waitUntil: "commit",
|
||||
});
|
||||
await signInPage.verifyRedirectWithCallback("/providers?ref=test");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user