feat(ui): API keys implementation (#8874)

This commit is contained in:
Alan Buscaglia
2025-10-13 13:48:00 +02:00
committed by GitHub
parent 5f9ab68bd9
commit 741217ce80
20 changed files with 1410 additions and 12 deletions
+1
View File
@@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- 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
+44
View File
@@ -0,0 +1,44 @@
import {
ApiKeyResponse,
EnrichedApiKey,
} from "@/components/users/profile/api-keys/types";
import { getApiKeyUserEmail } from "@/components/users/profile/api-keys/utils";
import { MetaDataProps } from "@/types";
/**
* Adapts the raw API response to enriched API keys with metadata
* - Resolves user email from included resources
* - Co-locates data for better performance
* - Preserves pagination metadata
*
* @param response - Raw API response with data and included resources
* @returns Object with enriched API keys and metadata
*/
export function adaptApiKeysResponse(response: ApiKeyResponse | undefined): {
data: EnrichedApiKey[];
metadata?: MetaDataProps;
} {
if (!response?.data) {
return { data: [] };
}
const enrichedData = response.data.map((key) => ({
...key,
userEmail: getApiKeyUserEmail(key, response.included),
}));
// Transform meta to MetaDataProps format if pagination exists
const metadata: MetaDataProps | undefined = response.meta?.pagination
? {
pagination: {
page: response.meta.pagination.page,
pages: response.meta.pagination.pages,
count: response.meta.pagination.count,
itemsPerPage: [10, 25, 50, 100],
},
version: "1.0",
}
: undefined;
return { data: enrichedData, metadata };
}
+184
View File
@@ -0,0 +1,184 @@
"use server";
import { revalidateTag } from "next/cache";
import {
ApiKeyResponse,
CreateApiKeyPayload,
CreateApiKeyResponse,
SingleApiKeyResponse,
UpdateApiKeyPayload,
} from "@/components/users/profile/api-keys/types";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import { adaptApiKeysResponse } from "./api-keys.adapter";
interface GetApiKeysParams {
page?: number;
pageSize?: number;
sort?: string;
}
/**
* Fetches API keys for the current tenant with pagination support
* Returns enriched API keys with user data already resolved and pagination metadata
*/
export const getApiKeys = async (params?: GetApiKeysParams) => {
const { page = 1, pageSize = 10, sort } = params || {};
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/api-keys`);
url.searchParams.set("include", "entity.roles");
url.searchParams.set("page[number]", page.toString());
url.searchParams.set("page[size]", pageSize.toString());
if (sort) {
url.searchParams.set("sort", sort);
}
try {
const response = await fetch(url.toString(), {
headers,
next: { tags: ["api-keys"] },
});
const apiResponse = (await handleApiResponse(response)) as ApiKeyResponse;
return adaptApiKeysResponse(apiResponse);
} catch (error) {
console.error("Error fetching API keys:", error);
return { data: [], metadata: undefined };
}
};
/**
* Creates a new API key
* IMPORTANT: The full API key is only returned in this response, it cannot be retrieved again
*/
export const createApiKey = async (
payload: CreateApiKeyPayload,
): Promise<
| { data: CreateApiKeyResponse; error?: never }
| { data?: never; error: string }
> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/api-keys`);
const body = {
data: {
type: "api-keys",
attributes: {
name: payload.name,
...(payload.expires_at && { expires_at: payload.expires_at }),
},
},
};
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
return handleApiError(response);
}
const data = (await handleApiResponse(response)) as CreateApiKeyResponse;
// Revalidate the api-keys list
revalidateTag("api-keys");
return { data };
} catch (error) {
console.error("Error creating API key:", error);
return {
error:
error instanceof Error ? error.message : "Failed to create API key",
};
}
};
/**
* Updates an API key (only the name can be updated)
*/
export const updateApiKey = async (
id: string,
payload: UpdateApiKeyPayload,
): Promise<
| { data: SingleApiKeyResponse; error?: never }
| { data?: never; error: string }
> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/api-keys/${id}`);
const body = {
data: {
type: "api-keys",
id,
attributes: {
name: payload.name,
},
},
};
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
return handleApiError(response);
}
const data = (await handleApiResponse(response)) as SingleApiKeyResponse;
// Revalidate the api-keys list
revalidateTag("api-keys");
return { data };
} catch (error) {
console.error("Error updating API key:", error);
return {
error:
error instanceof Error ? error.message : "Failed to update API key",
};
}
};
/**
* Revokes an API key (cannot be undone)
*/
export const revokeApiKey = async (
id: string,
): Promise<{ error?: string; success?: boolean }> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/api-keys/${id}/revoke`);
try {
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
if (!response.ok) {
const errorData = handleApiError(response);
return { error: errorData.error };
}
// Revalidate the api-keys list
revalidateTag("api-keys");
return { success: true };
} catch (error) {
console.error("Error revoking API key:", error);
return {
error:
error instanceof Error ? error.message : "Failed to revoke API key",
};
}
};
+21 -11
View File
@@ -4,10 +4,11 @@ import { getSamlConfig } from "@/actions/integrations/saml";
import { getUserInfo } from "@/actions/users/users";
import { SamlIntegrationCard } from "@/components/integrations/saml/saml-integration-card";
import { ContentLayout } from "@/components/ui";
import { UserBasicInfoCard } from "@/components/users/profile";
import { ApiKeysCard, UserBasicInfoCard } from "@/components/users/profile";
import { MembershipsCard } from "@/components/users/profile/memberships-card";
import { RolesCard } from "@/components/users/profile/roles-card";
import { SkeletonUserInfo } from "@/components/users/profile/skeleton-user-info";
import { SearchParamsProps } from "@/types";
import {
MembershipDetailData,
RoleDetail,
@@ -15,17 +16,27 @@ import {
UserProfileResponse,
} from "@/types/users";
export default async function Profile() {
export default async function Profile({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
return (
<ContentLayout title="User Profile" icon="lucide:users">
<Suspense fallback={<SkeletonUserInfo />}>
<SSRDataUser />
<SSRDataUser searchParams={resolvedSearchParams} />
</Suspense>
</ContentLayout>
);
}
const SSRDataUser = async () => {
const SSRDataUser = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const userProfile = (await getUserInfo()) as UserProfileResponse | undefined;
if (!userProfile?.data) {
return null;
@@ -96,22 +107,21 @@ const SSRDataUser = async () => {
<div className="flex w-full flex-col gap-6">
<UserBasicInfoCard user={userData} tenantId={userTenantId || ""} />
<div className="flex flex-col gap-6 xl:flex-row">
<div className="w-full">
<div className="flex w-full flex-col gap-6 xl:max-w-[50%]">
<RolesCard roles={roleDetails} roleDetails={roleDetailsMap} />
{hasManageIntegrations && (
<SamlIntegrationCard samlConfig={samlConfig?.data?.[0]} />
)}
</div>
<div className="w-full">
<div className="flex w-full flex-col gap-6 xl:max-w-[50%]">
<MembershipsCard
memberships={membershipsIncluded}
tenantsMap={tenantsMap}
isOwner={isOwner && hasManageAccount}
/>
{hasManageAccount && <ApiKeysCard searchParams={searchParams} />}
</div>
</div>
{hasManageIntegrations && (
<div className="w-full pr-0 xl:w-1/2 xl:pr-3">
<SamlIntegrationCard samlConfig={samlConfig?.data?.[0]} />
</div>
)}
</div>
);
};
+1 -1
View File
@@ -10,7 +10,7 @@ const alertVariants = cva(
variant: {
default: "bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50",
destructive:
"border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900",
"bg-danger-50 border-red-500/50 text-red-200 dark:border-red-500 dark:border-red-900/50 dark:text-red-200 dark:dark:border-red-900",
},
},
defaultVariants: {
@@ -0,0 +1,67 @@
"use client";
import { Snippet } from "@heroui/snippet";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert/Alert";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { CustomButton } from "@/components/ui/custom/custom-button";
interface ApiKeySuccessModalProps {
isOpen: boolean;
onClose: () => void;
apiKey: string;
}
export const ApiKeySuccessModal = ({
isOpen,
onClose,
apiKey,
}: ApiKeySuccessModalProps) => {
return (
<CustomAlertModal
isOpen={isOpen}
onOpenChange={(open) => !open && onClose()}
title="API Key Created Successfully"
size="2xl"
>
<div className="flex flex-col gap-4">
<Alert variant="destructive">
<AlertTitle> Important</AlertTitle>
<AlertDescription>
This is the only time you will see this API key. Please copy it now
and store it securely. Once you close this dialog, the key cannot be
retrieved again.
</AlertDescription>
</Alert>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Your API Key</p>
<Snippet
hideSymbol
classNames={{
pre: "font-mono text-sm break-all whitespace-pre-wrap",
}}
tooltipProps={{
content: "Copy API key",
color: "default",
}}
>
{apiKey}
</Snippet>
</div>
</div>
<CustomButton
ariaLabel="Close and confirm API key saved"
color="action"
onPress={onClose}
>
Acknowledged
</CustomButton>
</CustomAlertModal>
);
};
@@ -0,0 +1,131 @@
"use client";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { useDisclosure } from "@heroui/use-disclosure";
import { Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { CustomButton } from "@/components/ui/custom/custom-button";
import { DataTable } from "@/components/ui/table";
import { MetaDataProps } from "@/types";
import { ApiKeySuccessModal } from "./api-key-success-modal";
import { createApiKeyColumns } from "./api-keys/column-api-keys";
import { ICON_SIZE } from "./api-keys/constants";
import { EnrichedApiKey } from "./api-keys/types";
import { CreateApiKeyModal } from "./create-api-key-modal";
import { EditApiKeyNameModal } from "./edit-api-key-name-modal";
import { RevokeApiKeyModal } from "./revoke-api-key-modal";
interface ApiKeysCardClientProps {
initialApiKeys: EnrichedApiKey[];
metadata?: MetaDataProps;
}
export const ApiKeysCardClient = ({
initialApiKeys,
metadata,
}: ApiKeysCardClientProps) => {
const router = useRouter();
const [selectedApiKey, setSelectedApiKey] = useState<EnrichedApiKey | null>(
null,
);
const [createdApiKey, setCreatedApiKey] = useState<string | null>(null);
const createModal = useDisclosure();
const successModal = useDisclosure();
const revokeModal = useDisclosure();
const editModal = useDisclosure();
const handleCreateSuccess = (apiKey: string) => {
setCreatedApiKey(apiKey);
successModal.onOpen();
router.refresh();
};
const handleRevokeSuccess = () => {
router.refresh();
};
const handleEditSuccess = () => {
router.refresh();
};
const handleRevokeClick = (apiKey: EnrichedApiKey) => {
setSelectedApiKey(apiKey);
revokeModal.onOpen();
};
const handleEditClick = (apiKey: EnrichedApiKey) => {
setSelectedApiKey(apiKey);
editModal.onOpen();
};
const columns = createApiKeyColumns(handleEditClick, handleRevokeClick);
return (
<>
<Card className="dark:bg-prowler-blue-400">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div className="flex flex-col gap-1">
<h4 className="text-lg font-bold">API Keys</h4>
<p className="text-xs">Manage API keys for programmatic access</p>
</div>
<CustomButton
ariaLabel="Create new API key"
color="action"
size="sm"
startContent={<Plus size={ICON_SIZE} />}
onPress={createModal.onOpen}
>
Create API Key
</CustomButton>
</CardHeader>
<CardBody>
{initialApiKeys.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-12">
<p className="text-sm">No API keys created yet.</p>
</div>
) : (
<DataTable
columns={columns}
data={initialApiKeys}
metadata={metadata}
/>
)}
</CardBody>
</Card>
{/* Modals */}
<CreateApiKeyModal
isOpen={createModal.isOpen}
onClose={createModal.onClose}
onSuccess={handleCreateSuccess}
/>
{createdApiKey && (
<ApiKeySuccessModal
isOpen={successModal.isOpen}
onClose={successModal.onClose}
apiKey={createdApiKey}
/>
)}
<RevokeApiKeyModal
isOpen={revokeModal.isOpen}
onClose={revokeModal.onClose}
apiKey={selectedApiKey}
onSuccess={handleRevokeSuccess}
/>
<EditApiKeyNameModal
isOpen={editModal.isOpen}
onClose={editModal.onClose}
apiKey={selectedApiKey}
onSuccess={handleEditSuccess}
existingApiKeys={initialApiKeys}
/>
</>
);
};
@@ -0,0 +1,23 @@
import { getApiKeys } from "@/actions/api-keys/api-keys";
import { SearchParamsProps } from "@/types";
import { ApiKeysCardClient } from "./api-keys-card-client";
export const ApiKeysCard = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const sort = searchParams.sort?.toString();
const apiKeysResponse = await getApiKeys({ page, pageSize, sort });
return (
<ApiKeysCardClient
initialApiKeys={apiKeysResponse.data}
metadata={apiKeysResponse.metadata}
/>
);
};
@@ -0,0 +1,86 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTableColumnHeader } from "@/components/ui/table";
import { DataTableRowActions } from "./data-table-row-actions";
import {
DateCell,
EmailCell,
LastUsedCell,
NameCell,
PrefixCell,
StatusCell,
} from "./table-cells";
import { EnrichedApiKey } from "./types";
export const createApiKeyColumns = (
onEdit: (apiKey: EnrichedApiKey) => void,
onRevoke: (apiKey: EnrichedApiKey) => void,
): ColumnDef<EnrichedApiKey>[] => [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="NAME" param="name" />
),
cell: ({ row }) => <NameCell apiKey={row.original} />,
},
{
accessorKey: "prefix",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="PREFIX" param="prefix" />
),
cell: ({ row }) => <PrefixCell apiKey={row.original} />,
},
{
id: "email",
header: "EMAIL",
cell: ({ row }) => <EmailCell apiKey={row.original} />,
enableSorting: false,
},
{
accessorKey: "inserted_at",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="CREATED"
param="inserted_at"
/>
),
cell: ({ row }) => <DateCell date={row.original.attributes.inserted_at} />,
},
{
accessorKey: "last_used_at",
header: "LAST USED",
cell: ({ row }) => <LastUsedCell apiKey={row.original} />,
enableSorting: false,
},
{
accessorKey: "expires_at",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="EXPIRES"
param="expires_at"
/>
),
cell: ({ row }) => <DateCell date={row.original.attributes.expires_at} />,
},
{
accessorKey: "revoked",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="STATUS" param="revoked" />
),
cell: ({ row }) => <StatusCell apiKey={row.original} />,
},
{
id: "actions",
header: "",
cell: ({ row }) => {
return (
<DataTableRowActions row={row} onEdit={onEdit} onRevoke={onRevoke} />
);
},
},
];
@@ -0,0 +1,10 @@
export const DEFAULT_EXPIRY_DAYS = "365";
export const ICON_SIZE = 16;
// Fallback values for display
export const FALLBACK_VALUES = {
UNNAMED: "Unnamed",
UNNAMED_KEY: "Unnamed Key",
NEVER: "Never",
UNKNOWN: "Unknown",
} as const;
@@ -0,0 +1,86 @@
"use client";
import { Button } from "@heroui/button";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@heroui/dropdown";
import {
DeleteDocumentBulkIcon,
EditDocumentBulkIcon,
} from "@heroui/shared-icons";
import { Row } from "@tanstack/react-table";
import clsx from "clsx";
import { VerticalDotsIcon } from "@/components/icons";
import { EnrichedApiKey } from "./types";
interface DataTableRowActionsProps {
row: Row<EnrichedApiKey>;
onEdit: (apiKey: EnrichedApiKey) => void;
onRevoke: (apiKey: EnrichedApiKey) => void;
}
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
export function DataTableRowActions({
row,
onEdit,
onRevoke,
}: DataTableRowActionsProps) {
const apiKey = row.original;
return (
<div className="relative flex items-center justify-end gap-2">
<Dropdown
className="dark:bg-prowler-blue-800 shadow-xl"
placement="bottom"
>
<DropdownTrigger>
<Button isIconOnly radius="full" size="sm" variant="light">
<VerticalDotsIcon className="text-default-400" />
</Button>
</DropdownTrigger>
<DropdownMenu
closeOnSelect
aria-label="API Key actions"
color="default"
variant="flat"
>
<DropdownSection title="Actions">
<DropdownItem
key="edit"
description="Edit the API key name"
textValue="Edit name"
startContent={<EditDocumentBulkIcon className={iconClasses} />}
onPress={() => onEdit(apiKey)}
>
Edit name
</DropdownItem>
</DropdownSection>
<DropdownSection title="Danger zone">
<DropdownItem
key="revoke"
className="text-danger"
color="danger"
description="Revoke this API key permanently"
textValue="Revoke"
startContent={
<DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-danger")}
/>
}
onPress={() => onRevoke(apiKey)}
>
Revoke
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
</div>
);
}
@@ -0,0 +1,42 @@
import { CustomButton } from "@/components/ui/custom/custom-button";
interface ModalButtonsProps {
onCancel: () => void;
onSubmit: () => void;
isLoading: boolean;
isDisabled?: boolean;
submitText?: string;
submitColor?: "action" | "danger";
}
export const ModalButtons = ({
onCancel,
onSubmit,
isLoading,
isDisabled = false,
submitText = "Save",
submitColor = "action",
}: ModalButtonsProps) => {
return (
<div className="flex w-full justify-end gap-3 pt-4">
<CustomButton
ariaLabel="Cancel"
color="transparent"
variant="light"
onPress={onCancel}
isDisabled={isLoading}
>
Cancel
</CustomButton>
<CustomButton
ariaLabel={submitText}
color={submitColor}
onPress={onSubmit}
isLoading={isLoading}
isDisabled={isDisabled || isLoading}
>
{submitText}
</CustomButton>
</div>
);
};
@@ -0,0 +1,38 @@
import { Chip } from "@heroui/chip";
import { FALLBACK_VALUES } from "./constants";
import { EnrichedApiKey, getApiKeyStatus } from "./types";
import { formatRelativeTime, getStatusColor, getStatusLabel } from "./utils";
export const NameCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => (
<p className="text-sm font-medium">
{apiKey.attributes.name || FALLBACK_VALUES.UNNAMED}
</p>
);
export const PrefixCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => (
<code className="rounded px-2 py-1 font-mono text-xs">
{apiKey.attributes.prefix}
</code>
);
export const DateCell = ({ date }: { date: string | null }) => (
<p className="text-sm">{formatRelativeTime(date)}</p>
);
export const LastUsedCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => (
<DateCell date={apiKey.attributes.last_used_at} />
);
export const StatusCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => {
const status = getApiKeyStatus(apiKey);
return (
<Chip color={getStatusColor(status)} size="sm" variant="flat">
{getStatusLabel(status)}
</Chip>
);
};
export const EmailCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => (
<p className="text-sm">{apiKey.userEmail}</p>
);
@@ -0,0 +1,132 @@
// API Key types following JSON:API specification
export interface ApiKeyAttributes {
name: string | null;
prefix: string;
expires_at: string;
revoked: boolean;
inserted_at: string;
last_used_at: string | null;
}
export interface ApiKeyRelationships {
entity: {
data: {
type: "users";
id: string;
} | null;
};
}
export interface ApiKeyData {
type: "api-keys";
id: string;
attributes: ApiKeyAttributes;
relationships?: ApiKeyRelationships;
}
// Included resource types
export interface UserAttributes {
name: string;
email: string;
company_name: string;
date_joined: string;
}
export interface RoleAttributes {
name: string;
manage_users: boolean;
manage_account: boolean;
}
export interface UserData {
type: "users";
id: string;
attributes: UserAttributes;
relationships?: {
roles: {
data: Array<{
type: "roles";
id: string;
}>;
meta?: {
count: number;
};
};
};
}
export interface RoleData {
type: "roles";
id: string;
attributes: RoleAttributes;
}
export type IncludedResource = UserData | RoleData;
export interface ApiKeyResponse {
data: ApiKeyData[];
included?: IncludedResource[];
meta?: {
pagination?: {
page: number;
pages: number;
count: number;
};
};
}
/**
* Enriched API Key with user data already resolved
* This type extends the base ApiKeyData with additional fields
* populated from the included resources in the API response
*/
export interface EnrichedApiKey extends ApiKeyData {
userEmail: string;
}
export interface SingleApiKeyResponse {
data: ApiKeyData;
}
export interface CreateApiKeyResponse {
data: ApiKeyData & {
attributes: ApiKeyAttributes & {
api_key: string; // Only present on creation
};
};
}
export interface CreateApiKeyPayload {
name: string;
expires_at?: string; // ISO date string
}
export interface UpdateApiKeyPayload {
name: string;
}
// Status for UI display
export const API_KEY_STATUS = {
ACTIVE: "active",
REVOKED: "revoked",
EXPIRED: "expired",
} as const;
export type ApiKeyStatus = (typeof API_KEY_STATUS)[keyof typeof API_KEY_STATUS];
// Helper to determine API key status
export const getApiKeyStatus = (apiKey: ApiKeyData): ApiKeyStatus => {
if (apiKey.attributes.revoked) {
return API_KEY_STATUS.REVOKED;
}
const expiryDate = new Date(apiKey.attributes.expires_at);
const now = new Date();
if (expiryDate < now) {
return API_KEY_STATUS.EXPIRED;
}
return API_KEY_STATUS.ACTIVE;
};
@@ -0,0 +1,71 @@
import { useState } from "react";
interface UseModalFormOptions<TFormData> {
initialData: TFormData;
onSubmit: (data: TFormData) => Promise<void>;
onSuccess?: () => void;
onClose: () => void;
}
interface UseModalFormReturn<TFormData> {
formData: TFormData;
setFormData: React.Dispatch<React.SetStateAction<TFormData>>;
isLoading: boolean;
error: string | null;
setError: (error: string | null) => void;
handleSubmit: () => Promise<void>;
handleClose: () => void;
resetForm: () => void;
}
/**
* Custom hook to manage modal form state and submission logic
* Reduces boilerplate in modal components
*/
export const useModalForm = <TFormData>({
initialData,
onSubmit,
onSuccess,
onClose,
}: UseModalFormOptions<TFormData>): UseModalFormReturn<TFormData> => {
const [formData, setFormData] = useState<TFormData>(initialData);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const resetForm = () => {
setFormData(initialData);
setError(null);
};
const handleSubmit = async () => {
setIsLoading(true);
setError(null);
try {
await onSubmit(formData);
resetForm();
onSuccess?.();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
resetForm();
onClose();
};
return {
formData,
setFormData,
isLoading,
error,
setError,
handleSubmit,
handleClose,
resetForm,
};
};
@@ -0,0 +1,95 @@
import { formatDistanceToNow } from "date-fns";
import { FALLBACK_VALUES } from "./constants";
import {
API_KEY_STATUS,
ApiKeyData,
ApiKeyStatus,
IncludedResource,
UserData,
} from "./types";
export const getStatusColor = (
status: ApiKeyStatus,
): "success" | "danger" | "warning" => {
const colorMap: Record<ApiKeyStatus, "success" | "danger" | "warning"> = {
[API_KEY_STATUS.ACTIVE]: "success",
[API_KEY_STATUS.REVOKED]: "danger",
[API_KEY_STATUS.EXPIRED]: "warning",
};
return colorMap[status] || "success";
};
export const getStatusLabel = (status: ApiKeyStatus): string => {
const labelMap: Record<ApiKeyStatus, string> = {
[API_KEY_STATUS.ACTIVE]: "Active",
[API_KEY_STATUS.REVOKED]: "Revoked",
[API_KEY_STATUS.EXPIRED]: "Expired",
};
return labelMap[status] || FALLBACK_VALUES.UNKNOWN;
};
export const formatRelativeTime = (date: string | null): string => {
if (!date) return FALLBACK_VALUES.NEVER;
return formatDistanceToNow(new Date(date), { addSuffix: true });
};
export const calculateExpiryDate = (days: number): string => {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + days);
return expiresAt.toISOString();
};
/**
* Generic utility to find a resource in the included array by type and ID
*/
export const findIncludedResource = <T extends IncludedResource>(
included: IncludedResource[] | undefined,
type: string,
id: string,
): T | undefined => {
if (!included) return undefined;
return included.find(
(resource): resource is T => resource.type === type && resource.id === id,
);
};
/**
* Extracts the email from the included resources based on the API key's entity relationship
*/
export const getApiKeyUserEmail = (
apiKey: ApiKeyData,
included?: IncludedResource[],
): string => {
if (!apiKey.relationships?.entity?.data) {
return FALLBACK_VALUES.UNKNOWN;
}
const userId = apiKey.relationships.entity.data.id;
const user = findIncludedResource<UserData>(included, "users", userId);
return user?.attributes.email || FALLBACK_VALUES.UNKNOWN;
};
/**
* Checks if an API key name already exists in the list
* @param name - The name to check
* @param existingApiKeys - List of existing API keys
* @param excludeId - Optional ID to exclude from the check (for edit scenarios)
* @returns true if the name already exists, false otherwise
*/
export const isApiKeyNameDuplicate = (
name: string,
existingApiKeys: ApiKeyData[],
excludeId?: string,
): boolean => {
const trimmedName = name.trim().toLowerCase();
return existingApiKeys.some(
(key) =>
key.id !== excludeId &&
key.attributes.name?.toLowerCase() === trimmedName,
);
};
@@ -0,0 +1,141 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { createApiKey } from "@/actions/api-keys/api-keys";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { Form } from "@/components/ui/form";
import { DEFAULT_EXPIRY_DAYS } from "./api-keys/constants";
import { ModalButtons } from "./api-keys/modal-buttons";
import { calculateExpiryDate } from "./api-keys/utils";
interface CreateApiKeyModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (apiKey: string) => void;
}
const createApiKeySchema = z.object({
name: z.string().min(1, "Name is required"),
expiresInDays: z.string().refine((val) => {
const num = parseInt(val);
return num >= 1 && num <= 3650;
}, "Must be between 1 and 3650 days"),
});
type FormValues = z.infer<typeof createApiKeySchema>;
export const CreateApiKeyModal = ({
isOpen,
onClose,
onSuccess,
}: CreateApiKeyModalProps) => {
const { toast } = useToast();
const form = useForm<FormValues>({
resolver: zodResolver(createApiKeySchema),
defaultValues: {
name: "",
expiresInDays: DEFAULT_EXPIRY_DAYS,
},
});
const isLoading = form.formState.isSubmitting;
const onSubmitClient = async (values: FormValues) => {
try {
const result = await createApiKey({
name: values.name.trim(),
expires_at: calculateExpiryDate(parseInt(values.expiresInDays)),
});
if (result.error) {
throw new Error(result.error);
}
if (!result.data) {
throw new Error("Failed to create API key");
}
const apiKey = result.data.data.attributes.api_key;
if (!apiKey) {
throw new Error("Failed to retrieve API key");
}
form.reset();
onSuccess(apiKey);
onClose();
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
}
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<CustomAlertModal
isOpen={isOpen}
onOpenChange={(open) => !open && handleClose()}
title="Create API Key"
size="lg"
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-2">
<CustomInput
control={form.control}
name="name"
type="text"
label="Name"
labelPlacement="outside"
placeholder="My API Key"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.name}
/>
</div>
<div className="flex flex-col gap-2">
<CustomInput
control={form.control}
name="expiresInDays"
type="number"
label="Expires in (days)"
labelPlacement="outside"
placeholder="365"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.expiresInDays}
/>
</div>
<ModalButtons
onCancel={handleClose}
onSubmit={form.handleSubmit(onSubmitClient)}
isLoading={isLoading}
isDisabled={!form.formState.isValid}
submitText="Create API Key"
/>
</form>
</Form>
</CustomAlertModal>
);
};
@@ -0,0 +1,138 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { updateApiKey } from "@/actions/api-keys/api-keys";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { Form } from "@/components/ui/form";
import { ModalButtons } from "./api-keys/modal-buttons";
import { EnrichedApiKey } from "./api-keys/types";
import { isApiKeyNameDuplicate } from "./api-keys/utils";
interface EditApiKeyNameModalProps {
isOpen: boolean;
onClose: () => void;
apiKey: EnrichedApiKey | null;
onSuccess: () => void;
existingApiKeys: EnrichedApiKey[];
}
const editApiKeyNameSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof editApiKeyNameSchema>;
export const EditApiKeyNameModal = ({
isOpen,
onClose,
apiKey,
onSuccess,
existingApiKeys,
}: EditApiKeyNameModalProps) => {
const { toast } = useToast();
const form = useForm<FormValues>({
resolver: zodResolver(editApiKeyNameSchema),
defaultValues: {
name: apiKey?.attributes.name || "",
},
});
const isLoading = form.formState.isSubmitting;
// Sync form data when apiKey changes or modal opens
useEffect(() => {
if (isOpen && apiKey) {
form.reset({ name: apiKey.attributes.name || "" });
}
}, [isOpen, apiKey, form]);
const onSubmitClient = async (values: FormValues) => {
try {
if (!apiKey) {
throw new Error("API key not found");
}
if (isApiKeyNameDuplicate(values.name, existingApiKeys, apiKey.id)) {
throw new Error(
"An API key with this name already exists. Please choose a different name.",
);
}
const result = await updateApiKey(apiKey.id, {
name: values.name.trim(),
});
if (result.error) {
throw new Error(result.error);
}
form.reset();
onSuccess();
onClose();
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
}
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<CustomAlertModal
isOpen={isOpen}
onOpenChange={(open) => !open && handleClose()}
title="Edit API Key Name"
size="lg"
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4"
>
<div className="text-sm text-slate-400">
Prefix: {apiKey?.attributes.prefix}
</div>
<div className="flex flex-col gap-2">
<CustomInput
control={form.control}
name="name"
type="text"
label="Name"
labelPlacement="outside"
placeholder="My API Key"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.name}
/>
</div>
<ModalButtons
onCancel={handleClose}
onSubmit={form.handleSubmit(onSubmitClient)}
isLoading={isLoading}
isDisabled={!form.formState.isValid}
submitText="Save Changes"
/>
</form>
</Form>
</CustomAlertModal>
);
};
+6
View File
@@ -1,5 +1,11 @@
export * from "./api-key-success-modal";
export * from "./api-keys-card";
export * from "./api-keys-card-client";
export * from "./create-api-key-modal";
export * from "./edit-api-key-name-modal";
export * from "./membership-item";
export * from "./memberships-card";
export * from "./revoke-api-key-modal";
export * from "./role-item";
export * from "./roles-card";
export * from "./user-basic-info-card";
@@ -0,0 +1,93 @@
"use client";
import { revokeApiKey } from "@/actions/api-keys/api-keys";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert/Alert";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { FALLBACK_VALUES } from "./api-keys/constants";
import { ModalButtons } from "./api-keys/modal-buttons";
import { EnrichedApiKey } from "./api-keys/types";
import { useModalForm } from "./api-keys/use-modal-form";
interface RevokeApiKeyModalProps {
isOpen: boolean;
onClose: () => void;
apiKey: EnrichedApiKey | null;
onSuccess: () => void;
}
export const RevokeApiKeyModal = ({
isOpen,
onClose,
apiKey,
onSuccess,
}: RevokeApiKeyModalProps) => {
const { isLoading, error, handleSubmit, handleClose } = useModalForm({
initialData: {},
onSubmit: async () => {
if (!apiKey) {
throw new Error("No API key selected");
}
const result = await revokeApiKey(apiKey.id);
if (result.error) {
throw new Error(result.error);
}
onSuccess();
},
onSuccess,
onClose,
});
return (
<CustomAlertModal
isOpen={isOpen}
onOpenChange={(open) => !open && handleClose()}
title="Revoke API Key"
size="lg"
>
<div className="flex flex-col gap-4">
<Alert variant="destructive">
<AlertTitle className="text-danger-700"> Warning</AlertTitle>
<AlertDescription className="text-danger-600">
This action cannot be undone. This API key will be revoked and will
no longer work.
</AlertDescription>
</Alert>
<div className="text-sm">
<p>Are you sure you want to revoke this API key?</p>
<div className="mt-2 rounded-lg bg-slate-800 p-3">
<p className="font-medium text-white">
{apiKey?.attributes.name || FALLBACK_VALUES.UNNAMED_KEY}
</p>
<p className="mt-1 text-xs text-slate-400">
Prefix: {apiKey?.attributes.prefix}
</p>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
<ModalButtons
onCancel={handleClose}
onSubmit={handleSubmit}
isLoading={isLoading}
isDisabled={!apiKey}
submitText="Revoke API Key"
submitColor="danger"
/>
</CustomAlertModal>
);
};