Files
manage/frontend/src/pages/Settings.tsx
T
Developer e805c624b2 Mobile Settings: machine editor SheetForm (Slice 7)
Below md, the machine editor Dialog renders as a SheetForm (triggered by
the same Edit/Add buttons via machineDialogOpen state). The shared
MachineEditor body (fields + SSH validate button) renders inside the
sheet; the ConfirmDialog is a sibling outside. Desktop Dialog is
byte-for-byte identical.

No navigation needed on close -- the Settings page content (tabbed cards,
machine list) is always visible behind the sheet, so there is no stranding
risk (unlike ServicePage where the sheet was the whole page).

Added saveDisabled prop to SheetForm (additive, default false) so the
machine editor can gate Save on required fields (name + host for SSH
mode), matching the desktop DialogFooter confirmDisabled semantics.

Scope note: SSHKeyManager is an inline two-panel layout (SelectionRailCard
+ SectionCard), not a dialog, and already stacks responsively via
grid-cols-1 md:grid-cols-[...]. Wrapping it in SheetForm would break its
always-visible selection rail. Left as-is.

Tests: 3 new mobile cases (SheetForm render, save payload, cancel closes)
+ desktop unchanged. 113 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 7).
2026-06-26 13:56:27 +00:00

1269 lines
36 KiB
TypeScript

import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import type {
MonitoringMachine,
MonitoringMachineInput,
SSHKey,
SSHKeyInput,
} from "../types";
import {
useDeleteMonitoringMachine,
useDeleteSSHKey,
useGenerateSSHKey,
useMonitoringSettings,
useResetLocalDatabase,
useSSHKeys,
useSaveMonitoringMachine,
useSaveSSHKey,
useTestMonitoringMachineSSH,
} from "../hooks/useSettings";
import { useIsMobile } from "../hooks/useIsMobile";
import { SheetForm } from "@/components/ui/sheet-form";
import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton";
import { SectionCard } from "../components/SectionCard";
import { SelectionRailCard } from "../components/SelectionRailCard";
import { TabbedCard } from "../components/TabbedCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { cn } from "@/lib/utils";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
const SERVICE_OPTIONS = [
{ value: "monitoring", label: "Monitoring" },
{ value: "files", label: "Files" },
{ value: "nextcloud", label: "Nextcloud" },
];
// Radix Select disallows empty-string item values, so the "no selection" option
// maps to this sentinel and converts back to "" at the draft boundary.
const NONE = "__none__";
type SettingsTab = "machines" | "ssh-keys" | "danger";
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
function FormField({
label,
htmlFor,
helperText,
children,
}: {
label: string;
htmlFor?: string;
helperText?: string;
children: ReactNode;
}) {
return (
<div className="flex flex-col">
<Label htmlFor={htmlFor} className="mb-1">
{label}
</Label>
{children}
{helperText ? (
<p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
) : null}
</div>
);
}
/** Overline section label (replaces MUI `Typography variant="overline"`). */
function SectionLabel({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="pt-1">
<p className="text-[0.7rem] leading-tight font-medium tracking-wide text-muted-foreground uppercase">
{title}
</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
);
}
function emptyMachine(
mode: MonitoringMachineInput["mode"] = "local",
): MonitoringMachineInput {
return {
id: null,
name: mode === "local" ? "This machine" : "",
mode,
enabled: true,
services: mode === "local" ? ["monitoring", "files"] : [],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key: "",
ssh_private_key_passphrase: "",
password: "",
notes: "",
};
}
function MachineEditor({
title,
hint,
machine,
sshKeys,
editingMachine,
onChange,
onValidateSSH,
isValidatingSSH,
sshValidationMessage,
sshValidationError,
sshValidationStatus,
}: {
title: string;
hint?: string;
machine: MonitoringMachineInput;
sshKeys: SSHKey[];
editingMachine?: MonitoringMachine | null;
onChange: (
draft:
| MonitoringMachineInput
| ((current: MonitoringMachineInput) => MonitoringMachineInput),
) => void;
onValidateSSH: () => void;
isValidatingSSH: boolean;
sshValidationMessage: string;
sshValidationError: string;
sshValidationStatus: string;
}) {
const draft = machine;
const setDraft = onChange;
const isLocal = draft.mode === "local";
const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id);
const enabledServices = draft.services.length;
const placeholderIfSet = (isSet: boolean | undefined) =>
isSet ? "Set, not shown" : undefined;
return (
<Card>
<CardContent className="flex flex-col gap-4 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold">{title}</p>
{hint ? (
<p className="text-xs text-muted-foreground">{hint}</p>
) : null}
</div>
<div className="flex flex-row flex-wrap items-center gap-1">
<Badge variant="outline">{draft.mode}</Badge>
<Badge variant="outline">
{draft.enabled ? "enabled" : "disabled"}
</Badge>
<Badge variant="outline">{`${enabledServices} services`}</Badge>
</div>
</div>
<div className="grid grid-cols-12 gap-2">
<div className="col-span-12">
<SectionLabel
title="General"
description="Name, mode, enabled state, and service roles."
/>
</div>
<div className="col-span-12 md:col-span-6">
<FormField label="Name" htmlFor="machine-name">
<Input
id="machine-name"
value={draft.name}
onChange={(e) =>
setDraft((current) => ({ ...current, name: e.target.value }))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-3">
{editingMachine ? (
<FormField label="Mode">
<Input value={draft.mode} disabled />
</FormField>
) : (
<FormField label="Mode">
<Select
value={draft.mode}
onValueChange={(value) => {
const newMode = value as MonitoringMachineInput["mode"];
setDraft((current) => ({
...emptyMachine(newMode),
id: current.id,
}));
}}
>
<SelectTrigger className="w-full" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="ssh">SSH</SelectItem>
</SelectContent>
</Select>
</FormField>
)}
</div>
<div className="col-span-12 md:col-span-3">
<div className="flex items-center gap-2">
<Switch
id="machine-enabled"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft((current) => ({ ...current, enabled: checked }))
}
/>
<Label htmlFor="machine-enabled">
{draft.enabled ? "Enabled" : "Disabled"}
</Label>
</div>
</div>
<div className="col-span-12">
<div className="flex flex-row flex-wrap items-center gap-1">
{SERVICE_OPTIONS.map((option) => {
const checked = draft.services.includes(option.value);
return (
<button
key={option.value}
type="button"
onClick={() =>
setDraft((current) => ({
...current,
services: checked
? current.services.filter(
(service) => service !== option.value,
)
: [...current.services, option.value],
}))
}
className={cn(
"inline-flex h-5 cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-colors",
checked
? "bg-primary text-primary-foreground"
: "border border-border text-foreground hover:bg-muted",
)}
>
{option.label}
</button>
);
})}
</div>
</div>
{!isLocal && (
<>
<div className="col-span-12">
<SectionLabel
title="Connection"
description="SSH host, port, username, and credentials."
/>
</div>
<div className="col-span-12 md:col-span-4">
<FormField label="Host">
<Input
value={draft.host}
onChange={(e) =>
setDraft((current) => ({
...current,
host: e.target.value,
}))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-2">
<FormField label="Port">
<Input
type="number"
value={draft.port}
onChange={(e) =>
setDraft((current) => ({
...current,
port: Number(e.target.value || 22),
}))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-3">
<FormField label="Username">
<Input
value={draft.username}
onChange={(e) =>
setDraft((current) => ({
...current,
username: e.target.value,
}))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-4">
<FormField
label="SSH key"
helperText={
sshKeys.length > 0
? "Select a saved SSH key."
: "No SSH keys are saved yet. Add one in the SSH Keys tab."
}
>
<Select
value={draft.ssh_key_id || NONE}
onValueChange={(value) =>
setDraft((current) => ({
...current,
ssh_key_id: value === NONE ? "" : value,
}))
}
>
<SelectTrigger className="w-full" size="sm">
<SelectValue placeholder="No key selected" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>No key selected</SelectItem>
{sshKeys.map((key) => (
<SelectItem key={key.id} value={key.id}>
{key.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
<div className="col-span-12 md:col-span-6">
<FormField label="Password">
<Input
type="password"
placeholder={placeholderIfSet(editingMachine?.password_set)}
value={draft.password}
onChange={(e) =>
setDraft((current) => ({
...current,
password: e.target.value,
}))
}
/>
</FormField>
</div>
</>
)}
<div className="col-span-12">
<SectionLabel
title="Monitoring / Files"
description="Enable monitoring and file browsing for this machine."
/>
</div>
{isLocal && (
<div className="col-span-12 md:col-span-6">
<FormField label="Local hint">
<Input value="Uses the API host directly" disabled />
</FormField>
</div>
)}
<div className="col-span-12">
<SectionLabel
title="Notes"
description="Free-form administrator notes for this machine."
/>
</div>
<div className="col-span-12">
<FormField label="Notes">
<Input
value={draft.notes}
onChange={(e) =>
setDraft((current) => ({ ...current, notes: e.target.value }))
}
/>
</FormField>
</div>
</div>
{selectedSSHKey ? (
<Alert>
<AlertDescription>
Selected key: {selectedSSHKey.name}
{selectedSSHKey.fingerprint
? ` · ${selectedSSHKey.fingerprint}`
: ""}
</AlertDescription>
</Alert>
) : !isLocal && sshKeys.length === 0 ? (
<Alert>
<AlertDescription>
No SSH keys have been saved yet. Add one before configuring SSH
machines.
</AlertDescription>
</Alert>
) : draft.ssh_key_id ? (
<Alert variant="destructive">
<AlertDescription>
The selected SSH key was not found.
</AlertDescription>
</Alert>
) : null}
{!isLocal && enabledServices === 0 && (
<Alert>
<AlertDescription>
SSH machines usually need monitoring or files enabled.
</AlertDescription>
</Alert>
)}
{!isLocal && (
<div className="flex flex-col gap-2">
<Alert>
<AlertDescription>
Validate SSH before saving: this records the first trusted host
key in the backend-managed known_hosts file, then checks SSH
auth.
</AlertDescription>
</Alert>
<div className="flex flex-row flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={onValidateSSH}
disabled={
isValidatingSSH ||
!draft.host.trim() ||
!draft.username.trim()
}
>
{isValidatingSSH
? "Validating SSH..."
: "Validate SSH + trust host"}
</Button>
<p className="text-xs text-muted-foreground">
SSH status: {sshValidationStatus || "Not tested yet"}
</p>
</div>
{sshValidationMessage && (
<Alert>
<AlertDescription>{sshValidationMessage}</AlertDescription>
</Alert>
)}
{sshValidationError && (
<Alert variant="destructive">
<AlertDescription>{sshValidationError}</AlertDescription>
</Alert>
)}
</div>
)}
</CardContent>
</Card>
);
}
function SSHKeyManager({
sshKeys,
selectedKeyId,
onSelectKeyId,
}: {
sshKeys: SSHKey[];
selectedKeyId: string;
onSelectKeyId: (id: string) => void;
}) {
const saveKey = useSaveSSHKey();
const generateKey = useGenerateSSHKey();
const deleteKey = useDeleteSSHKey();
const [draft, setDraft] = useState<SSHKeyInput>({
id: null,
name: "",
private_key: "",
public_key: "",
fingerprint: "",
passphrase: "",
notes: "",
});
const selectedKey = useMemo(
() => sshKeys.find((key) => key.id === selectedKeyId) ?? null,
[sshKeys, selectedKeyId],
);
const editing = Boolean(draft.id);
const clear = () => {
setDraft({
id: null,
name: "",
private_key: "",
passphrase: "",
public_key: "",
fingerprint: "",
notes: "",
});
onSelectKeyId("");
};
const loadKey = (key: SSHKey) => {
onSelectKeyId(key.id);
setDraft({
id: key.id,
name: key.name,
private_key: "",
passphrase: "",
public_key: key.public_key,
fingerprint: key.fingerprint,
notes: key.notes,
});
};
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
<SelectionRailCard
title="SSH keys"
description="Select a key to see its details."
minHeight={420}
footer={
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
clear();
}}
>
New key
</Button>
}
>
{sshKeys.length === 0 && (
<div className="p-4 text-center">
<p className="text-xs text-muted-foreground">
No SSH keys saved yet.
</p>
</div>
)}
{sshKeys.map((key) => {
const active = key.id === selectedKeyId;
return (
<div
key={key.id}
onClick={() => loadKey(key)}
className={cn(
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5 group-hover:[&_.rail-edit]:opacity-100",
active ? "bg-muted" : "bg-card hover:bg-muted/50",
)}
>
<div className="min-w-0">
<p className="truncate font-semibold">{key.name}</p>
<p className="text-xs text-muted-foreground">
{key.private_key_set ? "key saved" : "no key"} ·{" "}
{key.usage_count} machine
{key.usage_count === 1 ? "" : "s"}
</p>
</div>
<HoverEditButton onClick={() => loadKey(key)} />
</div>
);
})}
</SelectionRailCard>
<SectionCard
title={selectedKey?.name || "No key selected"}
description={
selectedKey
? `${selectedKey.fingerprint || "No fingerprint"}`
: "Select a key on the left or create a new one."
}
action={
selectedKey ? (
<Badge variant="outline">{`${selectedKey.usage_count} machine${selectedKey.usage_count === 1 ? "" : "s"}`}</Badge>
) : undefined
}
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-12 gap-2">
<div className="col-span-12 md:col-span-4">
<FormField label="Key name">
<Input
value={draft.name}
onChange={(e) =>
setDraft((current) => ({
...current,
name: e.target.value,
}))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-4">
<FormField label="Passphrase" helperText="Optional">
<Input
type="password"
value={draft.passphrase}
onChange={(e) =>
setDraft((current) => ({
...current,
passphrase: e.target.value,
}))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-4">
<FormField label="Notes">
<Input
value={draft.notes}
onChange={(e) =>
setDraft((current) => ({
...current,
notes: e.target.value,
}))
}
/>
</FormField>
</div>
<div className="col-span-12">
<FormField
label="Private key"
helperText={
editing
? "Leave blank to keep the existing private key."
: "Paste the full key text here."
}
>
<Textarea
rows={6}
value={draft.private_key}
onChange={(e) =>
setDraft((current) => ({
...current,
private_key: e.target.value,
}))
}
/>
</FormField>
</div>
</div>
<div className="flex flex-row flex-wrap items-center gap-2">
<Button
disabled={saveKey.isPending}
onClick={async () => {
await saveKey.mutateAsync(draft);
clear();
}}
>
{editing ? "Update key" : "Save key"}
</Button>
<Button
variant="outline"
disabled={generateKey.isPending}
onClick={async () => {
const generated = await generateKey.mutateAsync({
name:
draft.name ||
`ssh-key-${new Date().toISOString().slice(0, 10)}`,
passphrase: draft.passphrase,
notes: draft.notes,
});
setDraft((current) => ({
...current,
name: generated.name || current.name,
private_key: generated.private_key,
passphrase: generated.passphrase,
public_key: generated.public_key,
fingerprint: generated.fingerprint,
notes: generated.notes,
}));
}}
>
{generateKey.isPending ? "Generating..." : "Generate key"}
</Button>
<Button variant="outline" onClick={clear}>
Clear
</Button>
{selectedKey && (
<Button
variant="destructive"
onClick={() => deleteKey.mutate(selectedKey.id)}
>
Delete
</Button>
)}
</div>
{saveKey.error && (
<Alert variant="destructive">
<AlertDescription>{String(saveKey.error)}</AlertDescription>
</Alert>
)}
{selectedKey && (
<div className="flex flex-col gap-2">
<div className="rounded-lg border border-border px-3 py-2">
<p className="text-xs text-muted-foreground">Fingerprint</p>
<p className="break-all font-mono text-sm">
{selectedKey.fingerprint || "Unavailable"}
</p>
</div>
<div className="rounded-lg border border-border px-3 py-2">
<p className="text-xs text-muted-foreground">Public key</p>
<p className="break-all font-mono text-sm">
{selectedKey.public_key || "Unavailable"}
</p>
</div>
</div>
)}
</div>
</SectionCard>
</div>
);
}
function ResetLocalDatabaseCard() {
const resetDatabase = useResetLocalDatabase();
const [open, setOpen] = useState(false);
const [phrase, setPhrase] = useState("");
const [ackSettings, setAckSettings] = useState(false);
const [ackIndex, setAckIndex] = useState(false);
const [ackIrreversible, setAckIrreversible] = useState(false);
const canSubmit =
phrase.trim().toLowerCase() === "reset local database" &&
ackSettings &&
ackIndex &&
ackIrreversible;
const close = () => {
setOpen(false);
setPhrase("");
setAckSettings(false);
setAckIndex(false);
setAckIrreversible(false);
};
return (
<>
<Card>
<CardContent className="flex flex-col gap-4 p-3">
<div className="flex flex-row flex-wrap items-center gap-2">
<p className="text-sm font-semibold">Danger zone</p>
<Badge variant="destructive">Destructive</Badge>
</div>
<p className="text-xs text-muted-foreground">
Reset the local SQLite settings/media index databases after
acknowledging the data loss.
</p>
<Button variant="destructive" onClick={() => setOpen(true)}>
Reset local database
</Button>
{resetDatabase.error && (
<Alert variant="destructive">
<AlertDescription>{String(resetDatabase.error)}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) close();
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reset local database</DialogTitle>
<DialogDescription>
This deletes the local settings and media index SQLite files. It
does not affect remote servers.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={ackSettings}
onCheckedChange={(checked) => setAckSettings(Boolean(checked))}
/>
I understand settings will be lost.
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={ackIndex}
onCheckedChange={(checked) => setAckIndex(Boolean(checked))}
/>
I understand the media index will be rebuilt.
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={ackIrreversible}
onCheckedChange={(checked) =>
setAckIrreversible(Boolean(checked))
}
/>
I understand this cannot be undone.
</label>
<FormField label='Type "reset local database" to confirm'>
<Input
value={phrase}
onChange={(e) => setPhrase(e.target.value)}
/>
</FormField>
</div>
<DialogFooter
onCancel={close}
cancelLabel="Cancel"
onConfirm={async () => {
await resetDatabase.mutateAsync({
confirm_phrase: phrase,
acknowledge_settings_loss: ackSettings,
acknowledge_media_index_loss: ackIndex,
acknowledge_irreversible: ackIrreversible,
});
close();
}}
confirmLabel="Reset database"
confirmBusyLabel="Resetting..."
confirmColor="error"
confirmDisabled={!canSubmit || resetDatabase.isPending}
/>
</DialogContent>
</Dialog>
</>
);
}
export function Settings() {
const { data: machines, error } = useMonitoringSettings();
const { data: sshKeys = [] } = useSSHKeys();
const saveMachine = useSaveMonitoringMachine();
const deleteMachine = useDeleteMonitoringMachine();
const testMachineSSH = useTestMonitoringMachineSSH();
const [tab, setTab] = useState<SettingsTab>("machines");
const [deleteMachineId, setDeleteMachineId] = useState<string | null>(null);
const [selectedSSHKeyId, setSelectedSSHKeyId] = useState("");
const [sshValidationMessage, setSSHValidationMessage] = useState("");
const [sshValidationError, setSSHValidationError] = useState("");
const [sshValidationStatus, setSSHValidationStatus] = useState("");
const [machineDialogOpen, setMachineDialogOpen] = useState(false);
const [machineDraft, setMachineDraft] = useState<MonitoringMachineInput>(
emptyMachine(),
);
const [editingMachine, setEditingMachine] =
useState<MonitoringMachine | null>(null);
const [selectedMachineId, setSelectedMachineId] = useState("");
const isMobile = useIsMobile();
const orderedMachines = useMemo(() => machines ?? [], [machines]);
const selectedMachine = useMemo(
() =>
orderedMachines.find((machine) => machine.id === selectedMachineId) ??
orderedMachines[0] ??
null,
[orderedMachines, selectedMachineId],
);
const clearSSHValidation = () => {
setSSHValidationMessage("");
setSSHValidationError("");
setSSHValidationStatus("");
};
const openEditMachine = (
input: MonitoringMachineInput,
original?: MonitoringMachine | null,
) => {
clearSSHValidation();
setMachineDraft(input);
setEditingMachine(original ?? null);
setMachineDialogOpen(true);
};
const closeMachineDialog = () => {
clearSSHValidation();
setMachineDialogOpen(false);
setEditingMachine(null);
};
const updateMachineDraft = (
draft:
| MonitoringMachineInput
| ((current: MonitoringMachineInput) => MonitoringMachineInput),
) => {
clearSSHValidation();
setMachineDraft(draft);
};
const saveMachineDraft = async (draft: MonitoringMachineInput) => {
clearSSHValidation();
await saveMachine.mutateAsync(draft);
setMachineDialogOpen(false);
setEditingMachine(null);
setMachineDraft(emptyMachine(draft.mode));
};
const validateMachineSSH = async () => {
clearSSHValidation();
try {
const result = await testMachineSSH.mutateAsync(machineDraft);
setSSHValidationMessage(result.message);
setSSHValidationStatus(
result.known_hosts_updated
? "Host key trusted and SSH auth succeeded."
: "Host key already trusted and SSH auth succeeded.",
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setSSHValidationError(message);
const lowered = message.toLowerCase();
if (lowered.includes("protocol banner")) {
setSSHValidationStatus("SSH banner not received.");
} else if (
lowered.includes("no authentication methods available") ||
lowered.includes("authentication failed")
) {
setSSHValidationStatus("SSH authentication failed.");
} else {
setSSHValidationStatus("SSH validation failed.");
}
}
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold">Settings</h1>
<p className="text-xs text-muted-foreground">
Structure machines, reusable SSH keys, and safety controls from a
tabbed admin workspace.
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{String(error)}</AlertDescription>
</Alert>
)}
{saveMachine.error && (
<Alert variant="destructive">
<AlertDescription>{String(saveMachine.error)}</AlertDescription>
</Alert>
)}
{deleteMachine.error && (
<Alert variant="destructive">
<AlertDescription>{String(deleteMachine.error)}</AlertDescription>
</Alert>
)}
<TabbedCard
value={tab}
onChange={(value) => setTab(value as SettingsTab)}
tabs={[
<TabsTrigger key="machines" value="machines">
Machines
</TabsTrigger>,
<TabsTrigger key="ssh-keys" value="ssh-keys">
SSH Keys
</TabsTrigger>,
<TabsTrigger key="danger" value="danger">
Danger Zone
</TabsTrigger>,
]}
contentSx={{}}
>
{tab === "machines" && (
<div className="flex flex-col gap-4">
{orderedMachines.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
<SelectionRailCard
title="Machines"
description="Select a machine to see its settings."
minHeight={420}
footer={
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
clearSSHValidation();
setMachineDraft(emptyMachine("local"));
setEditingMachine(null);
setMachineDialogOpen(true);
}}
>
Add machine
</Button>
}
>
{orderedMachines.map((machine) => {
const active = machine.id === selectedMachine?.id;
return (
<div
key={machine.id}
onClick={() => setSelectedMachineId(machine.id)}
className={cn(
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5 group-hover:[&_.rail-edit]:opacity-100",
active ? "bg-muted" : "bg-card hover:bg-muted/50",
)}
>
<div className="min-w-0">
<p className="truncate font-semibold">
{machine.name}
</p>
<p className="text-xs text-muted-foreground">
{machine.mode} ·{" "}
{machine.enabled ? "Enabled" : "Disabled"}
</p>
</div>
<HoverEditButton
onClick={() => {
openEditMachine(
{
id: machine.id,
name: machine.name,
mode: machine.mode,
enabled: machine.enabled,
services: machine.services,
host: machine.host,
port: machine.port,
username: machine.username,
key_directory: "",
key_name: "",
ssh_key_id: machine.ssh_key_id,
ssh_private_key: "",
ssh_private_key_passphrase: "",
password: "",
notes: machine.notes,
},
machine,
);
}}
/>
</div>
);
})}
</SelectionRailCard>
<SectionCard
title={selectedMachine?.name || "No machine selected"}
description={
selectedMachine
? selectedMachine.mode === "local"
? "Local API host"
: `${selectedMachine.username || "user"}@${selectedMachine.host || "host"}:${selectedMachine.port}`
: "Select a machine on the left to view its settings."
}
action={
selectedMachine ? (
<Badge variant="outline">{selectedMachine.mode}</Badge>
) : undefined
}
>
{selectedMachine ? (
<div className="flex flex-col gap-4">
<div className="flex flex-row flex-wrap items-center gap-1">
<Badge variant="outline">
{selectedMachine.enabled ? "enabled" : "disabled"}
</Badge>
<Badge variant="outline">{`${selectedMachine.services.length} services`}</Badge>
{selectedMachine.ssh_key_id && (
<Badge variant="outline">{`SSH key ${selectedMachine.ssh_key_id}`}</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{selectedMachine.notes || "No notes."}
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{selectedMachine.mode === "ssh" ? (
<>
<FormField label="Host">
<Input value={selectedMachine.host} disabled />
</FormField>
<FormField label="Port">
<Input value={selectedMachine.port} disabled />
</FormField>
<FormField label="Username">
<Input
value={selectedMachine.username}
disabled
/>
</FormField>
</>
) : (
<FormField label="Local hint">
<Input
value="Uses the API host directly"
disabled
/>
</FormField>
)}
</div>
<div className="flex flex-row flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={() =>
openEditMachine(
{
id: selectedMachine.id,
name: selectedMachine.name,
mode: selectedMachine.mode,
enabled: selectedMachine.enabled,
services: selectedMachine.services,
host: selectedMachine.host,
port: selectedMachine.port,
username: selectedMachine.username,
key_directory: "",
key_name: "",
ssh_key_id: selectedMachine.ssh_key_id,
ssh_private_key: "",
ssh_private_key_passphrase: "",
password: "",
notes: selectedMachine.notes,
},
selectedMachine,
)
}
>
Edit
</Button>
<Button
variant="destructive"
onClick={() => setDeleteMachineId(selectedMachine.id)}
>
Delete
</Button>
</div>
</div>
) : null}
</SectionCard>
</div>
) : null}
</div>
)}
{tab === "ssh-keys" && (
<SSHKeyManager
sshKeys={sshKeys}
selectedKeyId={selectedSSHKeyId}
onSelectKeyId={setSelectedSSHKeyId}
/>
)}
{tab === "danger" && <ResetLocalDatabaseCard />}
</TabbedCard>
{isMobile ? (
<SheetForm
open={machineDialogOpen}
onOpenChange={(open) => {
if (!open) closeMachineDialog();
}}
title={machineDraft.id ? "Edit machine" : "Create machine"}
onSave={() => {
void saveMachineDraft(machineDraft);
}}
onCancel={closeMachineDialog}
isPending={saveMachine.isPending}
saveDisabled={
!machineDraft.name ||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
}
saveLabel={machineDraft.id ? "Save machine" : "Create machine"}
>
<MachineEditor
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
title={
machineDraft.id
? machineDraft.name || "Edit machine"
: "New machine"
}
hint={
machineDraft.mode === "local" ? "Local API host" : "SSH target"
}
machine={machineDraft}
sshKeys={sshKeys}
editingMachine={editingMachine}
onChange={updateMachineDraft}
onValidateSSH={validateMachineSSH}
isValidatingSSH={testMachineSSH.isPending}
sshValidationMessage={sshValidationMessage}
sshValidationError={sshValidationError}
sshValidationStatus={sshValidationStatus}
/>
{machineDraft.id ? (
<Button
variant="destructive"
onClick={() => setDeleteMachineId(machineDraft.id as string)}
>
Delete machine
</Button>
) : null}
</SheetForm>
) : (
<Dialog
open={machineDialogOpen}
onOpenChange={(open) => {
if (!open) closeMachineDialog();
}}
>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>
{machineDraft.id ? "Edit machine" : "Create machine"}
</DialogTitle>
<DialogDescription>
{machineDraft.mode === "local"
? "Local API host"
: "SSH target"}
</DialogDescription>
</DialogHeader>
<MachineEditor
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
title={
machineDraft.id
? machineDraft.name || "Edit machine"
: "New machine"
}
hint={
machineDraft.mode === "local" ? "Local API host" : "SSH target"
}
machine={machineDraft}
sshKeys={sshKeys}
editingMachine={editingMachine}
onChange={updateMachineDraft}
onValidateSSH={validateMachineSSH}
isValidatingSSH={testMachineSSH.isPending}
sshValidationMessage={sshValidationMessage}
sshValidationError={sshValidationError}
sshValidationStatus={sshValidationStatus}
/>
<DialogFooter
onCancel={closeMachineDialog}
cancelLabel="Cancel"
onConfirm={() => {
void saveMachineDraft(machineDraft);
}}
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
confirmDisabled={
!machineDraft.name ||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
}
secondaryAction={
machineDraft.id ? (
<Button
variant="destructive"
onClick={() => {
setDeleteMachineId(machineDraft.id as string);
}}
>
Delete
</Button>
) : undefined
}
/>
</DialogContent>
</Dialog>
)}
<ConfirmDialog
open={Boolean(deleteMachineId)}
title="Delete machine?"
message="The machine will be removed. Action history will be deleted."
onCancel={() => setDeleteMachineId(null)}
onConfirm={() => {
if (deleteMachineId) {
deleteMachine.mutate(deleteMachineId);
closeMachineDialog();
}
setDeleteMachineId(null);
}}
/>
</div>
);
}