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:
+196
-123
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user