015f300941651035945b5cdd98aeada97e4337aa / frogue / src / ssh / tui / register.rs
Raw
//! The SSH registration form.
//!
//! A simple terminal UI that allows unauthenticated users to register an account
//! by providing username, email, and password. The SSH public key is taken from
//! the verified connection and displayed as read-only.
use std::sync::Arc;
use crossterm::ExecutableCommand;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::{Frame, Terminal};
use russh::keys::ssh_key;
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter};
use tokio::sync::{RwLock, mpsc};
use tracing::info;
use uuid::Uuid;
use crate::AppState;
use crate::auth::hash_password;
use crate::entities::{ssh_keys, users};
/// The editable fields in the registration form.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Field {
Username,
Email,
Password,
ConfirmPassword,
}
impl Field {
fn next(self) -> Self {
match self {
Field::Username => Field::Email,
Field::Email => Field::Password,
Field::Password => Field::ConfirmPassword,
Field::ConfirmPassword => Field::Username,
}
}
fn previous(self) -> Self {
match self {
Field::Username => Field::ConfirmPassword,
Field::Email => Field::Username,
Field::Password => Field::Email,
Field::ConfirmPassword => Field::Password,
}
}
fn label(self) -> &'static str {
match self {
Field::Username => "Username",
Field::Email => "Email",
Field::Password => "Password",
Field::ConfirmPassword => "Confirm Password",
}
}
}
/// The state of the registration form.
struct FormState {
username: String,
email: String,
password: String,
confirm_password: String,
current_field: Field,
error: Option<String>,
submitted: bool,
}
impl FormState {
fn new() -> Self {
Self {
username: String::new(),
email: String::new(),
password: String::new(),
confirm_password: String::new(),
current_field: Field::Username,
error: None,
submitted: false,
}
}
fn current_value_mut(&mut self) -> &mut String {
match self.current_field {
Field::Username => &mut self.username,
Field::Email => &mut self.email,
Field::Password => &mut self.password,
Field::ConfirmPassword => &mut self.confirm_password,
}
}
fn handle_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Tab | KeyCode::Down => {
self.current_field = self.current_field.next();
self.error = None;
}
KeyCode::BackTab | KeyCode::Up => {
self.current_field = self.current_field.previous();
self.error = None;
}
KeyCode::Enter => {
self.submitted = true;
}
KeyCode::Char(c) => {
self.current_value_mut().push(c);
}
KeyCode::Backspace => {
self.current_value_mut().pop();
}
_ => {}
}
}
}
/// Draws the registration form UI.
fn draw(f: &mut Frame, form: &FormState, verified_key: &Option<String>) {
let size = f.area();
// Clear the screen
f.render_widget(Clear, size);
// Create a centered layout
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(10),
Constraint::Length(1),
])
.split(size);
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(10),
Constraint::Percentage(80),
Constraint::Percentage(10),
])
.split(vertical[1]);
let area = horizontal[1];
// Create the form block
let block = Block::default()
.title(" Register a new account ")
.borders(Borders::ALL)
.style(Style::default().fg(Color::White));
let inner = block.inner(area);
f.render_widget(block, area);
// Create form fields
let fields = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Username
Constraint::Length(3), // Email
Constraint::Length(3), // Password
Constraint::Length(3), // Confirm
Constraint::Length(1), // Spacer
Constraint::Length(5), // SSH Key (read-only display)
Constraint::Length(3), // Error/Submit
Constraint::Min(0), // Spacer
])
.split(inner);
// Draw each field
draw_field(
f,
fields[0],
Field::Username,
&form.username,
form.current_field == Field::Username,
false,
);
draw_field(
f,
fields[1],
Field::Email,
&form.email,
form.current_field == Field::Email,
false,
);
draw_field(
f,
fields[2],
Field::Password,
&form.password,
form.current_field == Field::Password,
true,
);
draw_field(
f,
fields[3],
Field::ConfirmPassword,
&form.confirm_password,
form.current_field == Field::ConfirmPassword,
true,
);
// Draw SSH key as read-only section
draw_ssh_key_display(f, fields[5], verified_key);
// Draw error or submit button
if let Some(error) = &form.error {
let error_text = Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red));
f.render_widget(error_text, fields[6]);
} else {
let submit = Paragraph::new("Press ENTER to submit, Tab to navigate, Ctrl+C to cancel")
.style(Style::default().fg(Color::DarkGray));
f.render_widget(submit, fields[6]);
}
}
/// Draws a single form field.
fn draw_field(f: &mut Frame, area: Rect, field: Field, value: &str, focused: bool, masked: bool) {
let label = field.label();
let display_value = if masked {
"•".repeat(value.len())
} else {
value.to_string()
};
let border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.title(format!(" {} ", label))
.borders(Borders::ALL)
.style(border_style);
let inner = block.inner(area);
f.render_widget(block, area);
let text = Paragraph::new(display_value);
f.render_widget(text, inner);
// Draw cursor if focused
if focused {
let cursor_x = inner.x + value.len() as u16;
let cursor_y = inner.y;
f.set_cursor_position((cursor_x, cursor_y));
}
}
/// Draws the SSH public key as a read-only display.
fn draw_ssh_key_display(f: &mut Frame, area: Rect, verified_key: &Option<String>) {
let block = Block::default()
.title(" SSH Public Key (from this connection) ")
.borders(Borders::ALL)
.style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
f.render_widget(block, area);
let display_value = if let Some(key) = verified_key {
if key.len() > 30 {
// Ellide long keys: show first 10 and last 10 chars
format!("{}...{}", &key[..10], &key[key.len() - 10..])
} else {
key.clone()
}
} else {
"No SSH key provided".to_string()
};
let text = Paragraph::new(display_value).style(Style::default().fg(Color::DarkGray));
f.render_widget(text, inner);
}
/// Validates the form and performs registration if valid.
async fn validate_and_register(
form: &FormState,
verified_key: &Option<ssh_key::PublicKey>,
state: Arc<RwLock<AppState>>,
) -> Result<users::Model, String> {
if form.username.trim().is_empty() {
return Err("Username is required".to_string());
}
if form.email.trim().is_empty() {
return Err("Email is required".to_string());
}
if form.password.len() < 8 {
return Err("Password must be at least 8 characters".to_string());
}
if form.password != form.confirm_password {
return Err("Passwords do not match".to_string());
}
// The SSH key must be provided from the verified connection
let ssh_key_to_register = verified_key
.as_ref()
.ok_or_else(|| "No SSH key provided. Please reconnect with an SSH key.".to_string())?;
// Check if username already exists
let state_read = state.read().await;
let db = &state_read.db;
// Check registration mode gate before proceeding
crate::registration::check_mode_allows_registration(db, None).await.map_err(|e| {
if matches!(e, crate::registration::RegisterError::InviteRequired) {
"Registration on this server is invite-only. Please use the web UI with an invite link.".to_string()
} else {
e.to_string()
}
})?;
let existing_user = users::Entity::find()
.filter(users::Column::Name.eq(&form.username))
.one(db)
.await
.map_err(|e| format!("Database error: {}", e))?;
if existing_user.is_some() {
return Err("Username already taken".to_string());
}
// Check if email already exists
let existing_email = users::Entity::find()
.filter(users::Column::Email.eq(&form.email))
.one(db)
.await
.map_err(|e| format!("Database error: {}", e))?;
if existing_email.is_some() {
return Err("Email already registered".to_string());
}
// Hash the password
let password_hash =
hash_password(&form.password).map_err(|e| format!("Failed to hash password: {}", e))?;
// Create the user
let user = users::ActiveModel {
uuid: Set(Uuid::new_v4()),
name: Set(form.username.trim().to_string()),
email: Set(form.email.trim().to_string()),
password_hash: Set(password_hash),
..Default::default()
};
let user = user
.insert(db)
.await
.map_err(|e| format!("Failed to create user: {}", e))?;
info!(user_id = user.id, user = %user.name, "user registered via SSH TUI");
// Add the SSH key
let fingerprint = ssh_key_to_register
.fingerprint(russh::keys::HashAlg::Sha256)
.to_string();
let public_key_openssh = ssh_key_to_register
.to_openssh()
.map_err(|e| format!("Failed to serialize key: {}", e))?;
let ssh_key_model = ssh_keys::ActiveModel {
user_id: Set(user.id),
fingerprint: Set(fingerprint.clone()),
public_key: Set(public_key_openssh),
..Default::default()
};
ssh_key_model
.insert(db)
.await
.map_err(|e| format!("Failed to add SSH key: {}", e))?;
info!(user_id = user.id, fingerprint = %fingerprint, "SSH key added during registration");
Ok(user)
}
/// Runs the registration TUI and returns the created user if successful.
pub async fn run_registration_form(
input_rx: &mut mpsc::UnboundedReceiver<Vec<u8>>,
output_tx: mpsc::UnboundedSender<Vec<u8>>,
verified_key: Option<ssh_key::PublicKey>,
state: Arc<RwLock<AppState>>,
) -> Result<Option<users::Model>, Box<dyn std::error::Error + Send + Sync>> {
let writer = super::ChannelWriter::new(output_tx);
let backend = CrosstermBackend::new(writer);
let mut terminal = Terminal::new(backend)?;
// Clear the terminal before entering
terminal
.backend_mut()
.execute(terminal::Clear(terminal::ClearType::All))?;
// Convert verified key to string for display
let verified_key_str = verified_key
.as_ref()
.map(|k| k.to_openssh().unwrap_or_else(|_| "Invalid key".to_string()));
let mut form = FormState::new();
loop {
// Draw the UI
terminal.draw(|f| {
draw(f, &form, &verified_key_str);
})?;
// Wait for input
if let Some(data) = input_rx.recv().await {
// Parse the input as key events
for event in parse_key_events(&data) {
if let Event::Key(key) = event {
// Ctrl+C or Esc cancels the form
if key.code == KeyCode::Esc
|| (key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL))
{
cleanup(&mut terminal)?;
return Ok(None);
}
form.handle_key(key);
if form.submitted {
match validate_and_register(&form, &verified_key, state.clone()).await {
Ok(user) => {
cleanup(&mut terminal)?;
return Ok(Some(user));
}
Err(error) => {
form.error = Some(error);
form.submitted = false;
}
}
}
}
}
}
}
}
/// Cleans up the terminal state.
fn cleanup(
terminal: &mut Terminal<CrosstermBackend<super::ChannelWriter>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
terminal::disable_raw_mode()?;
terminal
.backend_mut()
.execute(terminal::Clear(terminal::ClearType::All))?;
terminal
.backend_mut()
.execute(terminal::LeaveAlternateScreen)?;
Ok(())
}
/// Parses raw bytes into key events.
fn parse_key_events(data: &[u8]) -> Vec<Event> {
let mut events = Vec::new();
let mut i = 0;
while i < data.len() {
// Check for escape sequences
if data[i] == 0x1b {
if i + 2 < data.len() && data[i + 1] == b'[' {
// CSI sequence
match data[i + 2] {
b'A' => events.push(Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))),
b'B' => {
events.push(Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)))
}
b'C' => events.push(Event::Key(KeyEvent::new(
KeyCode::Right,
KeyModifiers::NONE,
))),
b'D' => {
events.push(Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)))
}
b'Z' => events.push(Event::Key(KeyEvent::new(
KeyCode::BackTab,
KeyModifiers::NONE,
))),
_ => {}
}
i += 3;
continue;
}
i += 1;
continue;
}
// Regular character
let byte = data[i];
if byte < 0x80 {
// ASCII
if byte == 0x0d {
// Enter
events.push(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
)));
} else if byte == 0x7f || byte == 0x08 {
// Backspace
events.push(Event::Key(KeyEvent::new(
KeyCode::Backspace,
KeyModifiers::NONE,
)));
} else if byte == 0x09 {
// Tab
events.push(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
} else if byte == 0x03 {
// Ctrl+C
events.push(Event::Key(KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL,
)));
} else if byte == 0x1b {
// Escape
events.push(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
} else if (0x20..0x7f).contains(&byte) {
// Printable ASCII
events.push(Event::Key(KeyEvent::new(
KeyCode::Char(byte as char),
KeyModifiers::NONE,
)));
}
i += 1;
} else {
// UTF-8 multi-byte
// For simplicity, skip non-ASCII for now
i += 1;
}
}
events
}