Files
BanGUI/frontend/src/components/config/CreateActionDialog.tsx

210 lines
6.0 KiB
TypeScript

/**
* CreateActionDialog — dialog for creating a new user-defined fail2ban action.
*
* Asks for an action name and optional lifecycle commands, then calls
* ``POST /api/config/actions`` on confirmation.
*/
import { useCallback, useEffect, useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
MessageBar,
MessageBarBody,
Spinner,
Text,
Textarea,
tokens,
} from "@fluentui/react-components";
import type { ActionConfig, ActionCreateRequest } from "../../types/config";
import { ApiError } from "../../api/client";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CreateActionDialogProps {
/** Whether the dialog is currently open. */
open: boolean;
/** Called when the dialog should close without taking action. */
onClose: () => void;
/**
* Called when the form is submitted with valid dialog data.
*
* @param payload - Create request payload.
*/
onCreateAction: (payload: ActionCreateRequest) => Promise<ActionConfig>;
/**
* Called after the action has been successfully created.
*
* @param action - The newly created ActionConfig returned by the API.
*/
onCreate: (action: ActionConfig) => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Dialog for creating a new action in ``action.d/{name}.local``.
*
* The name field accepts a plain base name (e.g. ``my-action``); the ``.local``
* extension is added by the backend.
*
* @param props - Component props.
* @returns JSX element.
*/
export function CreateActionDialog({
open,
onClose,
onCreateAction,
onCreate,
}: CreateActionDialogProps): React.JSX.Element {
const [name, setName] = useState("");
const [actionban, setActionban] = useState("");
const [actionunban, setActionunban] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset form when the dialog opens.
useEffect(() => {
if (open) {
setName("");
setActionban("");
setActionunban("");
setError(null);
}
}, [open]);
const handleClose = useCallback((): void => {
if (submitting) return;
onClose();
}, [submitting, onClose]);
const handleConfirm = useCallback((): void => {
const trimmedName = name.trim();
if (!trimmedName || submitting) return;
const req: ActionCreateRequest = {
name: trimmedName,
actionban: actionban.trim() || null,
actionunban: actionunban.trim() || null,
};
setSubmitting(true);
setError(null);
onCreateAction(req)
.then((action) => {
onCreate(action);
})
.catch((err: unknown) => {
setError(
err instanceof ApiError ? err.message : "Failed to create action.",
);
})
.finally(() => {
setSubmitting(false);
});
}, [name, actionban, actionunban, submitting, onCreate]);
const canConfirm = name.trim() !== "" && !submitting;
return (
<Dialog open={open} onOpenChange={(_e, data) => { if (!data.open) handleClose(); }}>
<DialogSurface>
<DialogBody>
<DialogTitle>Create Action</DialogTitle>
<DialogContent>
<Text
as="p"
size={300}
style={{ marginBottom: tokens.spacingVerticalM }}
>
Creates a new action definition at{" "}
<code>action.d/&lt;name&gt;.local</code>.
</Text>
{error !== null && (
<MessageBar
intent="error"
style={{ marginBottom: tokens.spacingVerticalS }}
>
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
<Field
label="Action name"
required
hint='Base name without extension, e.g. "my-action". Must not already exist.'
>
<Input
value={name}
onChange={(_e, d) => { setName(d.value); }}
placeholder="my-action"
disabled={submitting}
/>
</Field>
<Field
label="actionban command"
hint="Command executed when an IP is banned. Leave blank to add later."
style={{ marginTop: tokens.spacingVerticalS }}
>
<Textarea
value={actionban}
onChange={(_e, d) => { setActionban(d.value); }}
placeholder={"echo ban <ip>"}
rows={3}
style={{ fontFamily: "monospace", fontSize: 12 }}
disabled={submitting}
/>
</Field>
<Field
label="actionunban command"
hint="Command executed when a ban is removed."
style={{ marginTop: tokens.spacingVerticalS }}
>
<Textarea
value={actionunban}
onChange={(_e, d) => { setActionunban(d.value); }}
placeholder={"echo unban <ip>"}
rows={3}
style={{ fontFamily: "monospace", fontSize: 12 }}
disabled={submitting}
/>
</Field>
</DialogContent>
<DialogActions>
<Button
appearance="secondary"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
appearance="primary"
onClick={handleConfirm}
disabled={!canConfirm}
icon={submitting ? <Spinner size="extra-small" /> : undefined}
>
{submitting ? "Creating…" : "Create"}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}