253 lines
7.6 KiB
TypeScript
253 lines
7.6 KiB
TypeScript
/**
|
|
* JailFilesTab — manage jail.d config files with a structured form editor.
|
|
*
|
|
* Displays all jail.d config files in a collapsible accordion. Each file
|
|
* panel renders a ``JailFileForm`` with per-section auto-save editing. The
|
|
* file-level enable/disable toggle remains in the accordion header.
|
|
*
|
|
* A "Create new jail file" section at the bottom allows adding new files.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import {
|
|
Accordion,
|
|
AccordionHeader,
|
|
AccordionItem,
|
|
AccordionPanel,
|
|
Button,
|
|
Field,
|
|
Input,
|
|
MessageBar,
|
|
MessageBarBody,
|
|
Skeleton,
|
|
SkeletonItem,
|
|
Switch,
|
|
Text,
|
|
tokens,
|
|
} from "@fluentui/react-components";
|
|
import { Add24Regular, ArrowClockwise24Regular, DocumentAdd24Regular } from "@fluentui/react-icons";
|
|
import { ApiError } from "../../api/client";
|
|
import {
|
|
createJailConfigFile,
|
|
fetchJailConfigFiles,
|
|
setJailConfigFileEnabled,
|
|
} from "../../api/config";
|
|
import type { JailConfigFile } from "../../types/config";
|
|
import { JailFileForm } from "./JailFileForm";
|
|
import { useConfigStyles } from "./configStyles";
|
|
|
|
/**
|
|
* Tab component for managing jail.d configuration files with structured forms.
|
|
*
|
|
* @returns JSX element.
|
|
*/
|
|
export function JailFilesTab(): React.JSX.Element {
|
|
const styles = useConfigStyles();
|
|
const [files, setFiles] = useState<JailConfigFile[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [toggling, setToggling] = useState<string | null>(null);
|
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
|
|
|
// Create new file form state
|
|
const [newFilename, setNewFilename] = useState("");
|
|
const [creating, setCreating] = useState(false);
|
|
const [createError, setCreateError] = useState<string | null>(null);
|
|
|
|
const loadFiles = useCallback(async (): Promise<void> => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const resp = await fetchJailConfigFiles();
|
|
setFiles(resp.files);
|
|
} catch (err: unknown) {
|
|
setError(
|
|
err instanceof ApiError ? err.message : "Failed to load jail config files.",
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadFiles();
|
|
}, [loadFiles]);
|
|
|
|
const handleToggleEnabled = useCallback(
|
|
async (filename: string, enabled: boolean): Promise<void> => {
|
|
setToggling(filename);
|
|
setMsg(null);
|
|
try {
|
|
await setJailConfigFileEnabled(filename, { enabled });
|
|
setFiles((prev) =>
|
|
prev.map((f) => (f.filename === filename ? { ...f, enabled } : f)),
|
|
);
|
|
setMsg({ text: `${filename} ${enabled ? "enabled" : "disabled"}.`, ok: true });
|
|
} catch (err: unknown) {
|
|
setMsg({
|
|
text: err instanceof ApiError ? err.message : "Toggle failed.",
|
|
ok: false,
|
|
});
|
|
} finally {
|
|
setToggling(null);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleCreate = useCallback(async (): Promise<void> => {
|
|
const name = newFilename.trim();
|
|
if (!name) return;
|
|
setCreating(true);
|
|
setCreateError(null);
|
|
try {
|
|
await createJailConfigFile({ name, content: `# ${name}\n` });
|
|
setNewFilename("");
|
|
await loadFiles();
|
|
} catch (err: unknown) {
|
|
setCreateError(err instanceof ApiError ? err.message : "Failed to create file.");
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
}, [newFilename, loadFiles]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<Skeleton aria-label="Loading jail config files…">
|
|
{[0, 1, 2].map((i) => (
|
|
<SkeletonItem key={i} size={40} style={{ marginBottom: 4 }} />
|
|
))}
|
|
</Skeleton>
|
|
);
|
|
}
|
|
if (error) {
|
|
return (
|
|
<MessageBar intent="error">
|
|
<MessageBarBody>{error}</MessageBarBody>
|
|
</MessageBar>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<Text
|
|
as="p"
|
|
size={300}
|
|
className={styles.infoText}
|
|
block
|
|
style={{ marginBottom: tokens.spacingVerticalM }}
|
|
>
|
|
Files in <code>jail.d/</code>. Toggle the switch to enable or disable a
|
|
jail config file. Changes take effect on the next fail2ban reload.
|
|
</Text>
|
|
|
|
{msg !== null && (
|
|
<MessageBar
|
|
intent={msg.ok ? "success" : "error"}
|
|
style={{ marginBottom: tokens.spacingVerticalS }}
|
|
>
|
|
<MessageBarBody>{msg.text}</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
|
|
<div className={styles.buttonRow}>
|
|
<Button
|
|
appearance="secondary"
|
|
icon={<ArrowClockwise24Regular />}
|
|
onClick={() => { void loadFiles(); }}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{files.length === 0 && (
|
|
<div className={styles.emptyState}>
|
|
<DocumentAdd24Regular
|
|
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
|
|
aria-hidden
|
|
/>
|
|
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
|
|
No files found in jail.d/.
|
|
</Text>
|
|
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
|
Use the form below to create your first jail config file.
|
|
</Text>
|
|
</div>
|
|
)}
|
|
|
|
<Accordion
|
|
multiple
|
|
collapsible
|
|
style={{ marginTop: tokens.spacingVerticalM }}
|
|
>
|
|
{files.map((file) => (
|
|
<AccordionItem key={file.filename} value={file.filename} className={styles.accordionItem}>
|
|
<AccordionHeader>
|
|
<span
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: tokens.spacingHorizontalM,
|
|
}}
|
|
>
|
|
<span className={styles.codeFont}>{file.filename}</span>
|
|
<Switch
|
|
checked={file.enabled}
|
|
disabled={toggling === file.filename}
|
|
label={file.enabled ? "Enabled" : "Disabled"}
|
|
onChange={(_e, d) => {
|
|
void handleToggleEnabled(file.filename, d.checked);
|
|
}}
|
|
onClick={(e) => { e.stopPropagation(); }}
|
|
/>
|
|
</span>
|
|
</AccordionHeader>
|
|
<AccordionPanel>
|
|
<JailFileForm filename={file.filename} />
|
|
</AccordionPanel>
|
|
</AccordionItem>
|
|
))}
|
|
</Accordion>
|
|
|
|
{/* Create new jail file */}
|
|
<div
|
|
style={{
|
|
marginTop: tokens.spacingVerticalXL,
|
|
padding: tokens.spacingVerticalM,
|
|
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
|
}}
|
|
>
|
|
<Text size={400} weight="semibold" block style={{ marginBottom: tokens.spacingVerticalS }}>
|
|
Create New Jail File
|
|
</Text>
|
|
{createError !== null && (
|
|
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
|
|
<MessageBarBody>{createError}</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
<div style={{ display: "flex", gap: tokens.spacingHorizontalM, alignItems: "flex-end" }}>
|
|
<Field label="Filename" style={{ flex: 1 }}>
|
|
<Input
|
|
value={newFilename}
|
|
placeholder="e.g. myapp.conf"
|
|
onChange={(_e, d) => { setNewFilename(d.value); }}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") { void handleCreate(); }
|
|
}}
|
|
/>
|
|
</Field>
|
|
<Button
|
|
appearance="primary"
|
|
icon={<Add24Regular />}
|
|
disabled={creating || !newFilename.trim()}
|
|
onClick={() => { void handleCreate(); }}
|
|
>
|
|
{creating ? "Creating…" : "Create"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|