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>
|
</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
|
||||||
|
|||||||
Reference in New Issue
Block a user