rust: settings, SSH key manager, export/import
This commit is contained in:
+184
-9
@@ -1,26 +1,201 @@
|
||||
//! SSH Key Manager window.
|
||||
//! STUB — interface fixed; implementation pending.
|
||||
//! SSH Key Manager window: key pairs in ~/.ssh — copy public key, delete, generate (ssh-keygen).
|
||||
|
||||
use crate::settings::{Job, poll};
|
||||
use crate::state::Shared;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::widgets;
|
||||
use egui::{RichText, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use nexus_core::service::SshKeyInfo;
|
||||
|
||||
struct Generate {
|
||||
name: String,
|
||||
kind: &'static str,
|
||||
bits: u32,
|
||||
pass: String,
|
||||
confirm: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SshKeys {
|
||||
open: bool,
|
||||
/// Reload the list on the next frame.
|
||||
stale: bool,
|
||||
keys: Vec<SshKeyInfo>,
|
||||
load_error: Option<String>,
|
||||
confirm_delete: Option<String>,
|
||||
form: Option<Generate>,
|
||||
job: Option<Job<Result<String, String>>>,
|
||||
}
|
||||
|
||||
impl SshKeys {
|
||||
pub fn open(&mut self) {
|
||||
self.open = true;
|
||||
}
|
||||
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.open
|
||||
self.stale = true;
|
||||
}
|
||||
|
||||
/// Called every frame; draws its window(s) while open.
|
||||
pub fn ui(&mut self, ctx: &egui::Context, _s: &mut Shared) {
|
||||
let mut open = self.open;
|
||||
egui::Window::new("SshKeys").open(&mut open).show(ctx, |ui| ui.label("todo"));
|
||||
pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) {
|
||||
match poll(&mut self.job) {
|
||||
Some(Ok(name)) => {
|
||||
s.success(format!("Key \"{name}\" generated successfully"));
|
||||
self.form = None;
|
||||
self.stale = true;
|
||||
}
|
||||
Some(Err(e)) => s.error(format!("Generation failed: {e}")),
|
||||
None => {}
|
||||
}
|
||||
if !self.open {
|
||||
return;
|
||||
}
|
||||
if std::mem::take(&mut self.stale) {
|
||||
match s.svc.ssh_key_pairs() {
|
||||
Ok(k) => (self.keys, self.load_error) = (k, None),
|
||||
Err(e) => self.load_error = Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let mut open = true;
|
||||
egui::Window::new(RichText::new(format!("{} SSH Key Manager", icon::KEY)).font(theme::semibold(14.0)))
|
||||
.id(egui::Id::new("ssh-keys-window"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
.default_pos(ctx.content_rect().center())
|
||||
.resizable(true)
|
||||
.default_size([620.0, 460.0])
|
||||
.min_size([480.0, 300.0])
|
||||
.show(ctx, |ui| {
|
||||
widgets::section(ui, "Key pairs in ~/.ssh");
|
||||
if let Some(e) = &self.load_error {
|
||||
ui.label(RichText::new(format!("Failed to load keys: {e}")).color(t.danger));
|
||||
} else if self.keys.is_empty() {
|
||||
widgets::empty(ui, icon::KEY, "No SSH keys found", "Generate a key pair below.");
|
||||
}
|
||||
let list_h = if self.form.is_some() { 150.0 } else { (ui.available_height() - 40.0).max(120.0) };
|
||||
egui::ScrollArea::vertical().id_salt("keys").max_height(list_h).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for k in &self.keys {
|
||||
egui::Frame::new().fill(t.field).corner_radius(t.radius).inner_margin(8).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&k.name).font(theme::semibold(13.0)));
|
||||
widgets::chip(ui, &k.kind.to_uppercase(), t.accent);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::icon_button(ui, icon::TRASH, "Delete").clicked() {
|
||||
self.confirm_delete = Some(k.name.clone());
|
||||
}
|
||||
if widgets::icon_button(ui, icon::COPY, "Copy public key").clicked() {
|
||||
ui.ctx().copy_text(k.public_key.clone());
|
||||
s.success("Public key copied to clipboard");
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add(egui::Label::new(RichText::new(&k.public_key).font(theme::mono(11.0)).color(t.text_dim)).truncate()).on_hover_text(&k.public_key);
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
match &mut self.form {
|
||||
None => {
|
||||
if widgets::primary(ui, icon::PLUS, "Generate New Key").clicked() {
|
||||
self.form = Some(Generate { name: String::new(), kind: "ed25519", bits: 4096, pass: String::new(), confirm: String::new() });
|
||||
}
|
||||
}
|
||||
Some(f) => {
|
||||
if generate_form(ui, f, self.job.is_some()) {
|
||||
let name = f.name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
s.error("Key name is required");
|
||||
} else if f.pass != f.confirm {
|
||||
s.error("Passphrases do not match");
|
||||
} else {
|
||||
let (svc, kind, bits, pass) = (s.svc.clone(), f.kind, f.bits, f.pass.clone());
|
||||
self.job = Some(Job::spawn(ctx, move || svc.generate_ssh_key(&name, kind, bits, &pass).map(|_| name).map_err(|e| e.to_string())));
|
||||
}
|
||||
}
|
||||
if self.job.is_none() && ui.button("Cancel").clicked() {
|
||||
self.form = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
self.open = open;
|
||||
self.delete_dialog(ctx, s);
|
||||
}
|
||||
|
||||
fn delete_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) {
|
||||
let Some(name) = self.confirm_delete.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let mut done = false;
|
||||
let r = egui::Modal::new(egui::Id::new("delete-key")).show(ctx, |ui| {
|
||||
ui.set_width(360.0);
|
||||
ui.label(RichText::new("Delete Key").font(theme::semibold(15.0)));
|
||||
ui.label(format!("Delete key pair \"{name}\"? This cannot be undone."));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add(egui::Button::new(RichText::new(format!("{} Delete", icon::TRASH)).color(t.accent_text)).fill(t.danger)).clicked() {
|
||||
match s.svc.delete_ssh_key(&name) {
|
||||
Ok(()) => s.success(format!("Key \"{name}\" deleted")),
|
||||
Err(e) => s.error(format!("Delete failed: {e}")),
|
||||
}
|
||||
self.stale = true;
|
||||
done = true;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
done = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if done || r.should_close() {
|
||||
self.confirm_delete = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The generate form; true when Generate is clicked.
|
||||
fn generate_form(ui: &mut egui::Ui, f: &mut Generate, busy: bool) -> bool {
|
||||
widgets::section(ui, "Generate new key");
|
||||
let mut go = false;
|
||||
egui::Grid::new("gen-key").num_columns(2).spacing(vec2(14.0, 8.0)).show(ui, |ui| {
|
||||
ui.label("Key name");
|
||||
ui.add(egui::TextEdit::singleline(&mut f.name).hint_text(format!("id_{}_nexus", f.kind)).desired_width(260.0));
|
||||
ui.end_row();
|
||||
ui.label("Key type");
|
||||
let label = |k: &str| match k {
|
||||
"rsa" => "RSA",
|
||||
"ecdsa" => "ECDSA",
|
||||
_ => "Ed25519 (recommended)",
|
||||
};
|
||||
egui::ComboBox::from_id_salt("key-type").selected_text(label(f.kind)).width(260.0).show_ui(ui, |ui| {
|
||||
for k in ["ed25519", "rsa", "ecdsa"] {
|
||||
ui.selectable_value(&mut f.kind, k, label(k));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
if f.kind == "rsa" {
|
||||
ui.label("Key size");
|
||||
ui.horizontal(|ui| {
|
||||
ui.selectable_value(&mut f.bits, 4096, "4096");
|
||||
ui.selectable_value(&mut f.bits, 2048, "2048");
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label("Passphrase");
|
||||
ui.add(egui::TextEdit::singleline(&mut f.pass).password(true).hint_text("Leave blank for no passphrase").desired_width(260.0));
|
||||
ui.end_row();
|
||||
ui.label("Confirm");
|
||||
ui.add(egui::TextEdit::singleline(&mut f.confirm).password(true).desired_width(260.0));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if busy {
|
||||
ui.spinner();
|
||||
ui.label("Generating…");
|
||||
} else {
|
||||
go = widgets::primary(ui, icon::KEY, "Generate").clicked();
|
||||
}
|
||||
});
|
||||
go
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user