Compare commits

...

2 Commits

Author SHA1 Message Date
c94105acc3
Add logout functionallity 2024-10-16 15:18:18 -05:00
8a8e773795
Add basic login page 2024-10-16 13:56:33 -05:00
10 changed files with 539 additions and 265 deletions

1
Cargo.lock generated
View File

@ -550,6 +550,7 @@ dependencies = [
"console_error_panic_hook",
"gloo 0.11.0",
"log",
"serde",
"wasm-bindgen",
"wasm-logger",
"web-sys",

View File

@ -6,3 +6,8 @@ pub struct ChatMessage {
pub message: String,
pub time: DateTime<Local>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LoggedInResponse {
pub logged_in: bool,
}

View File

@ -10,6 +10,7 @@ common = { version = "0.1.0", path = "../common" }
console_error_panic_hook = "0.1.7"
gloo = "0.11.0"
log = "0.4.22"
serde = { version = "1.0.210", features = ["derive"] }
wasm-bindgen = "0.2.93"
wasm-logger = "0.2.0"
web-sys = { version = "0.3.70", features = ["Navigator", "WebSocket", "EventListener"] }

View File

@ -1,12 +1,63 @@
use yew::prelude::*;
use common::LoggedInResponse;
use gloo::net::http::Request;
use yew::{platform::spawn_local, prelude::*};
use yew_hooks::{use_async_with_options, UseAsyncOptions};
use yew_router::hooks::use_navigator;
use crate::Route;
#[function_component]
pub fn Nav() -> Html {
let navigator = use_navigator();
let logged_in = use_async_with_options(
async move {
let location = web_sys::window().unwrap().location();
let logged_in_url = format!(
"{}//{}/api/logged_in",
location.protocol().unwrap(),
location.host().unwrap()
);
let resp = Request::get(&logged_in_url)
.send()
.await
.unwrap()
.json::<LoggedInResponse>()
.await
.unwrap();
Result::<bool, ()>::Ok(resp.logged_in)
},
UseAsyncOptions::enable_auto(),
);
let logout = {
let logged_in = logged_in.clone();
Callback::from(move |_| {
let navigator = navigator.clone();
let logged_in = logged_in.clone();
//let update = update.clone();
spawn_local(async move {
let location = web_sys::window().unwrap().location();
let logout_url = format!(
"{}//{}/api/logout",
location.protocol().unwrap(),
location.host().unwrap()
);
Request::post(&logout_url)
.body("")
.unwrap()
.send()
.await
.unwrap();
logged_in.run();
navigator.as_ref().unwrap().push(&Route::Home);
});
})
};
html! {
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="#">{ "Chat" }</a>
<a class="navbar-brand" href="/">{ "Chat" }</a>
<div class="nav-item dropdown navbar-nav">
if logged_in.data.unwrap_or(false) {
<a
class="nav-link dropdown-toggle"
href="#"
@ -16,12 +67,23 @@ pub fn Nav() -> Html {
>
{ "PJHT" }
</a>
} else {
<a
class="nav-link dropdown-toggle disabled"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{ "Not logged in" }
</a>
}
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="#">{ "Options" }</a>
</li>
<li>
<a class="dropdown-item" href="#">{ "Logout" }</a>
<a class="dropdown-item" href="#" onclick={logout}>{ "Logout" }</a>
</li>
</ul>
</div>

View File

@ -1,7 +1,7 @@
pub mod components;
mod pages;
use components::Nav;
use pages::Home;
use pages::{ChatPage, Home, Login};
use yew::prelude::*;
use yew_router::prelude::*;
@ -10,6 +10,10 @@ use yew_router::prelude::*;
enum Route {
#[at("/")]
Home,
#[at("/chat")]
ChatPage,
#[at("/login")]
Login,
#[not_found]
#[at("/404")]
NotFound,
@ -18,6 +22,8 @@ enum Route {
fn switch(routes: Route) -> Html {
match routes {
Route::Home => html! { <Home /> },
Route::ChatPage => html! { <ChatPage /> },
Route::Login => html! { <Login /> },
Route::NotFound => html! {
<>
<Nav />

View File

@ -1,2 +1,6 @@
mod home;
pub use home::Home;
mod chat;
pub use chat::ChatPage;
mod login;
pub use login::{Login, LoginQuery};

238
frontend/src/pages/chat.rs Normal file
View File

@ -0,0 +1,238 @@
use std::time::Duration;
use chrono::Local;
use common::ChatMessage;
use gloo::{console::log, events::EventListener};
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{js_sys::Uint8Array, CloseEvent, Event, MessageEvent, WebSocket};
use yew::{platform::time::sleep, prelude::*};
use yew_router::prelude::RouterScopeExt;
use crate::{
components::{Alert, Message, Nav},
pages::LoginQuery,
Route,
};
pub enum ChatPageMessage {
SubmittedMessage(String),
RecievedMessage(ChatMessage),
WsClose(CloseEvent),
WsOpen,
WsReconnect,
}
#[derive(PartialEq, Eq)]
enum WsState {
Closed,
Open,
}
pub struct ChatPage {
messages: Vec<ChatMessage>,
chat_ws: Option<WebSocketConn>,
ws_state: WsState,
ws_reconnecting: bool,
message_container_ref: NodeRef,
}
struct WebSocketConn {
ws: WebSocket,
#[allow(dead_code)]
listeners: [EventListener; 3],
}
impl WebSocketConn {
fn send_binary(&self, msg: &[u8]) -> Result<(), JsValue> {
self.ws.send_with_u8_array(msg)
}
}
impl Drop for WebSocketConn {
fn drop(&mut self) {
let _ = self.ws.close();
}
}
impl ChatPage {
fn connect_ws(&mut self, ctx: &Context<Self>) {
let location = web_sys::window().unwrap().location();
let ws_proto = if location.protocol().unwrap() == "https:" {
"wss"
} else {
"ws"
};
let ws_url = format!("{}://{}/api/chat_ws", ws_proto, location.host().unwrap());
log!("Connecting to ", &ws_url);
let ws = WebSocket::new(&ws_url).unwrap();
ws.set_binary_type(web_sys::BinaryType::Arraybuffer);
let link = ctx.link().clone();
let open_ev = EventListener::new(&ws, "open", move |_event| {
link.send_message(ChatPageMessage::WsOpen);
});
let link = ctx.link().clone();
let close_ev = EventListener::new(&ws, "close", move |event: &Event| {
let event = event.dyn_ref::<CloseEvent>().unwrap();
link.send_message(ChatPageMessage::WsClose(event.clone()));
});
let link = ctx.link().clone();
let msg_ev = EventListener::new(&ws, "message", move |event: &Event| {
let event = event.dyn_ref::<MessageEvent>().unwrap();
let bytes = event.data();
let bytes = Uint8Array::new(&bytes).to_vec();
let msg = ciborium::from_reader(bytes.as_slice()).unwrap();
link.send_message(ChatPageMessage::RecievedMessage(msg));
});
self.chat_ws = Some(WebSocketConn {
ws,
listeners: [open_ev, close_ev, msg_ev],
});
}
}
impl Component for ChatPage {
type Message = ChatPageMessage;
type Properties = ();
fn create(ctx: &Context<Self>) -> Self {
let mut slf = Self {
messages: Vec::new(),
chat_ws: None,
ws_state: WsState::Closed,
ws_reconnecting: false,
message_container_ref: NodeRef::default(),
};
slf.connect_ws(ctx);
slf
}
fn view(&self, ctx: &Context<Self>) -> Html {
let msg_keypress = {
ctx.link().batch_callback(|press: KeyboardEvent| {
if press.key() == "Enter" {
let input = press
.target_dyn_into::<web_sys::HtmlInputElement>()
.unwrap();
let msg = input.value();
input.set_value("");
//if on_mobile() {
// input.blur().unwrap();
//}
Some(ChatPageMessage::SubmittedMessage(msg))
} else {
None
}
})
};
let disable_input = self.ws_state != WsState::Open;
html! {
<div class="myvh-100 d-flex flex-column">
<Nav />
<div class="container-fluid d-flex flex-column flex-grow-1 mt-3">
if self.ws_state != WsState::Open {
<Alert message="Connection to backend lost, trying to reconnect" />
}
<div
ref={self.message_container_ref.clone()}
class="d-flex border rounded flex-grow-1 flex-column-reverse overflow-auto mb-3"
style="flex-basis: 0"
>
{ self.messages.iter().rev().map(|msg| html!{
<Message message={msg.clone()}/>
}
).collect::<Vec<_>>() }
</div>
<div class="bottom-0 mb-3 mt-3">
<input
onkeydown={msg_keypress}
disabled={disable_input}
type="text"
id="message"
class="w-100 form-control"
/>
</div>
</div>
</div>
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
ChatPageMessage::SubmittedMessage(msg) => {
let msg = ChatMessage {
message: msg.clone(),
time: Local::now(),
};
self.messages.push(msg.clone());
let mut buf = Vec::new();
ciborium::into_writer(&msg, &mut buf).unwrap();
self.chat_ws.as_ref().unwrap().send_binary(&buf).unwrap();
true
}
ChatPageMessage::RecievedMessage(msg) => {
self.messages.push(msg);
true
}
ChatPageMessage::WsClose(close_event) => {
let code = close_event.code();
log!("WS connection closed with code", code);
if code == 4000 {
log!("Unauthorized, redirecting to login page");
ctx.link()
.navigator()
.unwrap()
.push_with_query(
&Route::Login,
&LoginQuery {
redirect: "/chat".to_string(),
},
)
.unwrap();
return true;
}
if self.ws_state == WsState::Closed {
log!("Already closed");
if !self.ws_reconnecting {
log!("Reconnecting in 5s");
self.ws_reconnecting = true;
ctx.link().send_future(async move {
sleep(Duration::from_secs(5)).await;
ChatPageMessage::WsReconnect
});
}
false
} else {
log!("Reconnecting");
self.connect_ws(ctx);
self.ws_state = WsState::Closed;
true
}
}
ChatPageMessage::WsOpen => {
if self.ws_state != WsState::Open {
log!("WS connection opened");
self.messages.clear();
self.ws_state = WsState::Open;
true
} else {
false
}
}
ChatPageMessage::WsReconnect => {
if self.ws_state != WsState::Open {
log!("Reconnecting");
self.ws_reconnecting = false;
self.connect_ws(ctx);
}
false
}
}
}
fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
if let Some(message_container) = self.message_container_ref.cast::<web_sys::Element>() {
message_container.set_scroll_top(message_container.scroll_height());
}
}
}

View File

@ -1,261 +1,72 @@
use std::time::Duration;
use crate::{components::Nav, pages::LoginQuery, Route};
use common::LoggedInResponse;
use gloo::net::http::Request;
use yew::prelude::*;
use yew_hooks::{use_async_with_options, UseAsyncOptions};
use yew_router::hooks::use_navigator;
use chrono::Local;
use common::ChatMessage;
use gloo::{console::log, events::EventListener, net::http::Request};
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{js_sys::Uint8Array, CloseEvent, Event, MessageEvent, WebSocket};
use yew::{
platform::{spawn_local, time::sleep},
prelude::*,
#[function_component]
#[allow(non_snake_case)] // Component names should be in PascalCase
pub fn Home() -> Html {
let navigator = use_navigator();
let open_onclick = {
let navigator = navigator.clone();
Callback::from(move |_| navigator.clone().unwrap().push(&Route::ChatPage))
};
use crate::components::{Alert, Message, Nav};
pub enum HomeMessage {
SubmittedMessage(String),
RecievedMessage(ChatMessage),
WsClose(CloseEvent),
WsOpen,
WsReconnect,
Authenticated,
}
#[derive(PartialEq, Eq)]
enum WsState {
Closed,
Open,
}
pub struct Home {
authenticated: bool,
messages: Vec<ChatMessage>,
chat_ws: Option<WebSocketConn>,
ws_state: WsState,
ws_reconnecting: bool,
message_container_ref: NodeRef,
}
struct WebSocketConn {
ws: WebSocket,
#[allow(dead_code)]
listeners: [EventListener; 3],
}
impl WebSocketConn {
fn send_binary(&self, msg: &[u8]) -> Result<(), JsValue> {
self.ws.send_with_u8_array(msg)
}
}
impl Drop for WebSocketConn {
fn drop(&mut self) {
let _ = self.ws.close();
}
}
impl Home {
fn connect_ws(&mut self, ctx: &Context<Self>) {
let location = web_sys::window().unwrap().location();
let ws_proto = if location.protocol().unwrap() == "https:" {
"wss"
} else {
"ws"
let login_onclick = {
let navigator = navigator.clone();
Callback::from(move |_| {
navigator
.clone()
.unwrap()
.push_with_query(
&Route::Login,
&LoginQuery {
redirect: "/".to_string(),
},
)
.unwrap();
})
};
let ws_url = format!("{}://{}/api/chat_ws", ws_proto, location.host().unwrap());
log!("Connecting to ", &ws_url);
let ws = WebSocket::new(&ws_url).unwrap();
ws.set_binary_type(web_sys::BinaryType::Arraybuffer);
let link = ctx.link().clone();
let open_ev = EventListener::new(&ws, "open", move |_event| {
link.send_message(HomeMessage::WsOpen);
});
let link = ctx.link().clone();
let close_ev = EventListener::new(&ws, "close", move |event: &Event| {
let event = event.dyn_ref::<CloseEvent>().unwrap();
link.send_message(HomeMessage::WsClose(event.clone()));
});
let link = ctx.link().clone();
let msg_ev = EventListener::new(&ws, "message", move |event: &Event| {
let event = event.dyn_ref::<MessageEvent>().unwrap();
let bytes = event.data();
let bytes = Uint8Array::new(&bytes).to_vec();
let msg = ciborium::from_reader(bytes.as_slice()).unwrap();
link.send_message(HomeMessage::RecievedMessage(msg));
});
self.chat_ws = Some(WebSocketConn {
ws,
listeners: [open_ev, close_ev, msg_ev],
});
}
fn authenticate(ctx: &Context<Self>) {
log!("Authenticating to backend");
let auth_cb = ctx.link().callback(|_: ()| HomeMessage::Authenticated);
spawn_local(async move {
let logged_in = use_async_with_options(
async move {
let location = web_sys::window().unwrap().location();
let login_url = format!(
"{}//{}/api/login",
let logged_in_url = format!(
"{}//{}/api/logged_in",
location.protocol().unwrap(),
location.host().unwrap()
);
Request::post(&login_url)
.body("")
.unwrap()
let resp = Request::get(&logged_in_url)
.send()
.await
.unwrap()
.json::<LoggedInResponse>()
.await
.unwrap();
auth_cb.emit(());
});
}
}
impl Component for Home {
type Message = HomeMessage;
type Properties = ();
fn create(ctx: &Context<Self>) -> Self {
Self::authenticate(ctx);
let mut slf = Self {
authenticated: false,
messages: Vec::new(),
chat_ws: None,
ws_state: WsState::Closed,
ws_reconnecting: false,
message_container_ref: NodeRef::default(),
};
slf.connect_ws(ctx);
slf
}
fn view(&self, ctx: &Context<Self>) -> Html {
let msg_keypress = {
ctx.link().batch_callback(|press: KeyboardEvent| {
if press.key() == "Enter" {
let input = press
.target_dyn_into::<web_sys::HtmlInputElement>()
.unwrap();
let msg = input.value();
input.set_value("");
//if on_mobile() {
// input.blur().unwrap();
//}
Some(HomeMessage::SubmittedMessage(msg))
} else {
None
}
})
};
let disable_input = self.ws_state != WsState::Open || !self.authenticated;
Result::<bool, ()>::Ok(resp.logged_in)
},
UseAsyncOptions::enable_auto(),
);
html! {
<div class="myvh-100 d-flex flex-column">
<>
<Nav />
<div class="container-fluid d-flex flex-column flex-grow-1 mt-3">
if self.ws_state != WsState::Open {
<Alert message="Connection to backend lost, trying to reconnect" />
}
if !self.authenticated {
<Alert message="Authenticating to backend" />
}
<div
ref={self.message_container_ref.clone()}
class="d-flex border rounded flex-grow-1 flex-column-reverse overflow-auto mb-3"
style="flex-basis: 0"
>
{ self.messages.iter().rev().map(|msg| html!{
<Message message={msg.clone()}/>
}
).collect::<Vec<_>>() }
</div>
<div class="bottom-0 mb-3 mt-3">
<div class="container-fluid d-flex flex-column align-items-center">
<h1>{ "Local Chat" }</h1>
<input
onkeydown={msg_keypress}
disabled={disable_input}
type="text"
id="message"
class="w-100 form-control"
type="button"
class="btn btn-primary"
value="Open Chat"
onclick={open_onclick}
/>
if !logged_in.data.unwrap_or(true) {
<input
type="button"
class="btn btn-secondary mt-2"
value="Login"
onclick={login_onclick}
/>
}
</div>
</div>
</div>
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
HomeMessage::SubmittedMessage(msg) => {
let msg = ChatMessage {
message: msg.clone(),
time: Local::now(),
};
self.messages.push(msg.clone());
let mut buf = Vec::new();
ciborium::into_writer(&msg, &mut buf).unwrap();
self.chat_ws.as_ref().unwrap().send_binary(&buf).unwrap();
true
}
HomeMessage::RecievedMessage(msg) => {
self.messages.push(msg);
true
}
HomeMessage::WsClose(close_event) => {
let code = close_event.code();
log!("WS connection closed with code", code);
if code == 4000 {
log!("Unauthorized, reauthenticating");
self.ws_state = WsState::Closed;
self.authenticated = false;
Self::authenticate(ctx);
return true;
}
if self.ws_state == WsState::Closed {
log!("Already closed");
if !self.ws_reconnecting {
log!("Reconnecting in 5s");
self.ws_reconnecting = true;
ctx.link().send_future(async move {
sleep(Duration::from_secs(5)).await;
HomeMessage::WsReconnect
});
}
false
} else {
log!("Reconnecting");
self.connect_ws(ctx);
self.ws_state = WsState::Closed;
true
}
}
HomeMessage::WsOpen => {
if self.ws_state != WsState::Open {
log!("WS connection opened");
self.messages.clear();
self.ws_state = WsState::Open;
true
} else {
false
}
}
HomeMessage::WsReconnect => {
if self.ws_state != WsState::Open {
log!("Reconnecting");
self.ws_reconnecting = false;
self.connect_ws(ctx);
}
false
}
HomeMessage::Authenticated => {
self.connect_ws(ctx);
self.authenticated = true;
true
}
}
}
fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
if let Some(message_container) = self.message_container_ref.cast::<web_sys::Element>() {
message_container.set_scroll_top(message_container.scroll_height());
}
</>
}
}

130
frontend/src/pages/login.rs Normal file
View File

@ -0,0 +1,130 @@
use crate::components::Nav;
use common::LoggedInResponse;
use gloo::{
history::{BrowserHistory, History},
net::http::Request,
};
use serde::{Deserialize, Serialize};
use yew::{platform::spawn_local, prelude::*};
use yew_hooks::use_mount;
#[derive(Serialize, Deserialize)]
pub struct LoginQuery {
pub redirect: String,
}
impl Default for LoginQuery {
fn default() -> Self {
Self {
redirect: "/".to_string(),
}
}
}
#[function_component]
#[allow(non_snake_case)] // Component names should be in PascalCase
pub fn Login() -> Html {
let disable_input = use_state(|| false);
let query_params = BrowserHistory::new()
.location()
.query::<LoginQuery>()
.unwrap_or_default();
let redirect = query_params.redirect;
let login_submitted = {
let disable_input = disable_input.clone();
let redirect = redirect.clone();
move || {
disable_input.set(true);
let redirect = redirect.clone();
spawn_local(async move {
let location = web_sys::window().unwrap().location();
let login_url = format!(
"{}//{}/api/login",
location.protocol().unwrap(),
location.host().unwrap()
);
Request::post(&login_url)
.body("")
.unwrap()
.send()
.await
.unwrap();
BrowserHistory::new().push(&redirect);
});
}
};
let password_keypress = {
let login_submitted = login_submitted.clone();
Callback::from(move |press: KeyboardEvent| {
if press.key() == "Enter" {
login_submitted();
}
})
};
{
let redirect = redirect.clone();
use_mount(move || {
spawn_local(async move {
let location = web_sys::window().unwrap().location();
let logged_in_url = format!(
"{}//{}/api/logged_in",
location.protocol().unwrap(),
location.host().unwrap()
);
let resp = Request::get(&logged_in_url)
.send()
.await
.unwrap()
.json::<LoggedInResponse>()
.await
.unwrap();
if resp.logged_in {
BrowserHistory::new().push(&redirect);
}
})
});
}
html! {
<>
<Nav />
<div
class="container-fluid d-flex flex-column align-items-center"
style="max-width: 570px"
>
<h1>{ "Login" }</h1>
<input
type="text"
id="username"
class="form-control"
placeholder="Username"
disabled={*disable_input}
/>
<input
type="password"
id="password"
class="form-control"
placeholder="Password"
disabled={*disable_input}
onkeydown={password_keypress}
/>
if !*disable_input {
<input
type="button"
value="Login"
class="btn btn-success mt-3"
onclick={Callback::from(move |_| login_submitted())}
/>
} else {
<button class="btn btn-success mt-3" disabled=true>
<span
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
/>
{ "Login" }
</button>
}
</div>
</>
}
}

View File

@ -1,4 +1,3 @@
use axum::async_trait;
use axum::body::Body;
use axum::extract::ws::{self, CloseFrame, Message, WebSocket};
use axum::extract::{Request, State, WebSocketUpgrade};
@ -6,10 +5,11 @@ use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{async_trait, Json};
use axum::{routing::get, Router};
use axum_login::{AuthManagerLayerBuilder, AuthSession, AuthUser, AuthnBackend, UserId};
use clap::Parser;
use common::ChatMessage;
use common::{ChatMessage, LoggedInResponse};
use futures::stream::SplitSink;
use futures::{SinkExt, StreamExt};
use slab::Slab;
@ -131,6 +131,20 @@ async fn login(mut auth_session: AuthSession<DummyAuthBackend>) -> Response {
StatusCode::OK.into_response()
}
async fn logout(mut auth_session: AuthSession<DummyAuthBackend>) -> Response {
if auth_session.logout().await.is_err() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
StatusCode::OK.into_response()
}
async fn logged_in(auth_session: AuthSession<DummyAuthBackend>) -> Response {
let resp = LoggedInResponse {
logged_in: auth_session.user.is_some(),
};
Json(resp).into_response()
}
#[tokio::main]
async fn main() {
let opt = Opt::parse();
@ -157,7 +171,9 @@ async fn main() {
let api = Router::new()
//.route_layer(login_required!(DummyAuthBackend))
.route("/chat_ws", get(chat_ws))
.route("/login", post(login));
.route("/login", post(login))
.route("/logout", post(logout))
.route("/logged_in", get(logged_in));
let app = Router::new()
.nest("/api", api)