Implement account login and logout
This commit is contained in:
parent
84930c8c34
commit
8128342099
8 changed files with 299 additions and 29 deletions
|
|
@ -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
2
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
mod account;
|
||||
mod auth;
|
||||
mod nick;
|
||||
mod nick_list;
|
||||
|
|
|
|||
204
src/ui/euph/account.rs
Normal file
204
src/ui/euph/account.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ impl From<crossterm::event::KeyEvent> for KeyEvent {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO Use absolute paths
|
||||
#[rustfmt::skip]
|
||||
macro_rules! key {
|
||||
// key!(Paste text)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue