use std::io::{self, IsTerminal};
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Terminal;
use crate::api::ControlPlaneClient;
use crate::config::{default_api_base, SessionConfig};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Login,
Register,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Field {
Email,
Password,
DisplayName,
}
pub async fn run_if_needed() -> anyhow::Result<()> {
if crate::auth_context::credentials_available() {
return Ok(());
}
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
anyhow::bail!(
"not logged in; run `flatland3 auth login` or set FLATLAND_API_TOKEN.\n\
(non-interactive shell — cannot show login UI)"
);
}
run_login_screen().await
}
async fn run_login_screen() -> anyhow::Result<()> {
crate::terminal::enter()?;
let _guard = crate::terminal::Guard;
let backend = CrosstermBackend::new(io::stdout());
let mut term = Terminal::new(backend)?;
let mut mode = Mode::Login;
let mut field = Field::Email;
let mut email = String::new();
let mut password = String::new();
let mut display_name = String::new();
let mut status = String::from("Sign in to play on the control plane.");
let mut busy = false;
loop {
term.draw(|frame| {
draw_login(
frame,
frame.area(),
mode,
field,
&email,
&password,
&display_name,
&status,
busy,
);
})?;
if !event::poll(Duration::from_millis(100))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if busy {
continue;
}
match key.code {
KeyCode::Esc => anyhow::bail!("login cancelled"),
KeyCode::Tab => {
field = next_field(mode, field);
}
KeyCode::BackTab => {
field = prev_field(mode, field);
}
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
mode = Mode::Register;
field = Field::Email;
status = "Create an account, then play.".into();
}
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
mode = Mode::Login;
field = Field::Email;
status = "Sign in with email and password.".into();
}
KeyCode::Enter => {
busy = true;
status = "Working…".into();
term.draw(|frame| {
draw_login(
frame,
frame.area(),
mode,
field,
&email,
&password,
&display_name,
&status,
busy,
);
})?;
let result = match mode {
Mode::Login => submit_login(&email, &password).await,
Mode::Register => submit_register(&email, &password, &display_name).await,
};
busy = false;
match result {
Ok(()) => return Ok(()),
Err(err) => status = err.to_string(),
}
}
KeyCode::Char(c) => {
let target = active_string_mut(mode, field, &mut email, &mut password, &mut display_name);
target.push(c);
}
KeyCode::Backspace => {
let target = active_string_mut(mode, field, &mut email, &mut password, &mut display_name);
target.pop();
}
_ => {}
}
}
}
fn active_string_mut<'a>(
mode: Mode,
field: Field,
email: &'a mut String,
password: &'a mut String,
display_name: &'a mut String,
) -> &'a mut String {
match field {
Field::Email => email,
Field::Password => password,
Field::DisplayName if mode == Mode::Register => display_name,
Field::DisplayName => email,
}
}
fn next_field(mode: Mode, field: Field) -> Field {
match (mode, field) {
(Mode::Login, Field::Email) => Field::Password,
(Mode::Login, Field::Password) => Field::Email,
(Mode::Login, Field::DisplayName) => Field::Email,
(Mode::Register, Field::Email) => Field::Password,
(Mode::Register, Field::Password) => Field::DisplayName,
(Mode::Register, Field::DisplayName) => Field::Email,
}
}
fn prev_field(mode: Mode, field: Field) -> Field {
match (mode, field) {
(Mode::Login, Field::Email) => Field::Password,
(Mode::Login, Field::Password) => Field::Email,
(Mode::Login, Field::DisplayName) => Field::Password,
(Mode::Register, Field::Email) => Field::DisplayName,
(Mode::Register, Field::Password) => Field::Email,
(Mode::Register, Field::DisplayName) => Field::Password,
}
}
fn mask_password(password: &str) -> String {
if password.is_empty() {
"(password)".to_string()
} else {
"•".repeat(password.chars().count())
}
}
fn draw_login(
frame: &mut ratatui::Frame,
area: Rect,
mode: Mode,
field: Field,
email: &str,
password: &str,
display_name: &str,
status: &str,
busy: bool,
) {
frame.render_widget(Clear, area);
let popup = centered_rect(70, 55, area);
frame.render_widget(Clear, popup);
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(3),
])
.split(popup);
let title = match mode {
Mode::Login => " Flatland3 — Sign in ",
Mode::Register => " Flatland3 — Register ",
};
frame.render_widget(
Paragraph::new(title).block(Block::default().borders(Borders::ALL)),
chunks[0],
);
let mode_hint = match mode {
Mode::Login => "Mode: Login (Ctrl+R register)",
Mode::Register => "Mode: Register (Ctrl+L login)",
};
frame.render_widget(
Paragraph::new(mode_hint).style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
render_field(frame, chunks[2], "Email", email, field == Field::Email);
render_field(
frame,
chunks[3],
"Password",
&mask_password(password),
field == Field::Password,
);
if mode == Mode::Register {
render_field(
frame,
chunks[4],
"Display name",
display_name,
field == Field::DisplayName,
);
} else {
frame.render_widget(
Paragraph::new(status)
.wrap(ratatui::widgets::Wrap { trim: true })
.style(Style::default().fg(Color::Yellow)),
chunks[4],
);
}
let footer_idx = if mode == Mode::Register { 5 } else { 5 };
let footer = if busy {
"Please wait…"
} else {
"Tab: next field Enter: submit Esc: quit"
};
frame.render_widget(
Paragraph::new(footer).alignment(Alignment::Center),
chunks[footer_idx],
);
if mode == Mode::Register {
frame.render_widget(
Paragraph::new(status)
.wrap(ratatui::widgets::Wrap { trim: true })
.style(Style::default().fg(Color::Yellow)),
Rect {
y: chunks[4].y + chunks[4].height.saturating_sub(2),
height: 2,
..chunks[4]
},
);
}
}
fn render_field(frame: &mut ratatui::Frame, area: Rect, label: &str, value: &str, focused: bool) {
let style = if focused {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let display = if value.is_empty() {
format!("{label}: ")
} else {
format!("{label}: {value}")
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(display, style)))
.block(Block::default().borders(if focused { Borders::ALL } else { Borders::NONE })),
area,
);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
async fn submit_login(email: &str, password: &str) -> anyhow::Result<()> {
if email.trim().is_empty() || password.is_empty() {
anyhow::bail!("email and password are required");
}
let api = default_api_base();
let client = ControlPlaneClient::new(&api);
#[derive(serde::Deserialize)]
struct LoginData {
session_token: String,
}
let data: LoginData = client
.post(
"/v1/auth/login",
&serde_json::json!({ "email": email.trim(), "password": password }),
None,
)
.await?;
let config = SessionConfig {
api_base: api.trim_end_matches('/').to_string(),
session_token: data.session_token,
last_character_id: None,
};
config.save()?;
Ok(())
}
async fn submit_register(email: &str, password: &str, display_name: &str) -> anyhow::Result<()> {
if email.trim().is_empty() || password.is_empty() || display_name.trim().is_empty() {
anyhow::bail!("email, password, and display name are required");
}
let api = default_api_base();
let client = ControlPlaneClient::new(&api);
let _: serde_json::Value = client
.post(
"/v1/auth/register",
&serde_json::json!({
"email": email.trim(),
"password": password,
"display_name": display_name.trim(),
}),
None,
)
.await?;
submit_login(email, password).await
}