mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-31 07:44:21 +00:00
Merge branch 'master' into PROWLER-187-create-new-user
This commit is contained in:
commit
cef7fcc24b
126 files changed
+8165
-2045
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Vendored
+13
-12
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user