Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions ui/actions/invitations/invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "",
Expand Down Expand Up @@ -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 changes: 18 additions & 0 deletions ui/app/(auth)/(guest-only)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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}</>;
}
219 changes: 219 additions & 0 deletions ui/app/(auth)/invitation/accept/accept-invitation-client.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 22 additions & 0 deletions ui/app/(auth)/invitation/accept/page.tsx
Original file line number Diff line number Diff line change
@@ -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} />
);
}
20 changes: 5 additions & 15 deletions ui/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 />
Expand All @@ -53,7 +41,9 @@ export default async function RootLayout({
)}
>
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
<NavigationProgress />
<Suspense>
<NavigationProgress />
</Suspense>
{children}
<Toaster />
<GoogleTagManager
Expand Down
11 changes: 8 additions & 3 deletions ui/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading