Merge branch 'master' into PROWLER-187-create-new-user

This commit is contained in:
StylusFrost committed 2025-10-14 11:03:23 +02:00
commit cef7fcc24b
126 files changed
+8165 -2045

No files matched your search

-6
View File
@@ -1,6 +0,0 @@
SITE_URL=http://localhost:3000
API_BASE_URL=http://localhost:8080/api/v1
AUTH_TRUST_HOST=true
# openssl rand -base64 32
AUTH_SECRET=your-secret-key
+10 -7
View File
@@ -7,11 +7,13 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- Support for Markdown and AdditionalURLs in findings detail page [(#8704)](https://github.com/prowler-cloud/prowler/pull/8704)
- `Prowler Hub` menu item with tooltip [(#8692)] (https://github.com/prowler-cloud/prowler/pull/8692)
- Copy link button to finding detail page [(#8685)] (https://github.com/prowler-cloud/prowler/pull/8685)
- `Prowler Hub` menu item with tooltip [(#8692)](https://github.com/prowler-cloud/prowler/pull/8692)
- Copy link button to finding detail page [(#8685)](https://github.com/prowler-cloud/prowler/pull/8685)
- React Compiler support for automatic optimization [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)
- Turbopack support for faster development builds [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)
- Add compliance name in compliance detail view [(#8775)](https://github.com/prowler-cloud/prowler/pull/8775)
- API key management in user profile [(#8308)](https://github.com/prowler-cloud/prowler/pull/8308)
- Refresh access token error handling [(#8864)](https://github.com/prowler-cloud/prowler/pull/8864)
### 🔄 Changed
@@ -26,13 +28,14 @@ All notable changes to the **Prowler UI** are documented in this file.
- Migrated from `useFormState` to `useActionState` for React 19 compatibility [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)
- References display in findings detail page now shows as a proper bulleted list [(#8793)](https://github.com/prowler-cloud/prowler/pull/8793)
---
## [1.12.4] (Prowler v5.12.4)
### 🐞 Fixed
- SAML configuration errors are now properly caught and displayed [(#8880)](https://github.com/prowler-cloud/prowler/pull/8880)
- ThreatScore for each pillar in Prowler ThreatScore specific view [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582)
- Remove maxTokens model param for GPT-5 models [(#8843)](https://github.com/prowler-cloud/prowler/pull/8843)
- MITRE ATTACK compliance view now shows all requirements in charts [(#8886)](https://github.com/prowler-cloud/prowler/pull/8886)
---
## [1.12.3] (Prowler v5.12.3)
@@ -92,7 +95,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Disable `See Compliance` button until scan completes [(#8487)](https://github.com/prowler-cloud/prowler/pull/8487)
- Provider connection filter now shows "Connected/Disconnected" instead of "true/false" for better UX [(#8520)](https://github.com/prowler-cloud/prowler/pull/8520)
- Provider Uid filter on scan page to list all UIDs regardless of connection status [(#8375)] (https://github.com/prowler-cloud/prowler/pull/8375)
- Provider Uid filter on scan page to list all UIDs regardless of connection status [(#8375)](https://github.com/prowler-cloud/prowler/pull/8375)
### 🐞 Fixed
+44
View File
@@ -0,0 +1,44 @@
import {
ApiKeyResponse,
EnrichedApiKey,
} from "@/components/users/profile/api-keys/types";
import { getApiKeyUserEmail } from "@/components/users/profile/api-keys/utils";
import { MetaDataProps } from "@/types";
/**
* Adapts the raw API response to enriched API keys with metadata
* - Resolves user email from included resources
* - Co-locates data for better performance
* - Preserves pagination metadata
*
* @param response - Raw API response with data and included resources
* @returns Object with enriched API keys and metadata
*/
export function adaptApiKeysResponse(response: ApiKeyResponse | undefined): {
data: EnrichedApiKey[];
metadata?: MetaDataProps;
} {
if (!response?.data) {
return { data: [] };
}
const enrichedData = response.data.map((key) => ({
...key,
userEmail: getApiKeyUserEmail(key, response.included),
}));
// Transform meta to MetaDataProps format if pagination exists
const metadata: MetaDataProps | undefined = response.meta?.pagination
? {
pagination: {
page: response.meta.pagination.page,
pages: response.meta.pagination.pages,
count: response.meta.pagination.count,
itemsPerPage: [10, 25, 50, 100],
},
version: "1.0",
}
: undefined;
return { data: enrichedData, metadata };
}
+184
View File
@@ -0,0 +1,184 @@
"use server";
import { revalidateTag } from "next/cache";
import {
ApiKeyResponse,
CreateApiKeyPayload,
CreateApiKeyResponse,
SingleApiKeyResponse,
UpdateApiKeyPayload,
} from "@/components/users/profile/api-keys/types";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import { adaptApiKeysResponse } from "./api-keys.adapter";
interface GetApiKeysParams {
page?: number;
pageSize?: number;
sort?: string;
}
/**
* Fetches API keys for the current tenant with pagination support
* Returns enriched API keys with user data already resolved and pagination metadata
*/
export const getApiKeys = async (params?: GetApiKeysParams) => {
const { page = 1, pageSize = 10, sort } = params || {};
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/api-keys`);
url.searchParams.set("include", "entity.roles");
url.searchParams.set("page[number]", page.toString());
url.searchParams.set("page[size]", pageSize.toString());
if (sort) {
url.searchParams.set("sort", sort);
}
try {
const response = await fetch(url.toString(), {
headers,
next: { tags: ["api-keys"] },
});
const apiResponse = (await handleApiResponse(response)) as ApiKeyResponse;
return adaptApiKeysResponse(apiResponse);
} catch (error) {
console.error("Error fetching API keys:", error);
return { data: [], metadata: undefined };
}
};
/**
* Creates a new API key
* IMPORTANT: The full API key is only returned in this response, it cannot be retrieved again
*/
export const createApiKey = async (
payload: CreateApiKeyPayload,
): Promise<
| { data: CreateApiKeyResponse; error?: never }
| { data?: never; error: string }
> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/api-keys`);
const body = {
data: {
type: "api-keys",
attributes: {
name: payload.name,
...(payload.expires_at && { expires_at: payload.expires_at }),
},
},
};
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
return handleApiError(response);
}
const data = (await handleApiResponse(response)) as CreateApiKeyResponse;
// Revalidate the api-keys list
revalidateTag("api-keys");
return { data };
} catch (error) {
console.error("Error creating API key:", error);
return {
error:
error instanceof Error ? error.message : "Failed to create API key",
};
}
};
/**
* Updates an API key (only the name can be updated)
*/
export const updateApiKey = async (
id: string,
payload: UpdateApiKeyPayload,
): Promise<
| { data: SingleApiKeyResponse; error?: never }
| { data?: never; error: string }
> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/api-keys/${id}`);
const body = {
data: {
type: "api-keys",
id,
attributes: {
name: payload.name,
},
},
};
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
return handleApiError(response);
}
const data = (await handleApiResponse(response)) as SingleApiKeyResponse;
// Revalidate the api-keys list
revalidateTag("api-keys");
return { data };
} catch (error) {
console.error("Error updating API key:", error);
return {
error:
error instanceof Error ? error.message : "Failed to update API key",
};
}
};
/**
* Revokes an API key (cannot be undone)
*/
export const revokeApiKey = async (
id: string,
): Promise<{ error?: string; success?: boolean }> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/api-keys/${id}/revoke`);
try {
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
if (!response.ok) {
const errorData = handleApiError(response);
return { error: errorData.error };
}
// Revalidate the api-keys list
revalidateTag("api-keys");
return { success: true };
} catch (error) {
console.error("Error revoking API key:", error);
return {
error:
error instanceof Error ? error.message : "Failed to revoke API key",
};
}
};
+12 -1
View File
@@ -40,7 +40,18 @@ export const createSamlConfig = async (_prevState: any, formData: FormData) => {
}),
});
await handleApiResponse(response, "/integrations", false);
const result = await handleApiResponse(response, "/integrations", false);
if (result.error) {
return {
errors: {
general:
result.error instanceof Error
? result.error.message
: "Error creating SAML configuration. Please try again.",
},
};
}
return { success: "SAML configuration created successfully!" };
} catch (error) {
console.error("Error creating SAML config:", error);
+21 -11
View File
@@ -4,10 +4,11 @@ import { getSamlConfig } from "@/actions/integrations/saml";
import { getUserInfo } from "@/actions/users/users";
import { SamlIntegrationCard } from "@/components/integrations/saml/saml-integration-card";
import { ContentLayout } from "@/components/ui";
import { UserBasicInfoCard } from "@/components/users/profile";
import { ApiKeysCard, UserBasicInfoCard } from "@/components/users/profile";
import { MembershipsCard } from "@/components/users/profile/memberships-card";
import { RolesCard } from "@/components/users/profile/roles-card";
import { SkeletonUserInfo } from "@/components/users/profile/skeleton-user-info";
import { SearchParamsProps } from "@/types";
import {
MembershipDetailData,
RoleDetail,
@@ -15,17 +16,27 @@ import {
UserProfileResponse,
} from "@/types/users";
export default async function Profile() {
export default async function Profile({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
return (
<ContentLayout title="User Profile" icon="lucide:users">
<Suspense fallback={<SkeletonUserInfo />}>
<SSRDataUser />
<SSRDataUser searchParams={resolvedSearchParams} />
</Suspense>
</ContentLayout>
);
}
const SSRDataUser = async () => {
const SSRDataUser = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const userProfile = (await getUserInfo()) as UserProfileResponse | undefined;
if (!userProfile?.data) {
return null;
@@ -96,22 +107,21 @@ const SSRDataUser = async () => {
<div className="flex w-full flex-col gap-6">
<UserBasicInfoCard user={userData} tenantId={userTenantId || ""} />
<div className="flex flex-col gap-6 xl:flex-row">
<div className="w-full">
<div className="flex w-full flex-col gap-6 xl:max-w-[50%]">
<RolesCard roles={roleDetails} roleDetails={roleDetailsMap} />
{hasManageIntegrations && (
<SamlIntegrationCard samlConfig={samlConfig?.data?.[0]} />
)}
</div>
<div className="w-full">
<div className="flex w-full flex-col gap-6 xl:max-w-[50%]">
<MembershipsCard
memberships={membershipsIncluded}
tenantsMap={tenantsMap}
isOwner={isOwner && hasManageAccount}
/>
{hasManageAccount && <ApiKeysCard searchParams={searchParams} />}
</div>
</div>
{hasManageIntegrations && (
<div className="w-full pr-0 xl:w-1/2 xl:pr-3">
<SamlIntegrationCard samlConfig={samlConfig?.data?.[0]} />
</div>
)}
</div>
);
};
+3 -1
View File
@@ -1,6 +1,7 @@
import { Spacer } from "@heroui/spacer";
import { Suspense } from "react";
import { getRoles } from "@/actions/roles/roles";
import { getUsers } from "@/actions/users/users";
import { FilterControls } from "@/components/filters";
import { filterUsers } from "@/components/filters/data-filters";
@@ -52,6 +53,7 @@ const SSRDataTable = async ({
const query = (filters["filter[search]"] as string) || "";
const usersData = await getUsers({ query, page, sort, filters, pageSize });
const rolesData = await getRoles({});
// Create a dictionary for roles by user ID
const roleDict = (usersData?.included || []).reduce(
@@ -67,7 +69,7 @@ const SSRDataTable = async ({
// Generate the array of roles with all the roles available
const roles = Array.from(
new Map(
(usersData?.included || []).map((role: Role) => [
(rolesData?.data || []).map((role: Role) => [
role.id,
{ id: role.id, name: role.attributes?.name || "Unnamed Role" },
]),
+222 -98
View File
@@ -1,55 +1,199 @@
import { jwtDecode, JwtPayload } from "jwt-decode";
import NextAuth, { type NextAuthConfig, User } from "next-auth";
import { jwtDecode, type JwtPayload } from "jwt-decode";
import NextAuth, {
type DefaultSession,
type NextAuthConfig,
type Session,
User,
} from "next-auth";
import type { JWT } from "next-auth/jwt";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
import { getToken, getUserByMe } from "./actions/auth";
import { apiBaseUrl } from "./lib";
import type { RolePermissionAttributes } from "./types/users";
interface CustomJwtPayload extends JwtPayload {
user_id: string;
tenant_id: string;
}
const refreshAccessToken = async (token: JwtPayload) => {
type DefaultSessionUser = NonNullable<DefaultSession["user"]>;
type TokenUser = DefaultSessionUser & {
companyName?: string;
dateJoined?: string;
permissions: RolePermissionAttributes;
};
type AuthToken = JWT & {
accessToken?: string;
refreshToken?: string;
accessTokenExpires?: number;
user_id?: string;
tenant_id?: string;
user?: TokenUser;
error?: string;
};
type ExtendedSession = Session & {
user?: TokenUser;
userId?: string;
tenantId?: string;
accessToken?: string;
refreshToken?: string;
error?: string;
};
const DEFAULT_PERMISSIONS: RolePermissionAttributes = {
manage_users: false,
manage_account: false,
manage_providers: false,
manage_scans: false,
manage_integrations: false,
manage_billing: false,
unlimited_visibility: false,
};
type TokenUserInput = Partial<TokenUser> & { company?: string };
const toTokenUser = (user?: TokenUserInput): TokenUser =>
({
name: user?.name ?? undefined,
email: user?.email ?? undefined,
companyName: user?.companyName ?? user?.company,
dateJoined: user?.dateJoined,
permissions: user?.permissions ?? { ...DEFAULT_PERMISSIONS },
}) as TokenUser;
type UserMeResponse = Awaited<ReturnType<typeof getUserByMe>>;
const tokenUserFromApi = (user: UserMeResponse) =>
toTokenUser({
name: user.name,
email: user.email,
companyName: user.company,
dateJoined: user.dateJoined,
permissions: user.permissions,
});
const applyDecodedClaims = (
target: AuthToken,
accessToken?: string,
logContext = "access token",
) => {
if (!accessToken) return;
try {
const decodedToken = jwtDecode<CustomJwtPayload>(accessToken);
target.accessTokenExpires = decodedToken.exp
? decodedToken.exp * 1000
: target.accessTokenExpires;
target.user_id = decodedToken.user_id ?? target.user_id;
target.tenant_id = decodedToken.tenant_id ?? target.tenant_id;
} catch (decodeError) {
// eslint-disable-next-line no-console
console.warn(`Unable to decode ${logContext}`, decodeError);
}
};
const refreshTokenPromises = new Map<string, Promise<AuthToken>>();
const refreshAccessToken = async (token: AuthToken): Promise<AuthToken> => {
const refreshToken = token.refreshToken;
if (!refreshToken) {
return {
...token,
error: "MissingRefreshToken",
};
}
const existingPromise = refreshTokenPromises.get(refreshToken);
if (existingPromise) {
return existingPromise;
}
const url = new URL(`${apiBaseUrl}/tokens/refresh`);
const bodyData = {
data: {
type: "tokens-refresh",
attributes: {
refresh: (token as any).refreshToken,
refresh: refreshToken,
},
},
};
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
},
body: JSON.stringify(bodyData),
});
const refreshPromise = (async () => {
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
},
body: JSON.stringify(bodyData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
const payload = await response.json().catch(() => undefined);
if (!response.ok) {
const detail = payload?.errors?.[0]?.detail;
// eslint-disable-next-line no-console
console.warn(
"Failed to refresh access token:",
detail || `HTTP error ${response.status}`,
);
return {
...token,
error: "RefreshAccessTokenError",
};
}
const newAccessToken = payload?.data?.attributes?.access as
| string
| undefined;
const nextRefreshToken =
(payload?.data?.attributes?.refresh as string | undefined) ??
refreshToken;
if (!newAccessToken) {
// eslint-disable-next-line no-console
console.warn("Missing access token in refresh response");
return {
...token,
error: "RefreshAccessTokenError",
};
}
const nextToken: AuthToken = {
...token,
accessToken: newAccessToken,
refreshToken: nextRefreshToken,
error: undefined,
};
applyDecodedClaims(nextToken, newAccessToken, "refreshed access token");
return nextToken;
} catch (error) {
// eslint-disable-next-line no-console
console.warn("Error refreshing access token:", error);
return {
...token,
error: "RefreshAccessTokenError",
};
}
})();
const newTokens = await response.json();
refreshTokenPromises.set(refreshToken, refreshPromise);
return {
...token,
accessToken: newTokens.data.attributes.access,
refreshToken: newTokens.data.attributes.refresh,
};
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error refreshing access token:", error);
return {
error: "RefreshAccessTokenError",
};
try {
return await refreshPromise;
} finally {
refreshTokenPromises.delete(refreshToken);
}
};
@@ -90,13 +234,7 @@ export const authConfig = {
const userMeResponse = await getUserByMe(tokenResponse.accessToken);
const user = {
name: userMeResponse.name,
email: userMeResponse.email,
company: userMeResponse?.company,
dateJoined: userMeResponse.dateJoined,
permissions: userMeResponse.permissions,
};
const user = tokenUserFromApi(userMeResponse);
return {
...user,
@@ -122,14 +260,7 @@ export const authConfig = {
try {
const userMeResponse = await getUserByMe(accessToken as string);
const user = {
name: userMeResponse.name,
email: userMeResponse.email,
company: userMeResponse?.company,
dateJoined: userMeResponse.dateJoined,
permissions: userMeResponse.permissions,
};
const user = tokenUserFromApi(userMeResponse);
return {
...user,
@@ -137,7 +268,6 @@ export const authConfig = {
refreshToken: credentials.refreshToken,
};
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error in authorize:", error);
return null;
}
@@ -162,74 +292,68 @@ export const authConfig = {
},
jwt: async ({ token, account, user }) => {
if (token?.accessToken) {
const decodedToken = jwtDecode(
token.accessToken as string,
) as CustomJwtPayload;
// eslint-disable-next-line no-console
// console.log("decodedToken", decodedToken);
token.accessTokenExpires = (decodedToken.exp as number) * 1000;
token.user_id = decodedToken.user_id;
token.tenant_id = decodedToken.tenant_id;
}
const authToken = token as AuthToken;
const userInfo = {
name: user?.name,
companyName: user?.company,
email: user?.email,
dateJoined: user?.dateJoined,
permissions: user?.permissions || {
manage_users: false,
manage_account: false,
manage_providers: false,
manage_scans: false,
manage_integrations: false,
manage_billing: false,
unlimited_visibility: false,
},
};
applyDecodedClaims(authToken, authToken.accessToken);
if (account && user) {
return {
...token,
userId: token.user_id,
tenantId: token.tenant_id,
accessToken: (user as User & { accessToken: JwtPayload }).accessToken,
refreshToken: (user as User & { refreshToken: JwtPayload })
.refreshToken,
user: userInfo,
const signedInUser = user as User &
TokenUserInput & {
accessToken: string;
refreshToken: string;
};
const nextAuthToken: AuthToken = {
...authToken,
accessToken: signedInUser.accessToken,
refreshToken: signedInUser.refreshToken,
user: toTokenUser(signedInUser),
error: undefined,
};
applyDecodedClaims(
nextAuthToken,
signedInUser.accessToken,
"access token on sign-in",
);
return nextAuthToken;
}
// eslint-disable-next-line no-console
// console.log(
// "Access token expires",
// token.accessTokenExpires,
// new Date(Number(token.accessTokenExpires)),
// );
// If the access token is not expired, return the token
if (
typeof token.accessTokenExpires === "number" &&
Date.now() < token.accessTokenExpires
)
return token;
typeof authToken.accessTokenExpires === "number" &&
Date.now() < authToken.accessTokenExpires
) {
return authToken;
}
// If the access token is expired, try to refresh it
return refreshAccessToken(token as JwtPayload);
return refreshAccessToken(authToken);
},
session: async ({ session, token }) => {
if (token) {
session.userId = token?.user_id as string;
session.tenantId = token?.tenant_id as string;
session.accessToken = token?.accessToken as string;
session.refreshToken = token?.refreshToken as string;
session.user = token.user as any;
const authToken = token as AuthToken;
const nextSession = { ...session } as ExtendedSession;
if (authToken?.error) {
nextSession.error = authToken.error;
nextSession.user = undefined;
nextSession.userId = undefined;
nextSession.tenantId = undefined;
nextSession.accessToken = undefined;
nextSession.refreshToken = undefined;
return nextSession;
}
// console.log("session", session);
return session;
nextSession.error = undefined;
nextSession.userId = authToken.user_id ?? nextSession.userId;
nextSession.tenantId = authToken.tenant_id ?? nextSession.tenantId;
nextSession.accessToken =
authToken.accessToken ?? nextSession.accessToken;
nextSession.refreshToken =
authToken.refreshToken ?? nextSession.refreshToken;
nextSession.user = authToken.user ?? nextSession.user;
return nextSession;
},
},
} satisfies NextAuthConfig;
+32
View File
@@ -35,6 +35,7 @@ export const SignInForm = ({
useEffect(() => {
const samlError = searchParams.get("sso_saml_failed");
const sessionError = searchParams.get("error");
if (samlError) {
setTimeout(() => {
@@ -46,6 +47,37 @@ export const SignInForm = ({
});
}, 100);
}
if (sessionError) {
setTimeout(() => {
const errorMessages: Record<
string,
{ title: string; description: string }
> = {
RefreshAccessTokenError: {
title: "Session Expired",
description:
"Your session has expired. Please sign in again to continue.",
},
MissingRefreshToken: {
title: "Session Error",
description:
"There was a problem with your session. Please sign in again.",
},
};
const errorConfig = errorMessages[sessionError] || {
title: "Authentication Error",
description: "Please sign in again to continue.",
};
toast({
variant: "destructive",
title: errorConfig.title,
description: errorConfig.description,
});
}, 100);
}
}, [searchParams, toast]);
const form = useForm<SignInFormData>({
@@ -17,7 +17,7 @@ interface CISDetailsProps {
export const CISCustomDetails = ({ requirement }: CISDetailsProps) => {
const processReferences = (
references: string | number | string[] | object[] | undefined,
references: string | number | boolean | string[] | object[] | undefined,
): string[] => {
if (typeof references !== "string") return [];
@@ -54,6 +54,25 @@ export const ThreatCustomDetails = ({
conditional={true}
/>
)}
{typeof requirement.passedFindings === "number" &&
typeof requirement.totalFindings === "number" && (
<>
<ComplianceBadge
label="Findings"
value={`${requirement.passedFindings}/${requirement.totalFindings}`}
color="blue"
/>
{requirement.totalFindings > 0 && (
<ComplianceBadge
label="Pass Rate"
value={`${Math.round((requirement.passedFindings / requirement.totalFindings) * 100)}%`}
color="green"
conditional={true}
/>
)}
</>
)}
</ComplianceBadgeContainer>
{requirement.additionalInformation && (
+162
View File
@@ -0,0 +1,162 @@
"use client";
import {
Bar,
BarChart as RechartsBar,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ChartTooltip } from "./shared/ChartTooltip";
import { CHART_COLORS, LAYOUT_OPTIONS } from "./shared/constants";
import { getSeverityColorByName } from "./shared/utils";
import { BarDataPoint, LayoutOption } from "./types";
interface BarChartProps {
data: BarDataPoint[];
layout?: LayoutOption;
xLabel?: string;
yLabel?: string;
height?: number;
showValues?: boolean;
}
const CustomLabel = ({ x, y, width, height, value, data }: any) => {
const percentage = data.percentage;
return (
<text
x={x + width + 10}
y={y + height / 2}
fill={CHART_COLORS.textSecondary}
fontSize={12}
textAnchor="start"
dominantBaseline="middle"
>
{percentage !== undefined
? `${percentage}% • ${value.toLocaleString()}`
: value.toLocaleString()}
</text>
);
};
export function BarChart({
data,
layout = LAYOUT_OPTIONS.horizontal,
xLabel,
yLabel,
height = 400,
showValues = true,
}: BarChartProps) {
const isHorizontal = layout === LAYOUT_OPTIONS.horizontal;
return (
<ResponsiveContainer width="100%" height={height}>
<RechartsBar
data={data}
layout={layout}
margin={{ top: 20, right: showValues ? 100 : 30, left: 20, bottom: 20 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke={CHART_COLORS.gridLine}
horizontal={isHorizontal}
vertical={!isHorizontal}
/>
{isHorizontal ? (
<>
<XAxis
type="number"
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
label={
xLabel
? {
value: xLabel,
position: "insideBottom",
offset: -10,
fill: CHART_COLORS.textSecondary,
}
: undefined
}
/>
<YAxis
dataKey="name"
type="category"
width={100}
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
label={
yLabel
? {
value: yLabel,
angle: -90,
position: "insideLeft",
fill: CHART_COLORS.textSecondary,
}
: undefined
}
/>
</>
) : (
<>
<XAxis
dataKey="name"
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
label={
xLabel
? {
value: xLabel,
position: "insideBottom",
offset: -10,
fill: CHART_COLORS.textSecondary,
}
: undefined
}
/>
<YAxis
type="number"
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
label={
yLabel
? {
value: yLabel,
angle: -90,
position: "insideLeft",
fill: CHART_COLORS.textSecondary,
}
: undefined
}
/>
</>
)}
<Tooltip content={<ChartTooltip />} />
<Bar
dataKey="value"
radius={4}
label={
showValues && isHorizontal
? (props: any) => (
<CustomLabel {...props} data={data[props.index]} />
)
: false
}
>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={
entry.color ||
getSeverityColorByName(entry.name) ||
CHART_COLORS.defaultColor
}
opacity={1}
className="transition-opacity hover:opacity-80"
/>
))}
</Bar>
</RechartsBar>
</ResponsiveContainer>
);
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { Cell, Label, Pie, PieChart, Tooltip } from "recharts";
import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
import { ChartLegend } from "./shared/ChartLegend";
import { DonutDataPoint } from "./types";
interface DonutChartProps {
data: DonutDataPoint[];
height?: number;
innerRadius?: number;
outerRadius?: number;
showLegend?: boolean;
centerLabel?: {
value: string | number;
label: string;
};
}
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: data.color }}
/>
<span className="text-sm font-semibold text-white">
{data.percentage}% {data.name}
</span>
</div>
{data.change !== undefined && (
<p className="mt-2 text-xs text-slate-400">
<span className="font-bold">
{data.change > 0 ? "+" : ""}
{data.change}%
</span>{" "}
Since last scan
</p>
)}
</div>
);
}
return null;
};
const CustomLegend = ({ payload }: any) => {
const items = payload.map((entry: any) => ({
label: `${entry.value} (${entry.payload.percentage}%)`,
color: entry.color,
}));
return <ChartLegend items={items} />;
};
export function DonutChart({
data,
innerRadius = 80,
outerRadius = 120,
showLegend = true,
centerLabel,
}: DonutChartProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const chartConfig = data.reduce(
(config, item) => ({
...config,
[item.name]: {
label: item.name,
color: item.color,
},
}),
{} as ChartConfig,
);
const chartData = data.map((item) => ({
name: item.name,
value: item.value,
fill: item.color,
color: item.color,
percentage: item.percentage,
change: item.change,
}));
const legendPayload = chartData.map((entry) => ({
value: entry.name,
color: entry.color,
payload: {
percentage: entry.percentage,
},
}));
return (
<div>
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[350px]"
>
<PieChart>
<Tooltip content={<CustomTooltip />} />
<Pie
data={chartData}
dataKey="value"
nameKey="name"
innerRadius={innerRadius}
outerRadius={outerRadius}
strokeWidth={0}
paddingAngle={2}
>
{chartData.map((entry, index) => {
const opacity =
hoveredIndex === null ? 1 : hoveredIndex === index ? 1 : 0.5;
return (
<Cell
key={`cell-${index}`}
fill={entry.fill}
opacity={opacity}
style={{ transition: "opacity 0.2s" }}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
/>
);
})}
{centerLabel && (
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
const formattedValue =
typeof centerLabel.value === "number"
? centerLabel.value.toLocaleString()
: centerLabel.value;
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-white text-3xl font-bold"
>
{formattedValue}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 24}
className="fill-slate-400"
>
{centerLabel.label}
</tspan>
</text>
);
}
}}
/>
)}
</Pie>
</PieChart>
</ChartContainer>
{showLegend && <CustomLegend payload={legendPayload} />}
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { Bell } from "lucide-react";
import { useState } from "react";
import { CHART_COLORS, SEVERITY_ORDER } from "./shared/constants";
import { getSeverityColorByName } from "./shared/utils";
import { BarDataPoint } from "./types";
interface HorizontalBarChartProps {
data: BarDataPoint[];
height?: number;
title?: string;
}
export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const sortedData = [...data].sort((a, b) => {
const orderA = SEVERITY_ORDER[a.name as keyof typeof SEVERITY_ORDER] ?? 999;
const orderB = SEVERITY_ORDER[b.name as keyof typeof SEVERITY_ORDER] ?? 999;
return orderA - orderB;
});
return (
<div className="w-full">
{title && (
<div className="mb-4">
<h3 className="text-lg font-semibold text-white">{title}</h3>
</div>
)}
<div className="space-y-6">
{sortedData.map((item, index) => {
const isHovered = hoveredIndex === index;
const isFaded = hoveredIndex !== null && !isHovered;
const barColor =
item.color ||
getSeverityColorByName(item.name) ||
CHART_COLORS.defaultColor;
return (
<div
key={index}
className="relative flex items-center gap-4"
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
>
<div className="w-24 text-right">
<span
className="text-sm text-white"
style={{
opacity: isFaded ? 0.5 : 1,
transition: "opacity 0.2s",
}}
>
{item.name}
</span>
</div>
<div className="relative flex-1">
<div className="absolute inset-0 h-8 w-full rounded-lg bg-slate-700/50" />
<div
className="relative h-8 rounded-lg transition-all duration-300"
style={{
width: `${item.percentage || (item.value / Math.max(...data.map((d) => d.value))) * 100}%`,
backgroundColor: barColor,
opacity: isFaded ? 0.5 : 1,
}}
/>
{isHovered && (
<div className="absolute top-10 left-0 z-10 min-w-[200px] rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: barColor }}
/>
<span className="font-semibold text-white">
{item.value.toLocaleString()} {item.name} Risk
</span>
</div>
{item.newFindings !== undefined && (
<div className="mt-2 flex items-center gap-2">
<Bell size={14} className="text-slate-400" />
<span className="text-sm text-slate-400">
{item.newFindings} New Findings
</span>
</div>
)}
{item.change !== undefined && (
<p className="mt-1 text-sm text-slate-400">
<span className="font-bold">
{item.change > 0 ? "+" : ""}
{item.change}%
</span>{" "}
Since Last Scan
</p>
)}
</div>
)}
</div>
<div
className="flex w-40 items-center gap-2 text-sm text-white"
style={{
opacity: isFaded ? 0.5 : 1,
transition: "opacity 0.2s",
}}
>
<span className="font-semibold">{item.percentage}%</span>
<span className="text-slate-400">•</span>
<span>{item.value.toLocaleString()}</span>
</div>
</div>
);
})}
</div>
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
import { Bell } from "lucide-react";
import { useState } from "react";
import {
CartesianGrid,
Legend,
Line,
LineChart as RechartsLine,
ResponsiveContainer,
Tooltip,
TooltipProps,
XAxis,
YAxis,
} from "recharts";
import { AlertPill } from "./shared/AlertPill";
import { ChartLegend } from "./shared/ChartLegend";
import { CHART_COLORS } from "./shared/constants";
import { LineConfig, LineDataPoint } from "./types";
interface LineChartProps {
data: LineDataPoint[];
lines: LineConfig[];
xLabel?: string;
yLabel?: string;
height?: number;
}
interface TooltipPayloadItem {
dataKey: string;
value: number;
stroke: string;
name: string;
payload: LineDataPoint;
}
const CustomLineTooltip = ({
active,
payload,
label,
}: TooltipProps<number, string>) => {
if (!active || !payload || payload.length === 0) {
return null;
}
const typedPayload = payload as unknown as TooltipPayloadItem[];
const totalValue = typedPayload.reduce((sum, item) => sum + item.value, 0);
return (
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<p className="mb-3 text-xs text-slate-400">{label}</p>
<div className="mb-3">
<AlertPill value={totalValue} textSize="sm" />
</div>
<div className="space-y-3">
{typedPayload.map((item) => {
const newFindings = item.payload[`${item.dataKey}_newFindings`];
const change = item.payload[`${item.dataKey}_change`];
return (
<div key={item.dataKey} className="space-y-1">
<div className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: item.stroke }}
/>
<span className="text-sm text-white">{item.value}</span>
</div>
{newFindings !== undefined && (
<div className="flex items-center gap-2">
<Bell size={14} className="text-slate-400" />
<span className="text-xs text-slate-400">
{newFindings} New Findings
</span>
</div>
)}
{change !== undefined && typeof change === "number" && (
<p className="text-xs text-slate-400">
<span className="font-bold">
{change > 0 ? "+" : ""}
{change}%
</span>{" "}
Since Last Scan
</p>
)}
</div>
);
})}
</div>
</div>
);
};
const CustomLegend = ({ payload }: any) => {
const severityOrder = [
"Informational",
"Low",
"Medium",
"High",
"Critical",
"Muted",
];
const sortedPayload = [...payload].sort((a, b) => {
const indexA = severityOrder.indexOf(a.value);
const indexB = severityOrder.indexOf(b.value);
return indexA - indexB;
});
const items = sortedPayload.map((entry: any) => ({
label: entry.value,
color: entry.color,
}));
return <ChartLegend items={items} />;
};
export function LineChart({
data,
lines,
xLabel,
yLabel,
height = 400,
}: LineChartProps) {
const [hoveredLine, setHoveredLine] = useState<string | null>(null);
return (
<ResponsiveContainer width="100%" height={height}>
<RechartsLine
data={data}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.gridLine} />
<XAxis
dataKey="date"
label={
xLabel
? {
value: xLabel,
position: "insideBottom",
offset: -10,
fill: CHART_COLORS.textSecondary,
}
: undefined
}
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
/>
<YAxis
label={
yLabel
? {
value: yLabel,
angle: -90,
position: "insideLeft",
fill: CHART_COLORS.textSecondary,
}
: undefined
}
tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }}
/>
<Tooltip content={<CustomLineTooltip />} />
<Legend content={<CustomLegend />} />
{lines.map((line) => {
const isHovered = hoveredLine === line.dataKey;
const isFaded = hoveredLine !== null && !isHovered;
return (
<Line
key={line.dataKey}
type="monotone"
dataKey={line.dataKey}
stroke={line.color}
strokeWidth={2}
strokeOpacity={isFaded ? 0.5 : 1}
name={line.label}
dot={{ fill: line.color, r: 4, opacity: isFaded ? 0.5 : 1 }}
activeDot={{ r: 6 }}
onMouseEnter={() => setHoveredLine(line.dataKey)}
onMouseLeave={() => setHoveredLine(null)}
style={{ transition: "stroke-opacity 0.2s" }}
/>
);
})}
</RechartsLine>
</ResponsiveContainer>
);
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import {
PolarAngleAxis,
PolarGrid,
Radar,
RadarChart as RechartsRadar,
} from "recharts";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/components/ui/chart/Chart";
import { AlertPill } from "./shared/AlertPill";
import { CHART_COLORS } from "./shared/constants";
import { RadarDataPoint } from "./types";
interface RadarChartProps {
data: RadarDataPoint[];
height?: number;
dataKey?: string;
onSelectPoint?: (point: RadarDataPoint | null) => void;
selectedPoint?: RadarDataPoint | null;
}
const chartConfig = {
value: {
label: "Findings",
color: "var(--color-magenta)",
},
} satisfies ChartConfig;
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0];
return (
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<p className="text-sm font-semibold text-white">
{data.payload.category}
</p>
<div className="mt-1">
<AlertPill value={data.value} />
</div>
{data.payload.change !== undefined && (
<p className="mt-1 text-xs text-slate-400">
<span className="font-bold">
{data.payload.change > 0 ? "+" : ""}
{data.payload.change}%
</span>{" "}
Since Last Scan
</p>
)}
</div>
);
}
return null;
};
const CustomDot = (props: any) => {
const { cx, cy, payload, selectedPoint, onSelectPoint } = props;
const currentCategory = payload.category || payload.name;
const isSelected = selectedPoint?.category === currentCategory;
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (onSelectPoint) {
if (isSelected) {
onSelectPoint(null);
} else {
const point = {
category: currentCategory,
value: payload.value,
change: payload.change,
};
onSelectPoint(point);
}
}
};
return (
<circle
cx={cx}
cy={cy}
r={isSelected ? 9 : 6}
fill={isSelected ? "var(--color-success)" : "var(--color-purple-dark)"}
fillOpacity={1}
style={{
cursor: onSelectPoint ? "pointer" : "default",
pointerEvents: "all",
}}
onClick={onSelectPoint ? handleClick : undefined}
/>
);
};
export function RadarChart({
data,
height = 400,
dataKey = "value",
onSelectPoint,
selectedPoint,
}: RadarChartProps) {
return (
<ChartContainer
config={chartConfig}
className="mx-auto w-full"
style={{ height }}
>
<RechartsRadar data={data}>
<ChartTooltip cursor={false} content={<CustomTooltip />} />
<PolarAngleAxis
dataKey="category"
tick={{ fill: CHART_COLORS.textPrimary }}
/>
<PolarGrid strokeOpacity={0.3} />
<Radar
dataKey={dataKey}
fill="var(--color-magenta)"
fillOpacity={0.2}
activeDot={false}
dot={
onSelectPoint
? (dotProps: any) => {
const { key, ...rest } = dotProps;
return (
<CustomDot
key={key}
{...rest}
selectedPoint={selectedPoint}
onSelectPoint={onSelectPoint}
/>
);
}
: {
r: 6,
fill: "var(--color-purple-dark)",
fillOpacity: 1,
}
}
/>
</RechartsRadar>
</ChartContainer>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import {
PolarAngleAxis,
RadialBar,
RadialBarChart,
ResponsiveContainer,
} from "recharts";
import { CHART_COLORS } from "./shared/constants";
interface RadialChartProps {
percentage: number;
label?: string;
color?: string;
backgroundColor?: string;
height?: number;
innerRadius?: number;
outerRadius?: number;
startAngle?: number;
endAngle?: number;
}
export function RadialChart({
percentage,
label = "Score",
color = "var(--color-success)",
backgroundColor = CHART_COLORS.tooltipBackground,
height = 250,
innerRadius = 60,
outerRadius = 100,
startAngle = 90,
endAngle = -270,
}: RadialChartProps) {
const data = [
{
name: label,
value: percentage,
fill: color,
},
];
return (
<ResponsiveContainer width="100%" height={height}>
<RadialBarChart
cx="50%"
cy="50%"
innerRadius={innerRadius}
outerRadius={outerRadius}
barSize={20}
data={data}
startAngle={startAngle}
endAngle={endAngle}
>
<PolarAngleAxis
type="number"
domain={[0, 100]}
angleAxisId={0}
tick={false}
/>
<RadialBar
background={{ fill: backgroundColor }}
dataKey="value"
cornerRadius={10}
fill={color}
/>
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
className="fill-white text-4xl font-bold"
>
{percentage}%
</text>
</RadialBarChart>
</ResponsiveContainer>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
import { CHART_COLORS, SEVERITY_COLORS } from "./shared/constants";
interface SankeyNode {
name: string;
}
interface SankeyLink {
source: number;
target: number;
value: number;
}
interface SankeyChartProps {
data: {
nodes: SankeyNode[];
links: SankeyLink[];
};
height?: number;
}
const COLORS: Record<string, string> = {
Success: "var(--color-success)",
Fail: "var(--color-destructive)",
AWS: "var(--color-orange)",
Azure: "var(--color-cyan)",
Google: "var(--color-red)",
...SEVERITY_COLORS,
};
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<p className="text-sm font-semibold text-white">{data.name}</p>
{data.value && (
<p className="text-xs text-slate-400">Value: {data.value}</p>
)}
</div>
);
}
return null;
};
const CustomNode = ({ x, y, width, height, payload, containerWidth }: any) => {
const isOut = x + width + 6 > containerWidth;
const nodeName = payload.name;
const color = COLORS[nodeName] || CHART_COLORS.defaultColor;
return (
<g>
<Rectangle
x={x}
y={y}
width={width}
height={height}
fill={color}
fillOpacity="1"
/>
<text
textAnchor={isOut ? "end" : "start"}
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2}
fontSize="14"
className="fill-white stroke-white"
>
{nodeName}
</text>
<text
textAnchor={isOut ? "end" : "start"}
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2 + 13}
fontSize="12"
className="fill-slate-400 stroke-slate-400"
strokeOpacity="0.5"
>
{payload.value}
</text>
</g>
);
};
const CustomLink = (props: any) => {
const {
sourceX,
targetX,
sourceY,
targetY,
sourceControlX,
targetControlX,
linkWidth,
} = props;
const sourceName = props.payload.source?.name || "";
const color = COLORS[sourceName] || CHART_COLORS.defaultColor;
return (
<g>
<path
d={`
M${sourceX},${sourceY + linkWidth / 2}
C${sourceControlX},${sourceY + linkWidth / 2}
${targetControlX},${targetY + linkWidth / 2}
${targetX},${targetY + linkWidth / 2}
L${targetX},${targetY - linkWidth / 2}
C${targetControlX},${targetY - linkWidth / 2}
${sourceControlX},${sourceY - linkWidth / 2}
${sourceX},${sourceY - linkWidth / 2}
Z
`}
fill={color}
fillOpacity="0.4"
stroke="none"
/>
</g>
);
};
export function SankeyChart({ data, height = 400 }: SankeyChartProps) {
return (
<ResponsiveContainer width="100%" height={height}>
<Sankey
data={data}
node={<CustomNode />}
link={<CustomLink />}
nodePadding={50}
margin={{ top: 20, right: 160, bottom: 20, left: 160 }}
>
<Tooltip content={<CustomTooltip />} />
</Sankey>
</ResponsiveContainer>
);
}
+181
View File
@@ -0,0 +1,181 @@
"use client";
import {
CartesianGrid,
Legend,
ResponsiveContainer,
Scatter,
ScatterChart,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { AlertPill } from "./shared/AlertPill";
import { ChartLegend } from "./shared/ChartLegend";
import { CHART_COLORS } from "./shared/constants";
import { getSeverityColorByRiskScore } from "./shared/utils";
interface ScatterDataPoint {
x: number;
y: number;
provider: string;
name: string;
size?: number;
}
interface ScatterPlotProps {
data: ScatterDataPoint[];
xLabel?: string;
yLabel?: string;
height?: number;
onSelectPoint?: (point: ScatterDataPoint | null) => void;
selectedPoint?: ScatterDataPoint | null;
}
const PROVIDER_COLORS = {
AWS: "var(--color-orange)",
Azure: "var(--color-cyan)",
Google: "var(--color-red)",
};
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const severityColor = getSeverityColorByRiskScore(data.x);
return (
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<p className="text-sm font-semibold text-white">{data.name}</p>
<p className="mt-1 text-xs text-slate-400">
<span style={{ color: severityColor }}>{data.x}</span> Risk Score
</p>
<div className="mt-2">
<AlertPill value={data.y} />
</div>
</div>
);
}
return null;
};
const CustomScatterDot = ({
cx,
cy,
payload,
selectedPoint,
onSelectPoint,
}: any) => {
const isSelected = selectedPoint?.name === payload.name;
const size = isSelected ? 18 : 8;
const fill = isSelected
? "var(--color-success)"
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
CHART_COLORS.defaultColor;
return (
<circle
cx={cx}
cy={cy}
r={size / 2}
fill={fill}
stroke={isSelected ? "var(--color-success)" : "transparent"}
strokeWidth={2}
style={{ cursor: "pointer" }}
onClick={() => onSelectPoint?.(payload)}
/>
);
};
const CustomLegend = ({ payload }: any) => {
const items = payload.map((entry: any) => ({
label: entry.value,
color: entry.color,
}));
return <ChartLegend items={items} />;
};
export function ScatterPlot({
data,
xLabel = "Risk Score",
yLabel = "Failed Findings",
height = 400,
onSelectPoint,
selectedPoint,
}: ScatterPlotProps) {
const handlePointClick = (point: ScatterDataPoint) => {
if (onSelectPoint) {
if (selectedPoint?.name === point.name) {
onSelectPoint(null);
} else {
onSelectPoint(point);
}
}
};
const dataByProvider = data.reduce(
(acc, point) => {
const provider = point.provider;
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(point);
return acc;
},
{} as Record<string, ScatterDataPoint[]>,
);
return (
<ResponsiveContainer width="100%" height={height}>
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.gridLine} />
<XAxis
type="number"
dataKey="x"
name={xLabel}
label={{
value: xLabel,
position: "insideBottom",
offset: -10,
fill: CHART_COLORS.textSecondary,
}}
tick={{ fill: CHART_COLORS.textSecondary }}
domain={[0, 10]}
/>
<YAxis
type="number"
dataKey="y"
name={yLabel}
label={{
value: yLabel,
angle: -90,
position: "insideLeft",
fill: CHART_COLORS.textSecondary,
}}
tick={{ fill: CHART_COLORS.textSecondary }}
/>
<Tooltip content={<CustomTooltip />} />
<Legend content={<CustomLegend />} />
{Object.entries(dataByProvider).map(([provider, points]) => (
<Scatter
key={provider}
name={provider}
data={points}
fill={
PROVIDER_COLORS[provider as keyof typeof PROVIDER_COLORS] ||
CHART_COLORS.defaultColor
}
shape={(props: any) => (
<CustomScatterDot
{...props}
selectedPoint={selectedPoint}
onSelectPoint={handlePointClick}
/>
)}
/>
))}
</ScatterChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,33 @@
import { useState } from "react";
import { DEFAULT_SORT_OPTION, SORT_OPTIONS } from "../shared/constants";
type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS];
interface SortableItem {
name: string;
value: number;
}
export function useSortableData<T extends SortableItem>(data: T[]) {
const [sortBy, setSortBy] = useState<SortOption>(DEFAULT_SORT_OPTION);
const sortedData = [...data].sort((a, b) => {
switch (sortBy) {
case SORT_OPTIONS.highLow:
return b.value - a.value;
case SORT_OPTIONS.lowHigh:
return a.value - b.value;
case SORT_OPTIONS.alphabetical:
return a.name.localeCompare(b.name);
default:
return 0;
}
});
return {
sortBy,
setSortBy,
sortedData,
};
}
+9
View File
@@ -0,0 +1,9 @@
export { BarChart } from "./BarChart";
export { DonutChart } from "./DonutChart";
export { HorizontalBarChart } from "./HorizontalBarChart";
export { LineChart } from "./LineChart";
export { RadarChart } from "./RadarChart";
export { RadialChart } from "./RadialChart";
export { SankeyChart } from "./SankeyChart";
export { ScatterPlot } from "./ScatterPlot";
export { ChartLegend, type ChartLegendItem } from "./shared/ChartLegend";
+34
View File
@@ -0,0 +1,34 @@
import { AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
interface AlertPillProps {
value: number;
label?: string;
iconSize?: number;
textSize?: "xs" | "sm" | "base";
}
export function AlertPill({
value,
label = "Fail Findings",
iconSize = 12,
textSize = "xs",
}: AlertPillProps) {
return (
<div className="flex items-center gap-2">
<div className="bg-alert-pill-bg flex items-center gap-1 rounded-full px-2 py-1">
<AlertTriangle size={iconSize} className="text-alert-pill-text" />
<span
className={cn(
`text-${textSize}`,
"text-alert-pill-text font-semibold",
)}
>
{value}
</span>
</div>
<span className={cn(`text-${textSize}`, "text-slate-400")}>{label}</span>
</div>
);
}
@@ -0,0 +1,24 @@
export interface ChartLegendItem {
label: string;
color: string;
}
interface ChartLegendProps {
items: ChartLegendItem[];
}
export function ChartLegend({ items }: ChartLegendProps) {
return (
<div className="bg-card-border mt-4 inline-flex gap-[46px] rounded-full border-2 px-[19px] py-[9px]">
{items.map((item, index) => (
<div key={`legend-${index}`} className="flex items-center gap-1">
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: item.color }}
/>
<span className="text-xs text-gray-300">{item.label}</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,123 @@
import { Bell, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
import { TooltipData } from "../types";
interface ChartTooltipProps {
active?: boolean;
payload?: any[];
label?: string;
showColorIndicator?: boolean;
colorIndicatorShape?: "circle" | "square";
}
export function ChartTooltip({
active,
payload,
label,
showColorIndicator = true,
colorIndicatorShape = "square",
}: ChartTooltipProps) {
if (!active || !payload || payload.length === 0) {
return null;
}
const data: TooltipData = payload[0].payload || payload[0];
const color = payload[0].color || data.color;
return (
<div className="min-w-[200px] rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<div className="flex items-center gap-2">
{showColorIndicator && color && (
<div
className={cn(
"h-3 w-3",
colorIndicatorShape === "circle" ? "rounded-full" : "rounded-sm",
)}
style={{ backgroundColor: color }}
/>
)}
<p className="text-sm font-semibold text-white">{label || data.name}</p>
</div>
<p className="mt-1 text-xs text-white">
{typeof data.value === "number"
? data.value.toLocaleString()
: data.value}
{data.percentage !== undefined && ` (${data.percentage}%)`}
</p>
{data.newFindings !== undefined && data.newFindings > 0 && (
<div className="mt-1 flex items-center gap-2">
<Bell size={14} className="text-slate-400" />
<span className="text-xs text-slate-400">
{data.newFindings} New Findings
</span>
</div>
)}
{data.new !== undefined && data.new > 0 && (
<div className="mt-1 flex items-center gap-2">
<Bell size={14} className="text-slate-400" />
<span className="text-xs text-slate-400">{data.new} New</span>
</div>
)}
{data.muted !== undefined && data.muted > 0 && (
<div className="mt-1 flex items-center gap-2">
<VolumeX size={14} className="text-slate-400" />
<span className="text-xs text-slate-400">{data.muted} Muted</span>
</div>
)}
{data.change !== undefined && (
<p className="mt-1 text-xs text-slate-400">
<span className="font-bold">
{data.change > 0 ? "+" : ""}
{data.change}%
</span>{" "}
Since Last Scan
</p>
)}
</div>
);
}
/**
* Tooltip for charts with multiple data series (like LineChart)
*/
export function MultiSeriesChartTooltip({
active,
payload,
label,
}: ChartTooltipProps) {
if (!active || !payload || payload.length === 0) {
return null;
}
return (
<div className="min-w-[200px] rounded-lg border border-slate-700 bg-slate-800 p-3 shadow-lg">
<p className="mb-2 text-sm font-semibold text-white">{label}</p>
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-xs text-white">{entry.name}:</span>
<span className="text-xs font-semibold text-white">
{entry.value}
</span>
{entry.payload[`${entry.dataKey}_change`] && (
<span className="text-xs text-slate-400">
({entry.payload[`${entry.dataKey}_change`] > 0 ? "+" : ""}
{entry.payload[`${entry.dataKey}_change`]}%)
</span>
)}
</div>
))}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
export const SEVERITY_COLORS = {
Informational: "var(--color-info)",
Low: "var(--color-warning)",
Medium: "var(--color-warning-emphasis)",
High: "var(--color-danger)",
Critical: "var(--color-danger-emphasis)",
} as const;
export const CHART_COLORS = {
tooltipBorder: "var(--color-slate-700)",
tooltipBackground: "var(--color-slate-800)",
textPrimary: "var(--color-white)",
textSecondary: "var(--color-slate-400)",
gridLine: "var(--color-slate-700)",
backgroundTrack: "rgba(51, 65, 85, 0.5)", // slate-700 with 50% opacity
alertPillBg: "var(--color-alert-pill-bg)",
alertPillText: "var(--color-alert-pill-text)",
defaultColor: "var(--color-slate-500)", // Default fallback color for charts
} as const;
export const CHART_DIMENSIONS = {
defaultHeight: 400,
tooltipMinWidth: "200px",
borderRadius: "8px",
} as const;
export const SORT_OPTIONS = {
highLow: "high-low",
lowHigh: "low-high",
alphabetical: "alphabetical",
} as const;
export const DEFAULT_SORT_OPTION = SORT_OPTIONS.highLow;
export const SEVERITY_ORDER = {
Critical: 0,
High: 1,
Medium: 2,
Low: 3,
Informational: 4,
} as const;
export const LAYOUT_OPTIONS = {
horizontal: "horizontal",
vertical: "vertical",
} as const;
+13
View File
@@ -0,0 +1,13 @@
import { SEVERITY_COLORS } from "./constants";
export function getSeverityColorByRiskScore(riskScore: number): string {
if (riskScore >= 7) return SEVERITY_COLORS.Critical;
if (riskScore >= 5) return SEVERITY_COLORS.High;
if (riskScore >= 3) return SEVERITY_COLORS.Medium;
if (riskScore >= 1) return SEVERITY_COLORS.Low;
return SEVERITY_COLORS.Informational;
}
export function getSeverityColorByName(name: string): string | undefined {
return SEVERITY_COLORS[name as keyof typeof SEVERITY_COLORS];
}
+55
View File
@@ -0,0 +1,55 @@
import { LAYOUT_OPTIONS, SORT_OPTIONS } from "./shared/constants";
export type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS];
export type LayoutOption = (typeof LAYOUT_OPTIONS)[keyof typeof LAYOUT_OPTIONS];
export interface BaseDataPoint {
name: string;
value: number;
percentage?: number;
color?: string;
change?: number;
newFindings?: number;
}
export interface BarDataPoint extends BaseDataPoint {}
export interface DonutDataPoint {
name: string;
value: number;
color: string;
percentage?: number;
new?: number;
muted?: number;
change?: number;
}
export interface LineDataPoint {
date: string;
[key: string]: string | number;
}
export interface RadarDataPoint {
category: string;
value: number;
change?: number;
}
export interface LineConfig {
dataKey: string;
color: string;
label: string;
}
export interface TooltipData {
name: string;
value: number | string;
color?: string;
percentage?: number;
newFindings?: number;
new?: number;
muted?: number;
change?: number;
[key: string]: any;
}
+1 -1
View File
@@ -10,7 +10,7 @@ const alertVariants = cva(
variant: {
default: "bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50",
destructive:
"border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900",
"bg-danger-50 border-red-500/50 text-red-200 dark:border-red-500 dark:border-red-900/50 dark:text-red-200 dark:dark:border-red-900",
},
},
defaultVariants: {
+6 -1
View File
@@ -119,7 +119,12 @@ export const EditForm = ({
</div>
<div className="text-small flex items-center text-gray-600">
<ShieldIcon className="mr-2 h-4 w-4" />
<span className="text-gray-500">Role:</span>
<span className="text-gray-500">
Role:
<span className="ml-2 font-semibold text-gray-900">
{currentRole ? currentRole : "No role"}
</span>
</span>
<span className="ml-2 font-semibold text-gray-900">
{currentRole}
</span>
@@ -0,0 +1,67 @@
"use client";
import { Snippet } from "@heroui/snippet";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert/Alert";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { CustomButton } from "@/components/ui/custom/custom-button";
interface ApiKeySuccessModalProps {
isOpen: boolean;
onClose: () => void;
apiKey: string;
}
export const ApiKeySuccessModal = ({
isOpen,
onClose,
apiKey,
}: ApiKeySuccessModalProps) => {
return (
<CustomAlertModal
isOpen={isOpen}
onOpenChange={(open) => !open && onClose()}
title="API Key Created Successfully"
size="2xl"
>
<div className="flex flex-col gap-4">
<Alert variant="destructive">
<AlertTitle>⚠️ Important</AlertTitle>
<AlertDescription>
This is the only time you will see this API key. Please copy it now
and store it securely. Once you close this dialog, the key cannot be
retrieved again.
</AlertDescription>
</Alert>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Your API Key</p>
<Snippet
hideSymbol
classNames={{
pre: "font-mono text-sm break-all whitespace-pre-wrap",
}}
tooltipProps={{
content: "Copy API key",
color: "default",
}}
>
{apiKey}
</Snippet>
</div>
</div>
<CustomButton
ariaLabel="Close and confirm API key saved"
color="action"
onPress={onClose}
>
Acknowledged
</CustomButton>
</CustomAlertModal>
);
};
@@ -0,0 +1,131 @@
"use client";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { useDisclosure } from "@heroui/use-disclosure";
import { Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { CustomButton } from "@/components/ui/custom/custom-button";
import { DataTable } from "@/components/ui/table";
import { MetaDataProps } from "@/types";
import { ApiKeySuccessModal } from "./api-key-success-modal";
import { createApiKeyColumns } from "./api-keys/column-api-keys";
import { ICON_SIZE } from "./api-keys/constants";
import { EnrichedApiKey } from "./api-keys/types";
import { CreateApiKeyModal } from "./create-api-key-modal";
import { EditApiKeyNameModal } from "./edit-api-key-name-modal";
import { RevokeApiKeyModal } from "./revoke-api-key-modal";
interface ApiKeysCardClientProps {
initialApiKeys: EnrichedApiKey[];
metadata?: MetaDataProps;
}
export const ApiKeysCardClient = ({
initialApiKeys,
metadata,
}: ApiKeysCardClientProps) => {
const router = useRouter();
const [selectedApiKey, setSelectedApiKey] = useState<EnrichedApiKey | null>(
null,
);
const [createdApiKey, setCreatedApiKey] = useState<string | null>(null);
const createModal = useDisclosure();
const successModal = useDisclosure();
const revokeModal = useDisclosure();
const editModal = useDisclosure();
const handleCreateSuccess = (apiKey: string) => {
setCreatedApiKey(apiKey);
successModal.onOpen();
router.refresh();
};
const handleRevokeSuccess = () => {
router.refresh();
};
const handleEditSuccess = () => {
router.refresh();
};
const handleRevokeClick = (apiKey: EnrichedApiKey) => {
setSelectedApiKey(apiKey);
revokeModal.onOpen();
};
const handleEditClick = (apiKey: EnrichedApiKey) => {
setSelectedApiKey(apiKey);
editModal.onOpen();
};
const columns = createApiKeyColumns(handleEditClick, handleRevokeClick);
return (
<>
<Card className="dark:bg-prowler-blue-400">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div className="flex flex-col gap-1">
<h4 className="text-lg font-bold">API Keys</h4>
<p className="text-xs">Manage API keys for programmatic access</p>
</div>
<CustomButton
ariaLabel="Create new API key"
color="action"
size="sm"
startContent={<Plus size={ICON_SIZE} />}
onPress={createModal.onOpen}
>
Create API Key
</CustomButton>
</CardHeader>
<CardBody>
{initialApiKeys.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-12">
<p className="text-sm">No API keys created yet.</p>
</div>
) : (
<DataTable
columns={columns}
data={initialApiKeys}
metadata={metadata}
/>
)}
</CardBody>
</Card>
{/* Modals */}
<CreateApiKeyModal
isOpen={createModal.isOpen}
onClose={createModal.onClose}
onSuccess={handleCreateSuccess}
/>
{createdApiKey && (
<ApiKeySuccessModal
isOpen={successModal.isOpen}
onClose={successModal.onClose}
apiKey={createdApiKey}
/>
)}
<RevokeApiKeyModal
isOpen={revokeModal.isOpen}
onClose={revokeModal.onClose}
apiKey={selectedApiKey}
onSuccess={handleRevokeSuccess}
/>
<EditApiKeyNameModal
isOpen={editModal.isOpen}
onClose={editModal.onClose}
apiKey={selectedApiKey}
onSuccess={handleEditSuccess}
existingApiKeys={initialApiKeys}
/>
</>
);
};
@@ -0,0 +1,23 @@
import { getApiKeys } from "@/actions/api-keys/api-keys";
import { SearchParamsProps } from "@/types";
import { ApiKeysCardClient } from "./api-keys-card-client";
export const ApiKeysCard = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const sort = searchParams.sort?.toString();
const apiKeysResponse = await getApiKeys({ page, pageSize, sort });
return (
<ApiKeysCardClient
initialApiKeys={apiKeysResponse.data}
metadata={apiKeysResponse.metadata}
/>
);
};
@@ -0,0 +1,86 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTableColumnHeader } from "@/components/ui/table";
import { DataTableRowActions } from "./data-table-row-actions";
import {
DateCell,
EmailCell,
LastUsedCell,
NameCell,
PrefixCell,
StatusCell,
} from "./table-cells";
import { EnrichedApiKey } from "./types";
export const createApiKeyColumns = (
onEdit: (apiKey: EnrichedApiKey) => void,
onRevoke: (apiKey: EnrichedApiKey) => void,
): ColumnDef<EnrichedApiKey>[] => [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="NAME" param="name" />
),
cell: ({ row }) => <NameCell apiKey={row.original} />,
},
{
accessorKey: "prefix",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="PREFIX" param="prefix" />
),
cell: ({ row }) => <PrefixCell apiKey={row.original} />,
},
{
id: "email",
header: "EMAIL",
cell: ({ row }) => <EmailCell apiKey={row.original} />,
enableSorting: false,
},
{
accessorKey: "inserted_at",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="CREATED"
param="inserted_at"
/>
),
cell: ({ row }) => <DateCell date={row.original.attributes.inserted_at} />,
},
{
accessorKey: "last_used_at",
header: "LAST USED",
cell: ({ row }) => <LastUsedCell apiKey={row.original} />,
enableSorting: false,
},
{
accessorKey: "expires_at",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="EXPIRES"
param="expires_at"
/>
),
cell: ({ row }) => <DateCell date={row.original.attributes.expires_at} />,
},
{
accessorKey: "revoked",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="STATUS" param="revoked" />
),
cell: ({ row }) => <StatusCell apiKey={row.original} />,
},
{
id: "actions",
header: "",
cell: ({ row }) => {
return (
<DataTableRowActions row={row} onEdit={onEdit} onRevoke={onRevoke} />
);
},
},
];
@@ -0,0 +1,10 @@
export const DEFAULT_EXPIRY_DAYS = "365";
export const ICON_SIZE = 16;
// Fallback values for display
export const FALLBACK_VALUES = {
UNNAMED: "Unnamed",
UNNAMED_KEY: "Unnamed Key",
NEVER: "Never",
UNKNOWN: "Unknown",
} as const;
@@ -0,0 +1,86 @@
"use client";
import { Button } from "@heroui/button";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@heroui/dropdown";
import {
DeleteDocumentBulkIcon,
EditDocumentBulkIcon,
} from "@heroui/shared-icons";
import { Row } from "@tanstack/react-table";
import clsx from "clsx";
import { VerticalDotsIcon } from "@/components/icons";
import { EnrichedApiKey } from "./types";
interface DataTableRowActionsProps {
row: Row<EnrichedApiKey>;
onEdit: (apiKey: EnrichedApiKey) => void;
onRevoke: (apiKey: EnrichedApiKey) => void;
}
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
export function DataTableRowActions({
row,
onEdit,
onRevoke,
}: DataTableRowActionsProps) {
const apiKey = row.original;
return (
<div className="relative flex items-center justify-end gap-2">
<Dropdown
className="dark:bg-prowler-blue-800 shadow-xl"
placement="bottom"
>
<DropdownTrigger>
<Button isIconOnly radius="full" size="sm" variant="light">
<VerticalDotsIcon className="text-default-400" />
</Button>
</DropdownTrigger>
<DropdownMenu
closeOnSelect
aria-label="API Key actions"
color="default"
variant="flat"
>
<DropdownSection title="Actions">
<DropdownItem
key="edit"
description="Edit the API key name"
textValue="Edit name"
startContent={<EditDocumentBulkIcon className={iconClasses} />}
onPress={() => onEdit(apiKey)}
>
Edit name
</DropdownItem>
</DropdownSection>
<DropdownSection title="Danger zone">
<DropdownItem
key="revoke"
className="text-danger"
color="danger"
description="Revoke this API key permanently"
textValue="Revoke"
startContent={
<DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-danger")}
/>
}
onPress={() => onRevoke(apiKey)}
>
Revoke
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
</div>
);
}
@@ -0,0 +1,42 @@
import { CustomButton } from "@/components/ui/custom/custom-button";
interface ModalButtonsProps {
onCancel: () => void;
onSubmit: () => void;
isLoading: boolean;
isDisabled?: boolean;
submitText?: string;
submitColor?: "action" | "danger";
}
export const ModalButtons = ({
onCancel,
onSubmit,
isLoading,
isDisabled = false,
submitText = "Save",
submitColor = "action",
}: ModalButtonsProps) => {
return (
<div className="flex w-full justify-end gap-3 pt-4">
<CustomButton
ariaLabel="Cancel"
color="transparent"
variant="light"
onPress={onCancel}
isDisabled={isLoading}
>
Cancel
</CustomButton>
<CustomButton
ariaLabel={submitText}
color={submitColor}
onPress={onSubmit}
isLoading={isLoading}
isDisabled={isDisabled || isLoading}
>
{submitText}
</CustomButton>
</div>
);
};
@@ -0,0 +1,38 @@
import { Chip } from "@heroui/chip";
import { FALLBACK_VALUES } from "./constants";
import { EnrichedApiKey, getApiKeyStatus } from "./types";
import { formatRelativeTime, getStatusColor, getStatusLabel } from "./utils";
export const NameCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => (
<p className="text-sm font-medium">
{apiKey.attributes.name || FALLBACK_VALUES.UNNAMED}
</p>
);
export const PrefixCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => (
<code className="rounded px-2 py-1 font-mono text-xs">
{apiKey.attributes.prefix}
</code>
);
export const DateCell = ({ date }: { date: string | null }) => (
<p className="text-sm">{formatRelativeTime(date)}</p>
);
export const LastUsedCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => (
<DateCell date={apiKey.attributes.last_used_at} />
);
export const StatusCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => {
const status = getApiKeyStatus(apiKey);
return (
<Chip color={getStatusColor(status)} size="sm" variant="flat">
{getStatusLabel(status)}
</Chip>
);
};
export const EmailCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => (
<p className="text-sm">{apiKey.userEmail}</p>
);
@@ -0,0 +1,132 @@
// API Key types following JSON:API specification
export interface ApiKeyAttributes {
name: string | null;
prefix: string;
expires_at: string;
revoked: boolean;
inserted_at: string;
last_used_at: string | null;
}
export interface ApiKeyRelationships {
entity: {
data: {
type: "users";
id: string;
} | null;
};
}
export interface ApiKeyData {
type: "api-keys";
id: string;
attributes: ApiKeyAttributes;
relationships?: ApiKeyRelationships;
}
// Included resource types
export interface UserAttributes {
name: string;
email: string;
company_name: string;
date_joined: string;
}
export interface RoleAttributes {
name: string;
manage_users: boolean;
manage_account: boolean;
}
export interface UserData {
type: "users";
id: string;
attributes: UserAttributes;
relationships?: {
roles: {
data: Array<{
type: "roles";
id: string;
}>;
meta?: {
count: number;
};
};
};
}
export interface RoleData {
type: "roles";
id: string;
attributes: RoleAttributes;
}
export type IncludedResource = UserData | RoleData;
export interface ApiKeyResponse {
data: ApiKeyData[];
included?: IncludedResource[];
meta?: {
pagination?: {
page: number;
pages: number;
count: number;
};
};
}
/**
* Enriched API Key with user data already resolved
* This type extends the base ApiKeyData with additional fields
* populated from the included resources in the API response
*/
export interface EnrichedApiKey extends ApiKeyData {
userEmail: string;
}
export interface SingleApiKeyResponse {
data: ApiKeyData;
}
export interface CreateApiKeyResponse {
data: ApiKeyData & {
attributes: ApiKeyAttributes & {
api_key: string; // Only present on creation
};
};
}
export interface CreateApiKeyPayload {
name: string;
expires_at?: string; // ISO date string
}
export interface UpdateApiKeyPayload {
name: string;
}
// Status for UI display
export const API_KEY_STATUS = {
ACTIVE: "active",
REVOKED: "revoked",
EXPIRED: "expired",
} as const;
export type ApiKeyStatus = (typeof API_KEY_STATUS)[keyof typeof API_KEY_STATUS];
// Helper to determine API key status
export const getApiKeyStatus = (apiKey: ApiKeyData): ApiKeyStatus => {
if (apiKey.attributes.revoked) {
return API_KEY_STATUS.REVOKED;
}
const expiryDate = new Date(apiKey.attributes.expires_at);
const now = new Date();
if (expiryDate < now) {
return API_KEY_STATUS.EXPIRED;
}
return API_KEY_STATUS.ACTIVE;
};
@@ -0,0 +1,71 @@
import { useState } from "react";
interface UseModalFormOptions<TFormData> {
initialData: TFormData;
onSubmit: (data: TFormData) => Promise<void>;
onSuccess?: () => void;
onClose: () => void;
}
interface UseModalFormReturn<TFormData> {
formData: TFormData;
setFormData: React.Dispatch<React.SetStateAction<TFormData>>;
isLoading: boolean;
error: string | null;
setError: (error: string | null) => void;
handleSubmit: () => Promise<void>;
handleClose: () => void;
resetForm: () => void;
}
/**
* Custom hook to manage modal form state and submission logic
* Reduces boilerplate in modal components
*/
export const useModalForm = <TFormData>({
initialData,
onSubmit,
onSuccess,
onClose,
}: UseModalFormOptions<TFormData>): UseModalFormReturn<TFormData> => {
const [formData, setFormData] = useState<TFormData>(initialData);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const resetForm = () => {
setFormData(initialData);
setError(null);
};
const handleSubmit = async () => {
setIsLoading(true);
setError(null);
try {
await onSubmit(formData);
resetForm();
onSuccess?.();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
resetForm();
onClose();
};
return {
formData,
setFormData,
isLoading,
error,
setError,
handleSubmit,
handleClose,
resetForm,
};
};
@@ -0,0 +1,95 @@
import { formatDistanceToNow } from "date-fns";
import { FALLBACK_VALUES } from "./constants";
import {
API_KEY_STATUS,
ApiKeyData,
ApiKeyStatus,
IncludedResource,
UserData,
} from "./types";
export const getStatusColor = (
status: ApiKeyStatus,
): "success" | "danger" | "warning" => {
const colorMap: Record<ApiKeyStatus, "success" | "danger" | "warning"> = {
[API_KEY_STATUS.ACTIVE]: "success",
[API_KEY_STATUS.REVOKED]: "danger",
[API_KEY_STATUS.EXPIRED]: "warning",
};
return colorMap[status] || "success";
};
export const getStatusLabel = (status: ApiKeyStatus): string => {
const labelMap: Record<ApiKeyStatus, string> = {
[API_KEY_STATUS.ACTIVE]: "Active",
[API_KEY_STATUS.REVOKED]: "Revoked",
[API_KEY_STATUS.EXPIRED]: "Expired",
};
return labelMap[status] || FALLBACK_VALUES.UNKNOWN;
};
export const formatRelativeTime = (date: string | null): string => {
if (!date) return FALLBACK_VALUES.NEVER;
return formatDistanceToNow(new Date(date), { addSuffix: true });
};
export const calculateExpiryDate = (days: number): string => {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + days);
return expiresAt.toISOString();
};
/**
* Generic utility to find a resource in the included array by type and ID
*/
export const findIncludedResource = <T extends IncludedResource>(
included: IncludedResource[] | undefined,
type: string,
id: string,
): T | undefined => {
if (!included) return undefined;
return included.find(
(resource): resource is T => resource.type === type && resource.id === id,
);
};
/**
* Extracts the email from the included resources based on the API key's entity relationship
*/
export const getApiKeyUserEmail = (
apiKey: ApiKeyData,
included?: IncludedResource[],
): string => {
if (!apiKey.relationships?.entity?.data) {
return FALLBACK_VALUES.UNKNOWN;
}
const userId = apiKey.relationships.entity.data.id;
const user = findIncludedResource<UserData>(included, "users", userId);
return user?.attributes.email || FALLBACK_VALUES.UNKNOWN;
};
/**
* Checks if an API key name already exists in the list
* @param name - The name to check
* @param existingApiKeys - List of existing API keys
* @param excludeId - Optional ID to exclude from the check (for edit scenarios)
* @returns true if the name already exists, false otherwise
*/
export const isApiKeyNameDuplicate = (
name: string,
existingApiKeys: ApiKeyData[],
excludeId?: string,
): boolean => {
const trimmedName = name.trim().toLowerCase();
return existingApiKeys.some(
(key) =>
key.id !== excludeId &&
key.attributes.name?.toLowerCase() === trimmedName,
);
};
@@ -0,0 +1,141 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { createApiKey } from "@/actions/api-keys/api-keys";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { Form } from "@/components/ui/form";
import { DEFAULT_EXPIRY_DAYS } from "./api-keys/constants";
import { ModalButtons } from "./api-keys/modal-buttons";
import { calculateExpiryDate } from "./api-keys/utils";
interface CreateApiKeyModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (apiKey: string) => void;
}
const createApiKeySchema = z.object({
name: z.string().min(1, "Name is required"),
expiresInDays: z.string().refine((val) => {
const num = parseInt(val);
return num >= 1 && num <= 3650;
}, "Must be between 1 and 3650 days"),
});
type FormValues = z.infer<typeof createApiKeySchema>;
export const CreateApiKeyModal = ({
isOpen,
onClose,
onSuccess,
}: CreateApiKeyModalProps) => {
const { toast } = useToast();
const form = useForm<FormValues>({
resolver: zodResolver(createApiKeySchema),
defaultValues: {
name: "",
expiresInDays: DEFAULT_EXPIRY_DAYS,
},
});
const isLoading = form.formState.isSubmitting;
const onSubmitClient = async (values: FormValues) => {
try {
const result = await createApiKey({
name: values.name.trim(),
expires_at: calculateExpiryDate(parseInt(values.expiresInDays)),
});
if (result.error) {
throw new Error(result.error);
}
if (!result.data) {
throw new Error("Failed to create API key");
}
const apiKey = result.data.data.attributes.api_key;
if (!apiKey) {
throw new Error("Failed to retrieve API key");
}
form.reset();
onSuccess(apiKey);
onClose();
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
}
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<CustomAlertModal
isOpen={isOpen}
onOpenChange={(open) => !open && handleClose()}
title="Create API Key"
size="lg"
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-2">
<CustomInput
control={form.control}
name="name"
type="text"
label="Name"
labelPlacement="outside"
placeholder="My API Key"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.name}
/>
</div>
<div className="flex flex-col gap-2">
<CustomInput
control={form.control}
name="expiresInDays"
type="number"
label="Expires in (days)"
labelPlacement="outside"
placeholder="365"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.expiresInDays}
/>
</div>
<ModalButtons
onCancel={handleClose}
onSubmit={form.handleSubmit(onSubmitClient)}
isLoading={isLoading}
isDisabled={!form.formState.isValid}
submitText="Create API Key"
/>
</form>
</Form>
</CustomAlertModal>
);
};
@@ -0,0 +1,138 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { updateApiKey } from "@/actions/api-keys/api-keys";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { Form } from "@/components/ui/form";
import { ModalButtons } from "./api-keys/modal-buttons";
import { EnrichedApiKey } from "./api-keys/types";
import { isApiKeyNameDuplicate } from "./api-keys/utils";
interface EditApiKeyNameModalProps {
isOpen: boolean;
onClose: () => void;
apiKey: EnrichedApiKey | null;
onSuccess: () => void;
existingApiKeys: EnrichedApiKey[];
}
const editApiKeyNameSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof editApiKeyNameSchema>;
export const EditApiKeyNameModal = ({
isOpen,
onClose,
apiKey,
onSuccess,
existingApiKeys,
}: EditApiKeyNameModalProps) => {
const { toast } = useToast();
const form = useForm<FormValues>({
resolver: zodResolver(editApiKeyNameSchema),
defaultValues: {
name: apiKey?.attributes.name || "",
},
});
const isLoading = form.formState.isSubmitting;
// Sync form data when apiKey changes or modal opens
useEffect(() => {
if (isOpen && apiKey) {
form.reset({ name: apiKey.attributes.name || "" });
}
}, [isOpen, apiKey, form]);
const onSubmitClient = async (values: FormValues) => {
try {
if (!apiKey) {
throw new Error("API key not found");
}
if (isApiKeyNameDuplicate(values.name, existingApiKeys, apiKey.id)) {
throw new Error(
"An API key with this name already exists. Please choose a different name.",
);
}
const result = await updateApiKey(apiKey.id, {
name: values.name.trim(),
});
if (result.error) {
throw new Error(result.error);
}
form.reset();
onSuccess();
onClose();
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
}
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<CustomAlertModal
isOpen={isOpen}
onOpenChange={(open) => !open && handleClose()}
title="Edit API Key Name"
size="lg"
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4"
>
<div className="text-sm text-slate-400">
Prefix: {apiKey?.attributes.prefix}
</div>
<div className="flex flex-col gap-2">
<CustomInput
control={form.control}
name="name"
type="text"
label="Name"
labelPlacement="outside"
placeholder="My API Key"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.name}
/>
</div>
<ModalButtons
onCancel={handleClose}
onSubmit={form.handleSubmit(onSubmitClient)}
isLoading={isLoading}
isDisabled={!form.formState.isValid}
submitText="Save Changes"
/>
</form>
</Form>
</CustomAlertModal>
);
};
+6
View File
@@ -1,5 +1,11 @@
export * from "./api-key-success-modal";
export * from "./api-keys-card";
export * from "./api-keys-card-client";
export * from "./create-api-key-modal";
export * from "./edit-api-key-name-modal";
export * from "./membership-item";
export * from "./memberships-card";
export * from "./revoke-api-key-modal";
export * from "./role-item";
export * from "./roles-card";
export * from "./user-basic-info-card";
@@ -0,0 +1,93 @@
"use client";
import { revokeApiKey } from "@/actions/api-keys/api-keys";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert/Alert";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { FALLBACK_VALUES } from "./api-keys/constants";
import { ModalButtons } from "./api-keys/modal-buttons";
import { EnrichedApiKey } from "./api-keys/types";
import { useModalForm } from "./api-keys/use-modal-form";
interface RevokeApiKeyModalProps {
isOpen: boolean;
onClose: () => void;
apiKey: EnrichedApiKey | null;
onSuccess: () => void;
}
export const RevokeApiKeyModal = ({
isOpen,
onClose,
apiKey,
onSuccess,
}: RevokeApiKeyModalProps) => {
const { isLoading, error, handleSubmit, handleClose } = useModalForm({
initialData: {},
onSubmit: async () => {
if (!apiKey) {
throw new Error("No API key selected");
}
const result = await revokeApiKey(apiKey.id);
if (result.error) {
throw new Error(result.error);
}
onSuccess();
},
onSuccess,
onClose,
});
return (
<CustomAlertModal
isOpen={isOpen}
onOpenChange={(open) => !open && handleClose()}
title="Revoke API Key"
size="lg"
>
<div className="flex flex-col gap-4">
<Alert variant="destructive">
<AlertTitle className="text-danger-700">⚠️ Warning</AlertTitle>
<AlertDescription className="text-danger-600">
This action cannot be undone. This API key will be revoked and will
no longer work.
</AlertDescription>
</Alert>
<div className="text-sm">
<p>Are you sure you want to revoke this API key?</p>
<div className="mt-2 rounded-lg bg-slate-800 p-3">
<p className="font-medium text-white">
{apiKey?.attributes.name || FALLBACK_VALUES.UNNAMED_KEY}
</p>
<p className="mt-1 text-xs text-slate-400">
Prefix: {apiKey?.attributes.prefix}
</p>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
<ModalButtons
onCancel={handleClose}
onSubmit={handleSubmit}
isLoading={isLoading}
isDisabled={!apiKey}
submitText="Revoke API Key"
submitColor="danger"
/>
</CustomAlertModal>
);
};
+2 -4
View File
@@ -1,12 +1,10 @@
import {
Fira_Code as FontMono,
Plus_Jakarta_Sans as FontSans,
} from "next/font/google";
import { Fira_Code as FontMono, Work_Sans as FontSans } from "next/font/google";
export const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
preload: false,
display: "swap",
});
export const fontMono = FontMono({
+50 -40
View File
@@ -25,16 +25,15 @@ export const mapComplianceData = (
): Framework[] => {
const attributes = attributesData?.data || [];
const requirementsMap = createRequirementsMap(requirementsData);
const frameworks: Framework[] = [];
// Process attributes and merge with requirements data
// Process ALL attributes to ensure consistent counters for charts
for (const attributeItem of attributes) {
const id = attributeItem.id;
const metadataArray = attributeItem.attributes?.attributes
?.metadata as unknown as MITREAttributesMetadata[];
if (!metadataArray || metadataArray.length === 0) continue;
// Get corresponding requirement data
const requirementData = requirementsMap.get(id);
if (!requirementData) continue;
@@ -56,6 +55,7 @@ export const mapComplianceData = (
const framework = findOrCreateFramework(frameworks, frameworkName);
// Create requirement directly (flat structure - no categories)
// Include ALL requirements, even those without metadata (for accurate chart counts)
const finalStatus: RequirementStatus = status as RequirementStatus;
const requirement: Requirement = {
name: requirementName,
@@ -72,20 +72,23 @@ export const mapComplianceData = (
subtechniques: subtechniques,
platforms: platforms,
technique_url: techniqueUrl,
cloud_services: metadataArray.map((m) => {
// Dynamically find the service field (AWSService, GCPService, AzureService, etc.)
const serviceKey = Object.keys(m).find((key) =>
key.toLowerCase().includes("service"),
);
const serviceName = serviceKey ? m[serviceKey] : "Unknown Service";
// Mark items without metadata so accordion can filter them out
hasMetadata: !!(metadataArray && metadataArray.length > 0),
cloud_services:
metadataArray?.map((m) => {
// Dynamically find the service field (AWSService, GCPService, AzureService, etc.)
const serviceKey = Object.keys(m).find((key) =>
key.toLowerCase().includes("service"),
);
const serviceName = serviceKey ? m[serviceKey] : "Unknown Service";
return {
service: serviceName,
category: m.Category,
value: m.Value,
comment: m.Comment,
};
}),
return {
service: serviceName,
category: m.Category,
value: m.Value,
comment: m.Comment,
};
}) || [],
};
// Add requirement directly to framework (store in a special property)
@@ -106,31 +109,38 @@ export const toAccordionItems = (
return data.flatMap((framework) => {
const requirements = (framework as any).requirements || [];
return requirements.map((requirement: Requirement, i: number) => {
const itemKey = `${framework.name}-req-${i}`;
// Filter out requirements without metadata (can't be displayed in accordion)
const displayableRequirements = requirements.filter(
(requirement: Requirement) => requirement.hasMetadata !== false,
);
return {
key: itemKey,
title: (
<ComplianceAccordionRequirementTitle
type=""
name={requirement.name}
status={requirement.status as FindingStatus}
/>
),
content: (
<ClientAccordionContent
requirement={requirement}
scanId={scanId || ""}
framework={framework.name}
disableFindings={
requirement.check_ids.length === 0 && requirement.manual === 0
}
/>
),
items: [],
};
});
return displayableRequirements.map(
(requirement: Requirement, i: number) => {
const itemKey = `${framework.name}-req-${i}`;
return {
key: itemKey,
title: (
<ComplianceAccordionRequirementTitle
type=""
name={requirement.name}
status={requirement.status as FindingStatus}
/>
),
content: (
<ClientAccordionContent
requirement={requirement}
scanId={scanId || ""}
framework={framework.name}
disableFindings={
requirement.check_ids.length === 0 && requirement.manual === 0
}
/>
),
items: [],
};
},
);
});
};
+31 -12
View File
@@ -52,6 +52,8 @@ export const mapComplianceData = (
const weight = attrs.Weight;
const attributeDescription = attrs.AttributeDescription;
const additionalInformation = attrs.AdditionalInformation;
const passedFindings = requirementData.attributes.passed_findings || 0;
const totalFindings = requirementData.attributes.total_findings || 0;
// Calculate score: if PASS = levelOfRisk * weight, if FAIL = 0
const score = status === "PASS" ? levelOfRisk * weight : 0;
@@ -81,6 +83,8 @@ export const mapComplianceData = (
score: score,
attributeDescription: attributeDescription,
additionalInformation: additionalInformation,
passedFindings: passedFindings,
totalFindings: totalFindings,
};
control.requirements.push(requirement);
@@ -97,9 +101,10 @@ export const mapComplianceData = (
category.fail = 0;
category.manual = 0;
// Calculate total score for this section and maximum possible score
let totalSectionScore = 0;
let maxPossibleSectionScore = 0;
// Calculate ThreatScore using the new formula
let numerator = 0;
let denominator = 0;
let hasFindings = false;
category.controls.forEach((control) => {
control.pass = 0;
@@ -109,13 +114,25 @@ export const mapComplianceData = (
control.requirements.forEach((requirement) => {
updateCounters(control, requirement.status);
// Add to total section score (actual score obtained)
totalSectionScore += (requirement.score as number) || 0;
// Extract values for ThreatScore calculation
const pass_i = (requirement.passedFindings as number) || 0;
const total_i = (requirement.totalFindings as number) || 0;
// Add to maximum possible score (weight * levelOfRisk for each requirement)
// Skip if no findings (avoid division by zero)
if (total_i === 0) return;
hasFindings = true;
const rate_i = pass_i / total_i;
const weight_i = (requirement.weight as number) || 1;
const levelOfRisk = (requirement.levelOfRisk as number) || 0;
const weight = (requirement.weight as number) || 0;
maxPossibleSectionScore += levelOfRisk * weight;
// Map levelOfRisk to risk factor (rfac_i)
// Formula: rfac_i = 1 + (k * levelOfRisk) where k = 0.25
const rfac_i = 1 + 0.25 * levelOfRisk;
// Accumulate weighted values
numerator += rate_i * total_i * weight_i * rfac_i;
denominator += total_i * weight_i * rfac_i;
});
category.pass += control.pass;
@@ -123,10 +140,12 @@ export const mapComplianceData = (
category.manual += control.manual;
});
// Calculate percentualScore for this section: (suma de scores obtenidos / suma de weight * levelOfRisk) * 100
const percentualScore =
maxPossibleSectionScore > 0
? (totalSectionScore / maxPossibleSectionScore) * 100
// Calculate ThreatScore (percentualScore) for this section
// If no findings exist, consider it 100% (PASS)
const percentualScore = !hasFindings
? 100
: denominator > 0
? (numerator / denominator) * 100
: 0;
// Add percentualScore to category (we can extend the type or use a custom property)
+12 -1
View File
@@ -18,9 +18,20 @@ const isPublicRoute = (pathname: string): boolean => {
export default auth((req: NextRequest & { auth: any }) => {
const { pathname } = req.nextUrl;
const user = req.auth?.user;
const sessionError = req.auth?.error;
// If there's a session error (e.g., RefreshAccessTokenError), redirect to login with error info
if (sessionError && !isPublicRoute(pathname)) {
const signInUrl = new URL("/sign-in", req.url);
signInUrl.searchParams.set("error", sessionError);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);
}
if (!user && !isPublicRoute(pathname)) {
return NextResponse.redirect(new URL("/sign-in", req.url));
const signInUrl = new URL("/sign-in", req.url);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);
}
if (user?.permissions) {
+13 -12
View File
@@ -1,4 +1,4 @@
import { DefaultSession } from "next-auth";
import type { DefaultSession, User as NextAuthUser } from "next-auth";
import { RolePermissionAttributes } from "./types/users";
@@ -11,17 +11,18 @@ declare module "next-auth" {
permissions?: RolePermissionAttributes;
}
type SessionUser = NonNullable<DefaultSession["user"]> & {
companyName?: string;
dateJoined?: string;
permissions: RolePermissionAttributes;
};
interface Session extends DefaultSession {
user: {
name: string;
email: string;
companyName?: string;
dateJoined: string;
permissions: RolePermissionAttributes;
} & DefaultSession["user"];
userId: string;
tenantId: string;
accessToken: string;
refreshToken: string;
user?: SessionUser;
userId?: string;
tenantId?: string;
accessToken?: string;
refreshToken?: string;
error?: string;
}
}
+240 -5
View File
@@ -1,5 +1,240 @@
@import "tailwindcss";
@config "../tailwind.config.js";
@import "tailwindcss";
@config "../tailwind.config.js";
@theme {
/* Font Families are injected by Next.js from config/fonts.ts */
/* --font-sans: Work Sans (from Next.js) */
/* --font-mono: Fira Code (from Next.js) */
/* Text Sizes with Line Heights - Exact from Figma Design System */
--text-xs: 0.625rem; /* 10px */
--text-xs--line-height: 1.6;
--text-sm: 0.75rem; /* 12px */
--text-sm--line-height: 1.667;
--text-base: 0.875rem; /* 14px */
--text-base--line-height: 1.714;
--text-lg: 1rem; /* 16px */
--text-lg--line-height: 1.75;
--text-xl: 1.125rem; /* 18px */
--text-xl--line-height: 1.556;
--text-2xl: 1.25rem; /* 20px */
--text-2xl--line-height: 1.6;
--text-3xl: 1.5rem; /* 24px */
--text-3xl--line-height: 1.5;
--text-4xl: 1.875rem; /* 30px */
--text-4xl--line-height: 1.333;
--text-5xl: 2.25rem; /* 36px */
--text-5xl--line-height: 1.333;
--text-6xl: 3rem; /* 48px */
--text-6xl--line-height: 1.25;
--text-7xl: 3.75rem; /* 60px */
--text-7xl--line-height: 1.2;
--text-8xl: 4.5rem; /* 72px */
--text-8xl--line-height: 1.333;
--text-9xl: 6rem; /* 96px */
--text-9xl--line-height: 1.333;
/* Font Weights - Complete Inter Scale */
--font-weight-thin: 100;
--font-weight-extralight: 200;
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
--font-weight-black: 900;
/* Info & Primary Colors */
--color-info: #2e51b2;
--color-primary: #2e51b2;
/* Warning Colors */
--color-warning: #fdd34f;
--color-warning-emphasis: #ff7d19;
/* Danger & Destructive Colors */
--color-danger: #ff3077;
--color-danger-emphasis: #971348;
--color-destructive: #db2b49;
/* Success Colors */
--color-success: #86da26;
--color-success-emphasis: #20b853;
/* Provider-specific Colors */
--color-orange: #ff9800;
--color-cyan: #06b6d4;
--color-red: #ef4444;
/* Accent Colors */
--color-magenta: #b51c80;
--color-purple-dark: #5f1551;
/* Chart Card Colors - From Figma */
--color-card-bg: #080f23;
--color-card-border: #171d30;
--color-text-accent: #fface2;
--color-page-bg: #1a2034;
/* Alert Pill Colors */
--color-alert-pill-bg: #432232;
--color-alert-pill-text: #f54280;
/* ===========================
Border Radius
=========================== */
/* Widget Border Radius - From Figma */
--radius-widget: 12px;
/* White & Black */
--color-white: #ffffff;
--color-black: #000000;
/* Slate Scale */
--color-slate-50: #f8fafc;
--color-slate-100: #f1f5f9;
--color-slate-200: #e2e8f0;
--color-slate-300: #cbd5e1;
--color-slate-400: #94a3b8;
--color-slate-500: #64748b;
--color-slate-600: #475569;
--color-slate-700: #334155;
--color-slate-800: #1e293b;
--color-slate-900: #0f172a;
--color-slate-950: #020617;
/* Gray Scale */
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
--color-gray-950: #030712;
/* Zinc Scale */
--color-zinc-50: #fafafa;
--color-zinc-100: #f4f4f5;
--color-zinc-200: #e4e4e7;
--color-zinc-300: #d4d4d8;
--color-zinc-400: #a1a1aa;
--color-zinc-500: #71717a;
--color-zinc-600: #52525b;
--color-zinc-700: #3f3f46;
--color-zinc-800: #27272a;
--color-zinc-900: #18181b;
--color-zinc-950: #09090b;
/* Stone Scale */
--color-stone-50: #fafaf9;
--color-stone-100: #f5f5f4;
--color-stone-200: #e7e5e4;
--color-stone-300: #d6d3d1;
--color-stone-400: #a8a29e;
--color-stone-500: #78716c;
--color-stone-600: #57534e;
--color-stone-700: #44403c;
--color-stone-800: #292524;
--color-stone-900: #1c1917;
--color-stone-950: #0c0a09;
/* Red Scale */
--color-red-50: #fef2f2;
--color-red-100: #fee2e2;
--color-red-200: #fecaca;
--color-red-300: #fca5a5;
--color-red-400: #f87171;
--color-red-500: #ef4444;
--color-red-600: #dc2626;
--color-red-700: #b91c1c;
--color-red-800: #991b1b;
--color-red-900: #7f1d1d;
--color-red-950: #450a0a;
/* Rose Scale */
--color-rose-50: #fff1f2;
--color-rose-100: #ffe4e6;
--color-rose-200: #fecdd3;
--color-rose-300: #fda4af;
--color-rose-400: #fb7185;
--color-rose-500: #f43f5e;
--color-rose-600: #e11d48;
--color-rose-700: #be123c;
--color-rose-800: #9f1239;
--color-rose-900: #881337;
--color-rose-950: #4c0519;
/* Pink Scale */
--color-pink-50: #fdf2f8;
--color-pink-100: #fce7f3;
--color-pink-200: #fbcfe8;
--color-pink-300: #f9a8d4;
--color-pink-400: #f472b6;
--color-pink-500: #ec4899;
--color-pink-600: #db2777;
--color-pink-700: #be185d;
--color-pink-800: #9d174d;
--color-pink-900: #831843;
--color-pink-950: #500724;
/* Fuchsia Scale */
--color-fuchsia-50: #fdf4ff;
--color-fuchsia-100: #fae8ff;
--color-fuchsia-200: #f5d0fe;
--color-fuchsia-300: #f0abfc;
--color-fuchsia-400: #e879f9;
--color-fuchsia-500: #d946ef;
--color-fuchsia-600: #c026d3;
--color-fuchsia-700: #a21caf;
--color-fuchsia-800: #86198f;
--color-fuchsia-900: #701a75;
--color-fuchsia-950: #4a044e;
/* Purple Scale */
--color-purple-50: #faf5ff;
--color-purple-100: #f3e8ff;
--color-purple-200: #e9d5ff;
--color-purple-300: #d8b4fe;
--color-purple-400: #c084fc;
--color-purple-500: #a855f7;
--color-purple-600: #9333ea;
--color-purple-700: #7e22ce;
--color-purple-800: #6b21a8;
--color-purple-900: #581c87;
--color-purple-950: #3b0764;
/* Violet Scale */
--color-violet-50: #f5f3ff;
--color-violet-100: #ede9fe;
--color-violet-200: #ddd6fe;
--color-violet-300: #c4b5fd;
--color-violet-400: #a78bfa;
--color-violet-500: #8b5cf6;
--color-violet-600: #7c3aed;
--color-violet-700: #6d28d9;
--color-violet-800: #5b21b6;
--color-violet-900: #4c1d95;
--color-violet-950: #2e1065;
/* Indigo Scale */
--color-indigo-50: #eef2ff;
--color-indigo-100: #e0e7ff;
--color-indigo-200: #c7d2fe;
--color-indigo-300: #a5b4fc;
--color-indigo-400: #818cf8;
--color-indigo-500: #6366f1;
--color-indigo-600: #4f46e5;
--color-indigo-700: #4338ca;
--color-indigo-800: #3730a3;
--color-indigo-900: #312e81;
--color-indigo-950: #1e1b4b;
}
@layer base {
:root {
@@ -44,8 +279,8 @@
}
.checkbox-update {
margin-right: 0.5rem;
background-color: var(--background);
margin-right: 0.5rem;
background-color: var(--background);
}
}
@@ -64,4 +299,4 @@
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
}
+92
View File
@@ -0,0 +1,92 @@
import { test, expect } from "@playwright/test";
import {
goToLogin,
login,
verifySuccessfulLogin,
verifySessionValid,
TEST_CREDENTIALS,
URLS,
} from "./helpers";
test.describe("Middleware Error Handling", () => {
test("should allow access to public routes without session", async ({
page,
context,
}) => {
// Ensure no session exists
await context.clearCookies();
// Try to access login page (public route)
await page.goto(URLS.LOGIN);
await expect(page).toHaveURL(URLS.LOGIN);
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
// Try to access sign-up page (public route)
await page.goto(URLS.SIGNUP);
await expect(page).toHaveURL(URLS.SIGNUP);
});
test("should maintain protection after session error", async ({
page,
context,
}) => {
// Login
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Navigate to a protected page
await page.goto("/providers");
await expect(page).toHaveURL("/providers");
// Simulate session error by corrupting cookie
const cookies = await context.cookies();
const sessionCookie = cookies.find((c) =>
c.name.includes("authjs.session-token"),
);
if (sessionCookie) {
await context.clearCookies();
await context.addCookies([
{
...sessionCookie,
value: "invalid-session-token",
},
]);
// Try to navigate to another protected page
await page.goto("/scans", { waitUntil: "networkidle" });
// Should be redirected to login (may include callbackUrl)
await expect(page).toHaveURL(/\/sign-in/);
}
});
test("should handle permission-based redirects", async ({ page }) => {
// Login with valid credentials
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Get user permissions using helper
const session = await verifySessionValid(page);
const permissions = session.user.permissions;
// Test billing route if user doesn't have permission
if (!permissions.manage_billing) {
await page.goto("/billing", { waitUntil: "networkidle" });
// Should be redirected to profile (as per middleware logic)
await expect(page).toHaveURL("/profile");
}
// Test integrations route if user doesn't have permission
if (!permissions.manage_integrations) {
await page.goto("/integrations", { waitUntil: "networkidle" });
// Should be redirected to profile (as per middleware logic)
await expect(page).toHaveURL("/profile");
}
});
});
+124
View File
@@ -0,0 +1,124 @@
import { test, expect } from "@playwright/test";
import {
goToLogin,
login,
verifySuccessfulLogin,
getSession,
verifySessionValid,
TEST_CREDENTIALS,
URLS,
} from "./helpers";
test.describe("Token Refresh Flow", () => {
test("should refresh access token when expired", async ({ page }) => {
// Login first
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Get initial session using helper
const initialSession = await verifySessionValid(page);
const initialAccessToken = initialSession.accessToken;
// Wait for some time to allow token to potentially expire
// In a real scenario, you might want to manipulate the token expiry
await page.waitForTimeout(2000);
// Make a request that requires authentication
// This should trigger token refresh if needed
await page.reload();
await page.waitForLoadState("networkidle");
// Verify we're still authenticated
await expect(page).toHaveURL(URLS.DASHBOARD);
// Get session after potential refresh using helper
const refreshedSession = await verifySessionValid(page);
// User data should be maintained
expect(refreshedSession.user.email).toBe(initialSession.user.email);
expect(refreshedSession.userId).toBe(initialSession.userId);
expect(refreshedSession.tenantId).toBe(initialSession.tenantId);
});
test("should handle concurrent requests with token refresh", async ({
page,
}) => {
// Login
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Make multiple concurrent requests to the API
const requests = Array(5)
.fill(null)
.map(() => page.request.get("/api/auth/session"));
const responses = await Promise.all(requests);
// All requests should succeed - verify using helper
for (const response of responses) {
expect(response.ok()).toBeTruthy();
const session = await response.json();
// Validate session structure
expect(session).toBeTruthy();
expect(session.user).toBeTruthy();
expect(session.accessToken).toBeTruthy();
expect(session.refreshToken).toBeTruthy();
expect(session.error).toBeUndefined();
}
});
test("should preserve user permissions after token refresh", async ({
page,
}) => {
// Login
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Get initial session with permissions using helper
const initialSession = await verifySessionValid(page);
const initialPermissions = initialSession.user.permissions;
// Reload page to potentially trigger token refresh
await page.reload();
await page.waitForLoadState("networkidle");
// Get session after reload using helper
const refreshedSession = await verifySessionValid(page);
// Permissions should be preserved
expect(refreshedSession.user.permissions).toEqual(initialPermissions);
// All user data should be preserved
expect(refreshedSession.user.email).toBe(initialSession.user.email);
expect(refreshedSession.user.name).toBe(initialSession.user.name);
expect(refreshedSession.user.companyName).toBe(
initialSession.user.companyName,
);
});
test("should clear session when cookies are removed", async ({
page,
context,
}) => {
// Login
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Verify session is valid using helper
await verifySessionValid(page);
// Clear all cookies to simulate complete session expiry
await context.clearCookies();
// Verify session is null after clearing cookies
const expiredSession = await getSession(page);
expect(expiredSession).toBeNull();
// Note: Middleware redirect behavior is tested in auth-middleware-error.spec.ts
});
});
+102
View File
@@ -0,0 +1,102 @@
import { test, expect } from "@playwright/test";
import {
goToLogin,
login,
verifySuccessfulLogin,
TEST_CREDENTIALS,
URLS,
} from "./helpers";
test.describe("Session Error Messages", () => {
test("should show RefreshAccessTokenError message", async ({ page }) => {
// Navigate to sign-in with RefreshAccessTokenError query param
await page.goto("/sign-in?error=RefreshAccessTokenError");
// Wait for toast notification
await page.waitForTimeout(200);
// Verify error toast appears
const toast = page.locator('[role="status"], [role="alert"]').first();
const isVisible = await toast.isVisible().catch(() => false);
if (isVisible) {
const text = await toast.textContent();
expect(text).toContain("Session Expired");
expect(text).toContain("Please sign in again");
}
// Verify sign-in form is displayed
await expect(page.getByLabel("Email")).toBeVisible();
await expect(page.getByLabel("Password")).toBeVisible();
});
test("should show MissingRefreshToken error message", async ({ page }) => {
// Navigate to sign-in with MissingRefreshToken query param
await page.goto("/sign-in?error=MissingRefreshToken");
// Wait for toast notification
await page.waitForTimeout(200);
// Verify error toast appears
const toast = page.locator('[role="status"], [role="alert"]').first();
const isVisible = await toast.isVisible().catch(() => false);
if (isVisible) {
const text = await toast.textContent();
expect(text).toContain("Session Error");
}
// Verify sign-in form is displayed
await expect(page.getByLabel("Email")).toBeVisible();
});
test("should show generic error for unknown error types", async ({ page }) => {
// Navigate to sign-in with unknown error type
await page.goto("/sign-in?error=UnknownError");
// Wait for toast notification
await page.waitForTimeout(200);
// Verify generic error toast appears
const toast = page.locator('[role="status"], [role="alert"]').first();
const isVisible = await toast.isVisible().catch(() => false);
if (isVisible) {
const text = await toast.textContent();
expect(text).toContain("Authentication Error");
expect(text).toContain("Please sign in again");
}
});
test("should include callbackUrl in redirect", async ({
page,
context,
}) => {
// Login first
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Navigate to a specific page
await page.goto("/scans");
await page.waitForLoadState("networkidle");
// Clear cookies to simulate session expiry
await context.clearCookies();
// Try to navigate to a different protected route
await page.goto("/providers");
// Should be redirected to login with callbackUrl
await expect(page).toHaveURL(/\/sign-in\?.*callbackUrl=/);
// Verify callbackUrl contains the attempted route
const url = new URL(page.url());
const callbackUrl = url.searchParams.get("callbackUrl");
expect(callbackUrl).toBe("/providers");
});
});
+5 -5
View File
@@ -137,8 +137,8 @@ test.describe("Session Persistence", () => {
}) => {
// Try to access protected route without login
await page.goto(URLS.DASHBOARD);
// Should be redirected to login page
await expect(page).toHaveURL(URLS.LOGIN);
// Should be redirected to login page (may include callbackUrl)
await expect(page).toHaveURL(/\/sign-in/);
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
});
@@ -154,7 +154,7 @@ test.describe("Session Persistence", () => {
// Verify cannot access protected route after logout
await page.goto(URLS.DASHBOARD);
await expect(page).toHaveURL(URLS.LOGIN);
await expect(page).toHaveURL(/\/sign-in/);
});
test("should handle session timeout gracefully", async ({ browser }) => {
@@ -184,8 +184,8 @@ test.describe("Session Persistence", () => {
waitUntil: "networkidle",
});
// Should be redirected to login since this context has no auth
await expect(unauthPage).toHaveURL(URLS.LOGIN);
// Should be redirected to login since this context has no auth (may include callbackUrl)
await expect(unauthPage).toHaveURL(/\/sign-in/);
// Verify session is null in unauthenticated context
const unauthResponse = await unauthPage.request.get("/api/auth/session");
+1 -1
View File
@@ -75,7 +75,7 @@ const baseAuthSchema = z.object({
export const signInSchema = baseAuthSchema
.extend({
password: z.string().min(1, { message: "Password is required." }),
password: z.string(),
})
.refine(
(data) => {
+4 -1
View File
@@ -27,7 +27,7 @@ export interface Requirement {
check_ids: string[];
// This is to allow any key to be added to the requirement object
// because each compliance has different keys
[key: string]: string | string[] | number | object[] | undefined;
[key: string]: string | string[] | number | boolean | object[] | undefined;
}
export interface Control {
@@ -189,6 +189,9 @@ export interface RequirementItemData {
version: string;
description: string;
status: RequirementStatus;
// For Threat compliance:
passed_findings?: number;
total_findings?: number;
};
}