mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(ui): API keys implementation (#8874)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user