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> </Card>
); );
} }
function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) { function SSHKeyManager({
sshKeys,
selectedKeyId,
onSelectKeyId,
}: {
sshKeys: SSHKey[];
selectedKeyId: string;
onSelectKeyId: (id: string) => void;
}) {
const saveKey = useSaveSSHKey(); const saveKey = useSaveSSHKey();
const generateKey = useGenerateSSHKey(); const generateKey = useGenerateSSHKey();
const deleteKey = useDeleteSSHKey(); const deleteKey = useDeleteSSHKey();
@@ -610,8 +618,12 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
passphrase: "", passphrase: "",
notes: "", notes: "",
}); });
const selectedKey = useMemo(
() => sshKeys.find((key) => key.id === selectedKeyId) ?? null,
[sshKeys, selectedKeyId],
);
const editing = Boolean(draft.id); const editing = Boolean(draft.id);
const clear = () => const clear = () => {
setDraft({ setDraft({
id: null, id: null,
name: "", name: "",
@@ -621,33 +633,124 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
fingerprint: "", fingerprint: "",
notes: "", notes: "",
}); });
onSelectKeyId("");
};
return ( return (
<Card variant="outlined"> <Box
<CardContent sx={{ p: 1.5 }}> sx={{
<Stack spacing={1.5}> display: "grid",
<Stack gridTemplateColumns: {
direction="row" xs: "1fr",
spacing={1} md: "320px minmax(0, 1fr)",
sx={{ },
alignItems: "center", gap: 2,
justifyContent: "space-between", }}
flexWrap: "wrap", >
<SelectionRailCard
title="SSH keys"
description="Select a key to see its details."
minHeight={420}
footer={
<Button
variant="outlined"
size="small"
fullWidth
onClick={() => {
clear();
}} }}
> >
<Box> New key
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> </Button>
SSH keys }
</Typography> >
<Box sx={{ flex: 1, overflowY: "auto" }}>
{sshKeys.length === 0 && (
<Box sx={{ p: 2, textAlign: "center" }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Import or generate reusable keys for SSH machines. No SSH keys saved yet.
</Typography> </Typography>
</Box> </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 <Chip
size="small" size="small"
variant="outlined" 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 container spacing={1.25}>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
<TextField <TextField
@@ -656,7 +759,10 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
label="Key name" label="Key name"
value={draft.name} value={draft.name}
onChange={(e) => onChange={(e) =>
setDraft((current) => ({ ...current, name: e.target.value })) setDraft((current) => ({
...current,
name: e.target.value,
}))
} }
/> />
</Grid> </Grid>
@@ -683,7 +789,10 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
label="Notes" label="Notes"
value={draft.notes} value={draft.notes}
onChange={(e) => onChange={(e) =>
setDraft((current) => ({ ...current, notes: e.target.value })) setDraft((current) => ({
...current,
notes: e.target.value,
}))
} }
/> />
</Grid> </Grid>
@@ -752,113 +861,70 @@ function SSHKeyManager({ sshKeys }: { sshKeys: SSHKey[] }) {
<Button variant="outlined" onClick={clear}> <Button variant="outlined" onClick={clear}>
Clear Clear
</Button> </Button>
{selectedKey && (
<Button
variant="outlined"
color="error"
onClick={() => deleteKey.mutate(selectedKey.id)}
>
Delete
</Button>
)}
</Stack> </Stack>
{saveKey.error && ( {saveKey.error && (
<Alert severity="error">{String(saveKey.error)}</Alert> <Alert severity="error">{String(saveKey.error)}</Alert>
)} )}
{sshKeys.length > 0 ? ( {selectedKey && (
<Grid container spacing={1.25}> <Stack spacing={1.25}>
{sshKeys.map((key) => ( <Box
<Grid key={key.id} size={{ xs: 12, md: 6 }}> sx={{
<Card variant="outlined"> px: 1,
<CardContent sx={{ p: 1.5 }}> py: 0.75,
<Stack spacing={1.1}> border: 1,
<Stack borderColor: "divider",
direction="row" borderRadius: 1,
spacing={1} }}
sx={{ alignItems: "center", flexWrap: "wrap" }} >
> <Typography variant="caption" color="text.secondary">
<Typography sx={{ fontWeight: 700 }}> Fingerprint
{key.name} </Typography>
</Typography> <Typography
<Chip variant="body2"
size="small" sx={{
variant="outlined" fontFamily: "monospace",
label={key.private_key_set ? "key saved" : "no key"} wordBreak: "break-all",
/> }}
{key.passphrase_set && ( >
<Chip {selectedKey.fingerprint || "Unavailable"}
size="small" </Typography>
variant="outlined" </Box>
label="passphrase" <Box
/> sx={{
)} px: 1,
<Chip py: 0.75,
size="small" border: 1,
variant="outlined" borderColor: "divider",
label={`${key.usage_count} machine${key.usage_count === 1 ? "" : "s"}`} borderRadius: 1,
/> }}
</Stack> >
{key.notes && ( <Typography variant="caption" color="text.secondary">
<Typography variant="body2" color="text.secondary"> Public key
{key.notes} </Typography>
</Typography> <Typography
)} variant="body2"
<Box sx={{
sx={{ fontFamily: "monospace",
px: 1, wordBreak: "break-all",
py: 0.75, }}
border: 1, >
borderColor: "divider", {selectedKey.public_key || "Unavailable"}
borderRadius: 1, </Typography>
}} </Box>
> </Stack>
<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>
)} )}
</Stack> </Stack>
</CardContent> </SectionCard>
</Card> </Box>
); );
} }
function ResetLocalDatabaseCard() { function ResetLocalDatabaseCard() {
@@ -989,6 +1055,7 @@ export function Settings() {
const testMachineSSH = useTestMonitoringMachineSSH(); const testMachineSSH = useTestMonitoringMachineSSH();
const [tab, setTab] = useState<SettingsTab>("machines"); const [tab, setTab] = useState<SettingsTab>("machines");
const [deleteMachineId, setDeleteMachineId] = useState<string | null>(null); const [deleteMachineId, setDeleteMachineId] = useState<string | null>(null);
const [selectedSSHKeyId, setSelectedSSHKeyId] = useState("");
const [sshValidationMessage, setSSHValidationMessage] = useState(""); const [sshValidationMessage, setSSHValidationMessage] = useState("");
const [sshValidationError, setSSHValidationError] = useState(""); const [sshValidationError, setSSHValidationError] = useState("");
const [sshValidationStatus, setSSHValidationStatus] = useState(""); const [sshValidationStatus, setSSHValidationStatus] = useState("");
@@ -1395,7 +1462,13 @@ export function Settings() {
) : null} ) : null}
</Stack> </Stack>
)} )}
{tab === "ssh-keys" && <SSHKeyManager sshKeys={sshKeys} />} {tab === "ssh-keys" && (
<SSHKeyManager
sshKeys={sshKeys}
selectedKeyId={selectedSSHKeyId}
onSelectKeyId={setSelectedSSHKeyId}
/>
)}
{tab === "danger" && <ResetLocalDatabaseCard />} {tab === "danger" && <ResetLocalDatabaseCard />}
</TabbedCard> </TabbedCard>
<Dialog <Dialog