feat(settings): redesign SSH keys section with two-column layout

Refactor SSHKeyManager to match app layout pattern:
- List of SSH keys on the left (SelectionRailCard)
- Key details and form on the right (SectionCard)
- Add selectedSSHKeyId state to Settings component
- Click a key to select and populate the form
- New key button to create a new key
- Shows fingerprint and public key for selected key
- Delete button moved to the right panel
This commit is contained in:
2026-05-11 18:54:00 +02:00
parent 8b26cd4fe0
commit 2a84553575
+196 -123
View File
@@ -597,7 +597,15 @@ function MachineEditor({
</Card>
);
}
function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
function SSHKeyManager({
sshKeys,
selectedKeyId,
onSelectKeyId,
}: {
sshKeys: SSHKey[];
selectedKeyId: string;
onSelectKeyId: (id: string) => void;
}) {
const saveKey = useSaveSSHKey();
const generateKey = useGenerateSSHKey();
const deleteKey = useDeleteSSHKey();
@@ -610,8 +618,12 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
passphrase: "",
notes: "",
});
const selectedKey = useMemo(
() => sshKeys.find((key) => key.id === selectedKeyId) ?? null,
[sshKeys, selectedKeyId],
);
const editing = Boolean(draft.id);
const clear = () =>
const clear = () => {
setDraft({
id: null,
name: "",
@@ -621,33 +633,124 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
fingerprint: "",
notes: "",
});
onSelectKeyId("");
};
return (
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1.5}>
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
md: "320px minmax(0, 1fr)",
},
gap: 2,
}}
>
<SelectionRailCard
title="SSH keys"
description="Select a key to see its details."
minHeight={420}
footer={
<Button
variant="outlined"
size="small"
fullWidth
onClick={() => {
clear();
}}
>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
SSH keys
</Typography>
New key
</Button>
}
>
<Box sx={{ flex: 1, overflowY: "auto" }}>
{sshKeys.length === 0 && (
<Box sx={{ p: 2, textAlign: "center" }}>
<Typography variant="body2" color="text.secondary">
Import or generate reusable keys for SSH machines.
No SSH keys saved yet.
</Typography>
</Box>
)}
{sshKeys.map((key) => {
const active = key.id === selectedKeyId;
return (
<Box
key={key.id}
onClick={() => {
onSelectKeyId(key.id);
setDraft({
id: key.id,
name: key.name,
private_key: "",
passphrase: "",
public_key: key.public_key,
fingerprint: key.fingerprint,
notes: key.notes,
});
}}
sx={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) auto",
gap: 1,
px: 1.25,
py: 1.1,
borderTop: 1,
borderColor: "divider",
cursor: "pointer",
width: "100%",
bgcolor: active
? "action.selected"
: "background.paper",
"&:hover .rail-edit": { opacity: 1 },
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 700 }} noWrap>
{key.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{key.private_key_set ? "key saved" : "no key"} ·{" "}
{key.usage_count} machine
{key.usage_count === 1 ? "" : "s"}
</Typography>
</Box>
<HoverEditButton
onClick={() => {
onSelectKeyId(key.id);
setDraft({
id: key.id,
name: key.name,
private_key: "",
passphrase: "",
public_key: key.public_key,
fingerprint: key.fingerprint,
notes: key.notes,
});
}}
/>
</Box>
);
})}
</Box>
</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 ? (
<Chip
size="small"
variant="outlined"
label={`${sshKeys.length} saved`}
label={`${selectedKey.usage_count} machine${selectedKey.usage_count === 1 ? "" : "s"}`}
/>
</Stack>
) : undefined
}
>
<Stack spacing={1.5}>
<Grid container spacing={1.25}>
<Grid size={{ xs: 12, md: 4 }}>
<TextField
@@ -656,7 +759,10 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
label="Key name"
value={draft.name}
onChange={(e) =>
setDraft((current) => ({ ...current, name: e.target.value }))
setDraft((current) => ({
...current,
name: e.target.value,
}))
}
/>
</Grid>
@@ -683,7 +789,10 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
label="Notes"
value={draft.notes}
onChange={(e) =>
setDraft((current) => ({ ...current, notes: e.target.value }))
setDraft((current) => ({
...current,
notes: e.target.value,
}))
}
/>
</Grid>
@@ -752,113 +861,70 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
<Button variant="outlined" onClick={clear}>
Clear
</Button>
{selectedKey && (
<Button
variant="outlined"
color="error"
onClick={() => deleteKey.mutate(selectedKey.id)}
>
Delete
</Button>
)}
</Stack>
{saveKey.error && (
<Alert severity="error">{String(saveKey.error)}</Alert>
)}
{sshKeys.length > 0 ? (
<Grid container spacing={1.25}>
{sshKeys.map((key) => (
<Grid key={key.id} size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1.1}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography sx={{ fontWeight: 700 }}>
{key.name}
</Typography>
<Chip
size="small"
variant="outlined"
label={key.private_key_set ? "key saved" : "no key"}
/>
{key.passphrase_set && (
<Chip
size="small"
variant="outlined"
label="passphrase"
/>
)}
<Chip
size="small"
variant="outlined"
label={`${key.usage_count} machine${key.usage_count === 1 ? "" : "s"}`}
/>
</Stack>
{key.notes && (
<Typography variant="body2" color="text.secondary">
{key.notes}
</Typography>
)}
<Box
sx={{
px: 1,
py: 0.75,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Typography variant="caption" color="text.secondary">
Fingerprint
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
wordBreak: "break-all",
}}
>
{key.fingerprint || "Unavailable"}
</Typography>
</Box>
<Box
sx={{
px: 1,
py: 0.75,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Typography variant="caption" color="text.secondary">
Public key
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
wordBreak: "break-all",
}}
>
{key.public_key || "Unavailable"}
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Button
size="small"
color="error"
onClick={() => deleteKey.mutate(key.id)}
>
Delete
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
</Grid>
))}
</Grid>
) : (
<Alert severity="info">No SSH keys saved yet.</Alert>
{selectedKey && (
<Stack spacing={1.25}>
<Box
sx={{
px: 1,
py: 0.75,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Typography variant="caption" color="text.secondary">
Fingerprint
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
wordBreak: "break-all",
}}
>
{selectedKey.fingerprint || "Unavailable"}
</Typography>
</Box>
<Box
sx={{
px: 1,
py: 0.75,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Typography variant="caption" color="text.secondary">
Public key
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
wordBreak: "break-all",
}}
>
{selectedKey.public_key || "Unavailable"}
</Typography>
</Box>
</Stack>
)}
</Stack>
</CardContent>
</Card>
</SectionCard>
</Box>
);
}
function ResetLocalDatabaseCard() {
@@ -989,6 +1055,7 @@ export function Settings() {
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("");
@@ -1395,7 +1462,13 @@ export function Settings() {
) : null}
</Stack>
)}
{tab === "ssh-keys" && <SSHKeyManager sshKeys={sshKeys} />}
{tab === "ssh-keys" && (
<SSHKeyManager
sshKeys={sshKeys}
selectedKeyId={selectedSSHKeyId}
onSelectKeyId={setSelectedSSHKeyId}
/>
)}
{tab === "danger" && <ResetLocalDatabaseCard />}
</TabbedCard>
<Dialog