diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 471df7c7d0..022f719817 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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 diff --git a/ui/actions/api-keys/api-keys.adapter.ts b/ui/actions/api-keys/api-keys.adapter.ts new file mode 100644 index 0000000000..313cf0dba7 --- /dev/null +++ b/ui/actions/api-keys/api-keys.adapter.ts @@ -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 }; +} diff --git a/ui/actions/api-keys/api-keys.ts b/ui/actions/api-keys/api-keys.ts new file mode 100644 index 0000000000..966cc2114b --- /dev/null +++ b/ui/actions/api-keys/api-keys.ts @@ -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", + }; + } +}; diff --git a/ui/app/(prowler)/profile/page.tsx b/ui/app/(prowler)/profile/page.tsx index c3898439bd..41ff81a5fb 100644 --- a/ui/app/(prowler)/profile/page.tsx +++ b/ui/app/(prowler)/profile/page.tsx @@ -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; +}) { + const resolvedSearchParams = await searchParams; + return ( }> - + ); } -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 () => {
-
+
+ {hasManageIntegrations && ( + + )}
-
+
+ {hasManageAccount && }
- {hasManageIntegrations && ( -
- -
- )}
); }; diff --git a/ui/components/ui/alert/Alert.tsx b/ui/components/ui/alert/Alert.tsx index ed1deaae72..d83437a926 100644 --- a/ui/components/ui/alert/Alert.tsx +++ b/ui/components/ui/alert/Alert.tsx @@ -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: { diff --git a/ui/components/users/profile/api-key-success-modal.tsx b/ui/components/users/profile/api-key-success-modal.tsx new file mode 100644 index 0000000000..fdfcec2e59 --- /dev/null +++ b/ui/components/users/profile/api-key-success-modal.tsx @@ -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 ( + !open && onClose()} + title="API Key Created Successfully" + size="2xl" + > +
+ + ⚠️ Important + + 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. + + + +
+

Your API Key

+ + {apiKey} + +
+
+ + + Acknowledged + +
+ ); +}; diff --git a/ui/components/users/profile/api-keys-card-client.tsx b/ui/components/users/profile/api-keys-card-client.tsx new file mode 100644 index 0000000000..f7d806fc69 --- /dev/null +++ b/ui/components/users/profile/api-keys-card-client.tsx @@ -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( + null, + ); + const [createdApiKey, setCreatedApiKey] = useState(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 ( + <> + + +
+

API Keys

+

Manage API keys for programmatic access

+
+ } + onPress={createModal.onOpen} + > + Create API Key + +
+ + {initialApiKeys.length === 0 ? ( +
+

No API keys created yet.

+
+ ) : ( + + )} +
+
+ + {/* Modals */} + + + {createdApiKey && ( + + )} + + + + + + ); +}; diff --git a/ui/components/users/profile/api-keys-card.tsx b/ui/components/users/profile/api-keys-card.tsx new file mode 100644 index 0000000000..ce4018b47c --- /dev/null +++ b/ui/components/users/profile/api-keys-card.tsx @@ -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 ( + + ); +}; diff --git a/ui/components/users/profile/api-keys/column-api-keys.tsx b/ui/components/users/profile/api-keys/column-api-keys.tsx new file mode 100644 index 0000000000..3c113e39ce --- /dev/null +++ b/ui/components/users/profile/api-keys/column-api-keys.tsx @@ -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[] => [ + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + accessorKey: "prefix", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + id: "email", + header: "EMAIL", + cell: ({ row }) => , + enableSorting: false, + }, + { + accessorKey: "inserted_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + accessorKey: "last_used_at", + header: "LAST USED", + cell: ({ row }) => , + enableSorting: false, + }, + { + accessorKey: "expires_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + accessorKey: "revoked", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + id: "actions", + header: "", + cell: ({ row }) => { + return ( + + ); + }, + }, +]; diff --git a/ui/components/users/profile/api-keys/constants.ts b/ui/components/users/profile/api-keys/constants.ts new file mode 100644 index 0000000000..7a54fad365 --- /dev/null +++ b/ui/components/users/profile/api-keys/constants.ts @@ -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; diff --git a/ui/components/users/profile/api-keys/data-table-row-actions.tsx b/ui/components/users/profile/api-keys/data-table-row-actions.tsx new file mode 100644 index 0000000000..667b876f4d --- /dev/null +++ b/ui/components/users/profile/api-keys/data-table-row-actions.tsx @@ -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; + 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 ( +
+ + + + + + + } + onPress={() => onEdit(apiKey)} + > + Edit name + + + + + } + onPress={() => onRevoke(apiKey)} + > + Revoke + + + + +
+ ); +} diff --git a/ui/components/users/profile/api-keys/modal-buttons.tsx b/ui/components/users/profile/api-keys/modal-buttons.tsx new file mode 100644 index 0000000000..6fa1b68892 --- /dev/null +++ b/ui/components/users/profile/api-keys/modal-buttons.tsx @@ -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 ( +
+ + Cancel + + + {submitText} + +
+ ); +}; diff --git a/ui/components/users/profile/api-keys/table-cells.tsx b/ui/components/users/profile/api-keys/table-cells.tsx new file mode 100644 index 0000000000..bcba736056 --- /dev/null +++ b/ui/components/users/profile/api-keys/table-cells.tsx @@ -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 }) => ( +

+ {apiKey.attributes.name || FALLBACK_VALUES.UNNAMED} +

+); + +export const PrefixCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => ( + + {apiKey.attributes.prefix} + +); + +export const DateCell = ({ date }: { date: string | null }) => ( +

{formatRelativeTime(date)}

+); + +export const LastUsedCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => ( + +); + +export const StatusCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => { + const status = getApiKeyStatus(apiKey); + return ( + + {getStatusLabel(status)} + + ); +}; + +export const EmailCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => ( +

{apiKey.userEmail}

+); diff --git a/ui/components/users/profile/api-keys/types.ts b/ui/components/users/profile/api-keys/types.ts new file mode 100644 index 0000000000..73ec10a68a --- /dev/null +++ b/ui/components/users/profile/api-keys/types.ts @@ -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; +}; diff --git a/ui/components/users/profile/api-keys/use-modal-form.ts b/ui/components/users/profile/api-keys/use-modal-form.ts new file mode 100644 index 0000000000..a75564433d --- /dev/null +++ b/ui/components/users/profile/api-keys/use-modal-form.ts @@ -0,0 +1,71 @@ +import { useState } from "react"; + +interface UseModalFormOptions { + initialData: TFormData; + onSubmit: (data: TFormData) => Promise; + onSuccess?: () => void; + onClose: () => void; +} + +interface UseModalFormReturn { + formData: TFormData; + setFormData: React.Dispatch>; + isLoading: boolean; + error: string | null; + setError: (error: string | null) => void; + handleSubmit: () => Promise; + handleClose: () => void; + resetForm: () => void; +} + +/** + * Custom hook to manage modal form state and submission logic + * Reduces boilerplate in modal components + */ +export const useModalForm = ({ + initialData, + onSubmit, + onSuccess, + onClose, +}: UseModalFormOptions): UseModalFormReturn => { + const [formData, setFormData] = useState(initialData); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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, + }; +}; diff --git a/ui/components/users/profile/api-keys/utils.ts b/ui/components/users/profile/api-keys/utils.ts new file mode 100644 index 0000000000..c0e59bf18c --- /dev/null +++ b/ui/components/users/profile/api-keys/utils.ts @@ -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 = { + [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 = { + [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 = ( + 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(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, + ); +}; diff --git a/ui/components/users/profile/create-api-key-modal.tsx b/ui/components/users/profile/create-api-key-modal.tsx new file mode 100644 index 0000000000..0fcfc74c37 --- /dev/null +++ b/ui/components/users/profile/create-api-key-modal.tsx @@ -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; + +export const CreateApiKeyModal = ({ + isOpen, + onClose, + onSuccess, +}: CreateApiKeyModalProps) => { + const { toast } = useToast(); + + const form = useForm({ + 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 ( + !open && handleClose()} + title="Create API Key" + size="lg" + > +
+ +
+ +
+ +
+ +
+ + + + +
+ ); +}; diff --git a/ui/components/users/profile/edit-api-key-name-modal.tsx b/ui/components/users/profile/edit-api-key-name-modal.tsx new file mode 100644 index 0000000000..3fc02a5bf9 --- /dev/null +++ b/ui/components/users/profile/edit-api-key-name-modal.tsx @@ -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; + +export const EditApiKeyNameModal = ({ + isOpen, + onClose, + apiKey, + onSuccess, + existingApiKeys, +}: EditApiKeyNameModalProps) => { + const { toast } = useToast(); + + const form = useForm({ + 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 ( + !open && handleClose()} + title="Edit API Key Name" + size="lg" + > +
+ +
+ Prefix: {apiKey?.attributes.prefix} +
+ +
+ +
+ + + + +
+ ); +}; diff --git a/ui/components/users/profile/index.ts b/ui/components/users/profile/index.ts index 02cdd56c9d..35afe2c8fa 100644 --- a/ui/components/users/profile/index.ts +++ b/ui/components/users/profile/index.ts @@ -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"; diff --git a/ui/components/users/profile/revoke-api-key-modal.tsx b/ui/components/users/profile/revoke-api-key-modal.tsx new file mode 100644 index 0000000000..acf61091d8 --- /dev/null +++ b/ui/components/users/profile/revoke-api-key-modal.tsx @@ -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 ( + !open && handleClose()} + title="Revoke API Key" + size="lg" + > +
+ + ⚠️ Warning + + This action cannot be undone. This API key will be revoked and will + no longer work. + + + +
+

Are you sure you want to revoke this API key?

+
+

+ {apiKey?.attributes.name || FALLBACK_VALUES.UNNAMED_KEY} +

+

+ Prefix: {apiKey?.attributes.prefix} +

+
+
+ + {error && ( + + {error} + + )} +
+ + +
+ ); +};