Implement account login and logout

This commit is contained in:
Joscha 2022-08-22 17:12:41 +02:00
parent 84930c8c34
commit 8128342099
8 changed files with 299 additions and 29 deletions

View file

@ -15,6 +15,7 @@ Procedure when bumping the version number:
## Unreleased
### Added
- Account login and logout
- Authentication dialog for password-protected rooms
- Error popups in rooms when something goes wrong

2
Cargo.lock generated
View file

@ -290,7 +290,7 @@ checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be"
[[package]]
name = "euphoxide"
version = "0.1.0"
source = "git+https://github.com/Garmelon/euphoxide.git?rev=5ac16db3fcf9a5a6705630e92f3ad859e99cd891#5ac16db3fcf9a5a6705630e92f3ad859e99cd891"
source = "git+https://github.com/Garmelon/euphoxide.git?rev=516bb8232381a7f5751cc4e0c74477b535fe46df#516bb8232381a7f5751cc4e0c74477b535fe46df"
dependencies = [
"futures",
"serde",

View file

@ -30,10 +30,10 @@ features = ["rustls-tls-native-roots"]
[dependencies.euphoxide]
git = "https://github.com/Garmelon/euphoxide.git"
rev = "5ac16db3fcf9a5a6705630e92f3ad859e99cd891"
rev = "516bb8232381a7f5751cc4e0c74477b535fe46df"
# [patch."https://github.com/Garmelon/euphoxide.git"]
# toss = { path = "../euphoxide/" }
# euphoxide = { path = "../euphoxide/" }
[dependencies.toss]
git = "https://github.com/Garmelon/toss.git"

View file

@ -6,7 +6,9 @@ use std::time::Duration;
use anyhow::bail;
use cookie::{Cookie, CookieJar};
use euphoxide::api::packet::ParsedPacket;
use euphoxide::api::{Auth, AuthOption, Data, Log, Nick, Send, Snowflake, Time, UserId};
use euphoxide::api::{
Auth, AuthOption, Data, Log, Login, Logout, Nick, Send, Snowflake, Time, UserId,
};
use euphoxide::conn::{ConnRx, ConnTx, Joining, Status};
use log::{error, info, warn};
use parking_lot::Mutex;
@ -49,6 +51,8 @@ enum Event {
Auth(String),
Nick(String),
Send(Option<Snowflake>, String, oneshot::Sender<Snowflake>),
Login { email: String, password: String },
Logout,
}
#[derive(Debug)]
@ -192,6 +196,8 @@ impl State {
Event::Auth(password) => self.on_auth(password),
Event::Nick(name) => self.on_nick(name),
Event::Send(parent, content, id_tx) => self.on_send(parent, content, id_tx),
Event::Login { email, password } => self.on_login(email, password),
Event::Logout => self.on_logout(),
}
}
Ok(())
@ -361,6 +367,22 @@ impl State {
});
}
}
fn on_login(&self, email: String, password: String) {
if let Some(conn_tx) = &self.conn_tx {
let _ = conn_tx.send(Login {
namespace: "email".to_string(),
id: email,
password,
});
}
}
fn on_logout(&self) {
if let Some(conn_tx) = &self.conn_tx {
let _ = conn_tx.send(Logout);
}
}
}
#[derive(Debug)]
@ -428,4 +450,16 @@ impl Room {
.map(|_| id_rx)
.map_err(|_| Error::Stopped)
}
pub fn login(&self, email: String, password: String) -> Result<(), Error> {
self.event_tx
.send(Event::Login { email, password })
.map_err(|_| Error::Stopped)
}
pub fn logout(&self) -> Result<(), Error> {
self.event_tx
.send(Event::Logout)
.map_err(|_| Error::Stopped)
}
}

View file

@ -1,3 +1,4 @@
mod account;
mod auth;
mod nick;
mod nick_list;

204
src/ui/euph/account.rs Normal file
View file

@ -0,0 +1,204 @@
use std::sync::Arc;
use crossterm::event::KeyCode;
use euphoxide::api::PersonalAccountView;
use euphoxide::conn::Status;
use parking_lot::FairMutex;
use toss::styled::Styled;
use toss::terminal::Terminal;
use crate::euph::Room;
use crate::ui::input::{key, InputEvent, KeyBindingsList, KeyEvent};
use crate::ui::util;
use crate::ui::widgets::editor::EditorState;
use crate::ui::widgets::empty::Empty;
use crate::ui::widgets::join::{Segment, VJoin};
use crate::ui::widgets::popup::Popup;
use crate::ui::widgets::text::Text;
use crate::ui::widgets::BoxedWidget;
use super::room::RoomStatus;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
Email,
Password,
}
pub struct LoggedOut {
focus: Focus,
email: EditorState,
password: EditorState,
}
impl LoggedOut {
fn new() -> Self {
Self {
focus: Focus::Email,
email: EditorState::new(),
password: EditorState::new(),
}
}
fn widget(&self) -> BoxedWidget {
VJoin::new(vec![
Segment::new(Text::new("Email address")),
Segment::new(self.email.widget().focus(self.focus == Focus::Email)),
Segment::new(Empty::new().height(1)),
Segment::new(Text::new("Password")),
Segment::new(
self.password
.widget()
.focus(self.focus == Focus::Password)
.hidden(),
),
])
.into()
}
}
pub struct LoggedIn(PersonalAccountView);
impl LoggedIn {
fn widget(&self) -> BoxedWidget {
let text = Styled::new_plain("Name: ")
.then_plain(&self.0.name)
.then_plain("\n")
.then_plain("Email: ")
.then_plain(&self.0.email);
Text::new(text).into()
}
}
pub enum AccountUiState {
LoggedOut(LoggedOut),
LoggedIn(LoggedIn),
}
pub enum EventResult {
NotHandled,
Handled,
ResetState,
}
impl AccountUiState {
pub fn new() -> Self {
Self::LoggedOut(LoggedOut::new())
}
/// Returns `false` if the account UI should not be displayed any longer.
pub fn stabilize(&mut self, status: &RoomStatus) -> bool {
if let RoomStatus::Connected(Status::Joined(status)) = status {
match (&self, &status.account) {
(Self::LoggedOut(_), Some(view)) => *self = Self::LoggedIn(LoggedIn(view.clone())),
(Self::LoggedIn(_), None) => *self = Self::LoggedOut(LoggedOut::new()),
_ => {}
}
true
} else {
false
}
}
pub fn widget(&self) -> BoxedWidget {
let inner = match self {
Self::LoggedOut(logged_out) => logged_out.widget(),
Self::LoggedIn(logged_in) => logged_in.widget(),
};
Popup::new(inner).title("Account").build()
}
pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.binding("esc", "close account ui");
match self {
Self::LoggedOut(logged_out) => {
match logged_out.focus {
Focus::Email => bindings.binding("enter", "focus on password"),
Focus::Password => bindings.binding("enter", "log in"),
}
bindings.binding("tab", "switch focus");
util::list_editor_key_bindings(bindings, |c| c != '\n', false);
}
Self::LoggedIn(_) => bindings.binding("L", "log out"),
}
}
pub fn handle_input_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: &InputEvent,
room: &Option<Room>,
) -> EventResult {
if let key!(Esc) = event {
return EventResult::ResetState;
}
match self {
Self::LoggedOut(logged_out) => {
if let key!(Tab) = event {
logged_out.focus = match logged_out.focus {
Focus::Email => Focus::Password,
Focus::Password => Focus::Email,
};
return EventResult::Handled;
}
match logged_out.focus {
Focus::Email => {
if let key!(Enter) = event {
logged_out.focus = Focus::Password;
return EventResult::Handled;
}
if util::handle_editor_input_event(
&logged_out.email,
terminal,
crossterm_lock,
event,
|c| c != '\n',
false,
) {
EventResult::Handled
} else {
EventResult::NotHandled
}
}
Focus::Password => {
if let key!(Enter) = event {
if let Some(room) = room {
let _ =
room.login(logged_out.email.text(), logged_out.password.text());
}
return EventResult::Handled;
}
if util::handle_editor_input_event(
&logged_out.password,
terminal,
crossterm_lock,
event,
|c| c != '\n',
false,
) {
EventResult::Handled
} else {
EventResult::NotHandled
}
}
}
}
Self::LoggedIn(_) => {
if let key!('L') = event {
if let Some(room) = room {
let _ = room.logout();
}
EventResult::Handled
} else {
EventResult::NotHandled
}
}
}
}
}

View file

@ -27,6 +27,7 @@ use crate::ui::widgets::BoxedWidget;
use crate::ui::UiEvent;
use crate::vault::EuphVault;
use super::account::{self, AccountUiState};
use super::popup::RoomPopup;
use super::{auth, nick, nick_list};
@ -34,6 +35,7 @@ enum State {
Normal,
Auth(EditorState),
Nick(EditorState),
Account(AccountUiState),
}
#[allow(clippy::large_enum_variant)]
@ -152,7 +154,7 @@ impl EuphRoom {
}
fn stabilize_state(&mut self, status: &RoomStatus) {
match &self.state {
match &mut self.state {
State::Auth(_)
if !matches!(
status,
@ -167,6 +169,11 @@ impl EuphRoom {
State::Nick(_) if !matches!(status, RoomStatus::Connected(Status::Joined(_))) => {
self.state = State::Normal
}
State::Account(account) => {
if !account.stabilize(status) {
self.state = State::Normal
}
}
_ => {}
}
}
@ -192,6 +199,7 @@ impl EuphRoom {
State::Normal => {}
State::Auth(editor) => layers.push(auth::widget(editor)),
State::Nick(editor) => layers.push(nick::widget(editor)),
State::Account(account) => layers.push(account.widget()),
}
for popup in &self.popups {
@ -276,6 +284,7 @@ impl EuphRoom {
}
Some(Status::Joined(_)) => {
bindings.binding("n", "change nick");
bindings.binding("A", "show account ui");
true
}
_ => false,
@ -323,10 +332,17 @@ impl EuphRoom {
self.state = State::Auth(auth::new());
true
}
Some(Status::Joined(joined)) if matches!(event, key!('n') | key!('N')) => {
self.state = State::Nick(nick::new(joined));
true
}
Some(Status::Joined(joined)) => match event {
key!('n') | key!('N') => {
self.state = State::Nick(nick::new(joined));
true
}
key!('A') => {
self.state = State::Account(AccountUiState::new());
true
}
_ => false,
},
_ => false,
}
} else {
@ -349,6 +365,7 @@ impl EuphRoom {
State::Normal => self.list_normal_key_bindings(bindings).await,
State::Auth(_) => auth::list_key_bindings(bindings),
State::Nick(_) => nick::list_key_bindings(bindings),
State::Account(account) => account.list_key_bindings(bindings),
}
}
@ -366,7 +383,7 @@ impl EuphRoom {
return false;
}
match &self.state {
match &mut self.state {
State::Normal => {
self.handle_normal_input_event(terminal, crossterm_lock, event)
.await
@ -393,6 +410,16 @@ impl EuphRoom {
}
}
}
State::Account(account) => {
match account.handle_input_event(terminal, crossterm_lock, event, &self.room) {
account::EventResult::NotHandled => false,
account::EventResult::Handled => true,
account::EventResult::ResetState => {
self.state = State::Normal;
true
}
}
}
}
}
@ -407,11 +434,9 @@ impl EuphRoom {
}
fn handle_euph_data(&mut self, data: Data) -> bool {
// These packets don't result in any noticeable change in the UI. This
// function's main purpose is to prevent pings from causing a redraw.
// These packets don't result in any noticeable change in the UI.
#[allow(clippy::match_like_matches_macro)]
match data {
let handled = match &data {
Data::PingEvent(_) | Data::PingReply(_) => {
// Pings are displayed nowhere in the room UI.
false
@ -421,23 +446,27 @@ impl EuphRoom {
// we'll get an `EuphRoomEvent::Disconnected` soon after this.
false
}
Data::AuthReply(reply) if !reply.success => {
// Because the euphoria API is very carefully designed with
// emphasis on consistency, authentication failures are not
// normal errors but instead error-free replies that encode
// their own error.
let description = "Failed to authenticate.".to_string();
let reason = reply
.reason
.unwrap_or_else(|| "no idea, the server wouldn't say".to_string());
self.popups.push_front(RoomPopup::ServerError {
description,
reason,
});
true
}
_ => true,
};
// Because the euphoria API is very carefully designed with emphasis on
// consistency, some failures are not normal errors but instead
// error-free replies that encode their own error.
let error = match data {
Data::AuthReply(reply) if !reply.success => Some(("authenticate", reply.reason)),
Data::LoginReply(reply) if !reply.success => Some(("login", reply.reason)),
_ => None,
};
if let Some((action, reason)) = error {
let description = format!("Failed to {action}.");
let reason = reason.unwrap_or_else(|| "no idea, the server wouldn't say".to_string());
self.popups.push_front(RoomPopup::ServerError {
description,
reason,
});
}
handled
}
fn handle_euph_error(&mut self, r#type: PacketType, reason: String) -> bool {

View file

@ -53,6 +53,7 @@ impl From<crossterm::event::KeyEvent> for KeyEvent {
}
}
// TODO Use absolute paths
#[rustfmt::skip]
macro_rules! key {
// key!(Paste text)