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 <FormButtons
setIsOpen={() => {}} setIsOpen={() => {}}
onCancel={handleBack} onCancel={handleBack}
submitColor="danger"
submitText="Create Integration" submitText="Create Integration"
cancelText="Back" cancelText="Back"
loadingText="Creating..." loadingText="Creating..."

View File

@@ -10,7 +10,7 @@ const alertVariants = cva(
variant: { variant: {
default: "bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50", default: "bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50",
destructive: 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: { defaultVariants: {

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ export const ApiKeySuccessModal = ({
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle> Important</AlertTitle> <AlertTitle> Warning</AlertTitle>
<AlertDescription> <AlertDescription>
This is the only time you will see this API key. Please copy it now 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 and store it securely. Once you close this dialog, the key cannot be

View File

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

View File

@@ -33,6 +33,9 @@ export function DataTableRowActions({
onRevoke, onRevoke,
}: DataTableRowActionsProps) { }: DataTableRowActionsProps) {
const apiKey = row.original; const apiKey = row.original;
const isRevoked = apiKey.attributes.revoked;
const isExpired = new Date(apiKey.attributes.expires_at) < new Date();
const canRevoke = !isRevoked && !isExpired;
return ( return (
<div className="relative flex items-center justify-end gap-2"> <div className="relative flex items-center justify-end gap-2">
@@ -62,23 +65,25 @@ export function DataTableRowActions({
Edit name Edit name
</DropdownItem> </DropdownItem>
</DropdownSection> </DropdownSection>
<DropdownSection title="Danger zone"> {canRevoke ? (
<DropdownItem <DropdownSection title="Danger zone">
key="revoke" <DropdownItem
className="text-danger" key="revoke"
color="danger" className="text-danger"
description="Revoke this API key permanently" color="danger"
textValue="Revoke" description="Revoke this API key permanently"
startContent={ textValue="Revoke"
<DeleteDocumentBulkIcon startContent={
className={clsx(iconClasses, "!text-danger")} <DeleteDocumentBulkIcon
/> className={clsx(iconClasses, "!text-danger")}
} />
onPress={() => onRevoke(apiKey)} }
> onPress={() => onRevoke(apiKey)}
Revoke >
</DropdownItem> Revoke
</DropdownSection> </DropdownItem>
</DropdownSection>
) : null}
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
</div> </div>

View File

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

View File

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

View File

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