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:
Davidm4r
2026-04-09 10:11:52 +02:00
committed by GitHub
co-authored by Pablo Fernandez Guerra Pablo F.G Claude Opus 4.6
parent b9270df3e6
commit baf1194824
14 changed files with 360 additions and 22 deletions
+4
View File
@@ -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)
+35
View File
@@ -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);
}
};
+18
View File
@@ -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&apos;ve Been Invited
</h1>
<p className="text-default-500 mt-2">
You&apos;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&apos;m new Create an account
</Button>
</div>
</div>
)}
</div>
</div>
);
}
+22
View File
@@ -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} />
);
}
+5 -15
View File
@@ -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
View File
@@ -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">
+10
View File
@@ -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
View File
@@ -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);
}
+3 -1
View File
@@ -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();
}
+15
View File
@@ -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");
},
);
});