Compare commits

...

2 Commits

Author SHA1 Message Date
Alan Buscaglia
8e40c2ceb1 refactor(ui): move modal buttons to shared UI components
Move modal-buttons.tsx from api-keys feature to shared UI custom
components as custom-modal-buttons.tsx for reusability across the app.
Update import in revoke-api-key-modal.
2025-10-14 11:30:53 +02:00
Alan Buscaglia
71aec6aede refactor(api-keys): enhance UI components and validation
Improve API key management UI with better component architecture:
- Migrate to FormButtons component from form library for consistency
- Add conditional revoke button (hide for revoked/expired keys)
- Improve modal button styling with icon support
- Update column headers to sentence case for better readability
- Add AlertTitle support in Alert component
- Enhance form validation feedback across all modals
2025-10-14 11:30:53 +02:00
10 changed files with 94 additions and 62 deletions

View File

@@ -365,6 +365,7 @@ export const S3IntegrationForm = ({
<FormButtons
setIsOpen={() => {}}
onCancel={handleBack}
submitColor="danger"
submitText="Create Integration"
cancelText="Back"
loadingText="Creating..."

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:
"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",
"bg-danger-50 border-red-500/50 text-red-700 dark:border-red-500 dark:border-red-900/50 dark:text-red-700 dark:dark:border-red-900",
},
},
defaultVariants: {

View File

@@ -1,3 +1,5 @@
import { ReactNode } from "react";
import { CustomButton } from "@/components/ui/custom/custom-button";
interface ModalButtonsProps {
@@ -7,6 +9,7 @@ interface ModalButtonsProps {
isDisabled?: boolean;
submitText?: string;
submitColor?: "action" | "danger";
submitIcon?: ReactNode;
}
export const ModalButtons = ({
@@ -16,24 +19,32 @@ export const ModalButtons = ({
isDisabled = false,
submitText = "Save",
submitColor = "action",
submitIcon,
}: ModalButtonsProps) => {
return (
<div className="flex w-full justify-end gap-3 pt-4">
<div className="flex w-full justify-center gap-6">
<CustomButton
size="lg"
radius="lg"
variant="faded"
type="button"
ariaLabel="Cancel"
color="transparent"
variant="light"
className="w-full bg-transparent"
onPress={onCancel}
isDisabled={isLoading}
>
Cancel
</CustomButton>
<CustomButton
size="lg"
radius="lg"
className="w-full"
ariaLabel={submitText}
color={submitColor}
onPress={onSubmit}
isLoading={isLoading}
isDisabled={isDisabled || isLoading}
startContent={submitIcon}
>
{submitText}
</CustomButton>

View File

@@ -8,7 +8,7 @@ import { SaveIcon } from "@/components/icons";
import { CustomButton } from "../custom";
interface FormCancelButtonProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
setIsOpen?: Dispatch<SetStateAction<boolean>>;
onCancel?: () => void;
children?: React.ReactNode;
leftIcon?: React.ReactNode;
@@ -19,10 +19,11 @@ interface FormSubmitButtonProps {
loadingText?: string;
isDisabled?: boolean;
rightIcon?: React.ReactNode;
color?: SubmitColorsType;
}
interface FormButtonsProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
setIsOpen?: Dispatch<SetStateAction<boolean>>;
onCancel?: () => void;
submitText?: string;
cancelText?: string;
@@ -30,8 +31,16 @@ interface FormButtonsProps {
isDisabled?: boolean;
rightIcon?: React.ReactNode;
leftIcon?: React.ReactNode;
submitColor?: SubmitColorsType;
}
const SubmitColors = {
action: "action",
danger: "danger",
} as const;
export type SubmitColorsType = (typeof SubmitColors)[keyof typeof SubmitColors];
const FormCancelButton = ({
setIsOpen,
onCancel,
@@ -41,7 +50,7 @@ const FormCancelButton = ({
const handleCancel = () => {
if (onCancel) {
onCancel();
} else {
} else if (setIsOpen) {
setIsOpen(false);
}
};
@@ -53,6 +62,7 @@ const FormCancelButton = ({
className="w-full bg-transparent"
variant="faded"
size="lg"
radius="lg"
onPress={handleCancel}
startContent={leftIcon}
>
@@ -65,6 +75,7 @@ const FormSubmitButton = ({
children = "Save",
loadingText = "Loading",
isDisabled = false,
color = "action",
rightIcon,
}: FormSubmitButtonProps) => {
const { pending } = useFormStatus();
@@ -75,8 +86,9 @@ const FormSubmitButton = ({
ariaLabel="Save"
className="w-full"
variant="solid"
color="action"
color={color}
size="lg"
radius="lg"
isLoading={pending}
isDisabled={isDisabled}
startContent={!pending && rightIcon}
@@ -88,6 +100,7 @@ const FormSubmitButton = ({
export const FormButtons = ({
setIsOpen,
submitColor,
onCancel,
submitText = "Save",
cancelText = "Cancel",
@@ -110,6 +123,7 @@ export const FormButtons = ({
loadingText={loadingText}
isDisabled={isDisabled}
rightIcon={rightIcon}
color={submitColor}
>
{submitText}
</FormSubmitButton>

View File

@@ -30,7 +30,7 @@ export const ApiKeySuccessModal = ({
>
<div className="flex flex-col gap-4">
<Alert variant="destructive">
<AlertTitle> Important</AlertTitle>
<AlertTitle> Warning</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

View File

@@ -22,20 +22,20 @@ export const createApiKeyColumns = (
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="NAME" param="name" />
<DataTableColumnHeader column={column} title="Name" param="name" />
),
cell: ({ row }) => <NameCell apiKey={row.original} />,
},
{
accessorKey: "prefix",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="PREFIX" param="prefix" />
<DataTableColumnHeader column={column} title="Prefix" param="prefix" />
),
cell: ({ row }) => <PrefixCell apiKey={row.original} />,
},
{
id: "email",
header: "EMAIL",
header: "Email",
cell: ({ row }) => <EmailCell apiKey={row.original} />,
enableSorting: false,
},
@@ -44,7 +44,7 @@ export const createApiKeyColumns = (
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="CREATED"
title="Created"
param="inserted_at"
/>
),
@@ -52,7 +52,7 @@ export const createApiKeyColumns = (
},
{
accessorKey: "last_used_at",
header: "LAST USED",
header: "Last Used",
cell: ({ row }) => <LastUsedCell apiKey={row.original} />,
enableSorting: false,
},
@@ -61,7 +61,7 @@ export const createApiKeyColumns = (
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="EXPIRES"
title="Expires"
param="expires_at"
/>
),
@@ -70,7 +70,7 @@ export const createApiKeyColumns = (
{
accessorKey: "revoked",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="STATUS" param="revoked" />
<DataTableColumnHeader column={column} title="Status" param="revoked" />
),
cell: ({ row }) => <StatusCell apiKey={row.original} />,
},

View File

@@ -33,6 +33,9 @@ export function DataTableRowActions({
onRevoke,
}: DataTableRowActionsProps) {
const apiKey = row.original;
const isRevoked = apiKey.attributes.revoked;
const isExpired = new Date(apiKey.attributes.expires_at) < new Date();
const canRevoke = !isRevoked && !isExpired;
return (
<div className="relative flex items-center justify-end gap-2">
@@ -62,23 +65,25 @@ export function DataTableRowActions({
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>
{canRevoke ? (
<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>
) : null}
</DropdownMenu>
</Dropdown>
</div>

View File

@@ -8,10 +8,9 @@ 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 { Form, FormButtons } 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 {
@@ -45,8 +44,6 @@ export const CreateApiKeyModal = ({
},
});
const isLoading = form.formState.isSubmitting;
const onSubmitClient = async (values: FormValues) => {
try {
const result = await createApiKey({
@@ -99,7 +96,7 @@ export const CreateApiKeyModal = ({
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-2">
<div className="flex w-full justify-center gap-6">
<CustomInput
control={form.control}
name="name"
@@ -127,12 +124,12 @@ export const CreateApiKeyModal = ({
/>
</div>
<ModalButtons
<FormButtons
onCancel={handleClose}
onSubmit={form.handleSubmit(onSubmitClient)}
isLoading={isLoading}
isDisabled={!form.formState.isValid}
submitText="Create API Key"
cancelText="Cancel"
loadingText="Processing..."
isDisabled={!form.formState.isValid}
/>
</form>
</Form>

View File

@@ -9,9 +9,8 @@ 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 { Form, FormButtons } from "@/components/ui/form";
import { ModalButtons } from "./api-keys/modal-buttons";
import { EnrichedApiKey } from "./api-keys/types";
import { isApiKeyNameDuplicate } from "./api-keys/utils";
@@ -45,8 +44,6 @@ export const EditApiKeyNameModal = ({
},
});
const isLoading = form.formState.isSubmitting;
// Sync form data when apiKey changes or modal opens
useEffect(() => {
if (isOpen && apiKey) {
@@ -124,12 +121,12 @@ export const EditApiKeyNameModal = ({
/>
</div>
<ModalButtons
<FormButtons
onCancel={handleClose}
onSubmit={form.handleSubmit(onSubmitClient)}
isLoading={isLoading}
isDisabled={!form.formState.isValid}
submitText="Save Changes"
cancelText="Cancel"
loadingText="Processing..."
isDisabled={!form.formState.isValid}
/>
</form>
</Form>

View File

@@ -1,5 +1,8 @@
"use client";
import { Snippet } from "@heroui/snippet";
import { Trash2Icon } from "lucide-react";
import { revokeApiKey } from "@/actions/api-keys/api-keys";
import {
Alert,
@@ -7,9 +10,9 @@ import {
AlertTitle,
} from "@/components/ui/alert/Alert";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { ModalButtons } from "@/components/ui/custom/custom-modal-buttons";
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";
@@ -54,23 +57,26 @@ export const RevokeApiKeyModal = ({
>
<div className="flex flex-col gap-4">
<Alert variant="destructive">
<AlertTitle className="text-danger-700"> Warning</AlertTitle>
<AlertDescription className="text-danger-600">
<AlertTitle> Warning</AlertTitle>
<AlertDescription>
This action cannot be undone. This API key will be revoked and will
no longer work.
</AlertDescription>
</Alert>
<div className="text-sm">
<div className="flex flex-col gap-2">
<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>
<Snippet
hideSymbol
hideCopyButton={true}
classNames={{
pre: "font-mono text-sm break-all whitespace-pre-wrap",
}}
>
<p>{apiKey?.attributes.name || FALLBACK_VALUES.UNNAMED_KEY}</p>
<p className="mt-1 text-xs">Prefix: {apiKey?.attributes.prefix}</p>
</Snippet>
</div>
{error && (
@@ -87,6 +93,7 @@ export const RevokeApiKeyModal = ({
isDisabled={!apiKey}
submitText="Revoke API Key"
submitColor="danger"
submitIcon={<Trash2Icon size={24} />}
/>
</CustomAlertModal>
);