preliminary port of werewolves to leptos

includes an account system and reworks of
both the client and host views
This commit is contained in:
emilis 2026-02-17 20:20:44 +00:00
parent 314e113a46
commit 4f04a8597d
No known key found for this signature in database
198 changed files with 16140 additions and 17839 deletions

3
.gitignore vendored
View File

@ -7,3 +7,6 @@ werewolves/img/icons.svg
license_headers.fish license_headers.fish
util/ util/
werewolves/Trunk-local.toml werewolves/Trunk-local.toml
werewolves-old-client/
werewolves-old-server/

2637
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,133 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = [ members = [
"werewolves", # "werewolves-old-client",
"werewolves-macros", "werewolves-macros",
"werewolves-proto", "werewolves-proto",
"werewolves-server", # "werewolves-server",
"werewolves",
"api",
] ]
[[workspace.metadata.leptos]]
watch-additional-files = ["werewolves", "api", "style", "public"]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "werewolves"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "style/main.scss"
# Assets source dir. All files found here will be copied and synchronized to site-root.
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
#
# Optional. Env: LEPTOS_ASSETS_DIR.
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
# site-addr = "192.168.1.3:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
# [Windows] for non-WSL use "npx.cmd playwright test"
# This binary name can be checked in Powershell with Get-Command npx
end2end-cmd = "npx playwright test"
end2end-dir = "end2end"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false
# The profile to use for the lib target when compiling for release
#
# Optional. Defaults to "release".
lib-profile-release = "wasm-release"
name = "werewolves"
bin-package = "werewolves"
lib-package = "werewolves"
[workspace.dependencies]
axum = "0.8.1"
axum-extra = { version = "0.12", features = ["typed-header"] }
cfg-if = "1.0.0"
console_error_panic_hook = "0.1.7"
console_log = "1.0.0"
http = "1.3.1"
log = "0.4.27"
simple_logger = "5.0.0"
thiserror = "2.0.12"
wasm-bindgen = "0.2.106"
leptos-use = { version = "0.18" }
# leptos-use = { path = "../repos/leptos-use" }
werewolves-macros = { path = "werewolves-macros" }
werewolves-proto = { path = "werewolves-proto" }
serde_json = { version = "1" }
futures = { version = "*" }
codee = { version = "0.3", features = ["msgpack_serde"] }
bytes = { version = "1.10" }
convert_case = { version = "0.11" }
fast_qr = { version = "0.13", features = ["svg"] }
anyhow = { version = "1" }
uuid = { version = "1.18" }
sqlx = { version = "0.8", features = [
"runtime-tokio",
"postgres",
"derive",
"macros",
"uuid",
"chrono",
] }
argon2 = { version = "0.5" }
async-trait = { version = "0.1" }
chrono = { version = "0.4" }
leptos = { version = "0.8.2" }
leptos_axum = { version = "0.8.2" }
leptos_meta = { version = "0.8.2" }
leptos_router = { version = "0.8.2" }
rand = { version = "*" }
serde = { version = "1.0.228" }
tokio = { version = "1.45.0", features = ["full"] }
tower = { version = "0.5.2", features = ["full"] }
tower-http = { version = "0.6.4", features = ["full"] }
api = { path = "api" }
ciborium = { version = "0.2" }
[profile.dev]
opt-level = 0
debug = "full"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1

37
api/Cargo.toml Normal file
View File

@ -0,0 +1,37 @@
[package]
name = "api"
version = "0.1.0"
edition = "2024"
[dependencies]
bytes = { version = "1.10.1", features = ["serde"] }
rand = { version = "0.9", optional = true }
serde = { workspace = true, features = ["derive"] }
uuid = { workspace = true, features = ["serde", "v4"] }
thiserror = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
sqlx = { workspace = true, optional = true }
axum = { workspace = true, optional = true, features = ["macros"] }
axum-extra = { workspace = true, optional = true, features = ["typed-header"] }
argon2 = { workspace = true, optional = true }
ciborium = { workspace = true }
async-trait = { workspace = true, optional = true }
werewolves-proto = { workspace = true }
serde_json = { workspace = true, optional = true }
futures = { workspace = true, optional = true }
log.workspace = true
leptos.workspace = true
anyhow.workspace = true
[features]
ssr = [
"dep:sqlx",
"dep:axum",
"dep:axum-extra",
"dep:argon2",
"dep:rand",
"dep:async-trait",
"dep:serde_json",
"dep:futures",
]

143
api/src/cbor.rs Normal file
View File

@ -0,0 +1,143 @@
use axum::{
body::Bytes,
extract::{FromRequest, Request, rejection::BytesRejection},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use axum_extra::headers::Mime;
use bytes::{BufMut, BytesMut};
use core::fmt::Display;
use leptos::server_fn::{
ContentType, Decodes, Encodes, Format, FormatType,
codec::{Post, Put},
};
use serde::{Serialize, de::DeserializeOwned};
const CBOR_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("application/cbor");
const PLAIN_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("text/plain");
#[must_use]
pub struct Cbor<T>(pub T);
impl<T> Cbor<T> {
pub const fn new(t: T) -> Self {
Self(t)
}
}
impl<T, S> FromRequest<S> for Cbor<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = CborRejection;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
if !cbor_content_type(req.headers()) {
return Err(CborRejection::MissingCborContentType);
}
let bytes = Bytes::from_request(req, state).await?;
Ok(Self(ciborium::from_reader::<T, _>(&*bytes)?))
}
}
impl<T> IntoResponse for Cbor<T>
where
T: Serialize,
{
fn into_response(self) -> axum::response::Response {
// Extracted into separate fn so it's only compiled once for all T.
fn make_response(buf: BytesMut, ser_result: Result<(), CborRejection>) -> Response {
match ser_result {
Ok(()) => {
([(header::CONTENT_TYPE, CBOR_CONTENT_TYPE)], buf.freeze()).into_response()
}
Err(err) => err.into_response(),
}
}
// Use a small initial capacity of 128 bytes like serde_json::to_vec
// https://docs.rs/serde_json/1.0.82/src/serde_json/ser.rs.html#2189
let mut buf = BytesMut::with_capacity(128).writer();
let res = ciborium::into_writer(&self.0, &mut buf)
.map_err(|err| CborRejection::SerdeRejection(err.to_string()));
make_response(buf.into_inner(), res)
}
}
#[derive(Debug)]
pub enum CborRejection {
MissingCborContentType,
BytesRejection(BytesRejection),
DeserializeRejection(String),
SerdeRejection(String),
}
impl<T: Display> From<ciborium::de::Error<T>> for CborRejection {
fn from(value: ciborium::de::Error<T>) -> Self {
Self::SerdeRejection(match value {
ciborium::de::Error::Io(err) => format!("i/o: {err}"),
ciborium::de::Error::Syntax(offset) => format!("syntax error at {offset}"),
ciborium::de::Error::Semantic(offset, err) => format!(
"semantic parse: {err}{}",
offset
.map(|offset| format!(" at {offset}"))
.unwrap_or_default(),
),
ciborium::de::Error::RecursionLimitExceeded => {
String::from("the input caused serde to recurse too much")
}
})
}
}
impl From<BytesRejection> for CborRejection {
fn from(value: BytesRejection) -> Self {
Self::BytesRejection(value)
}
}
impl IntoResponse for CborRejection {
fn into_response(self) -> axum::response::Response {
match self {
CborRejection::MissingCborContentType => (
StatusCode::BAD_REQUEST,
[(header::CONTENT_TYPE, PLAIN_CONTENT_TYPE)],
String::from("missing cbor content type"),
),
CborRejection::BytesRejection(err) => (
err.status(),
[(header::CONTENT_TYPE, PLAIN_CONTENT_TYPE)],
format!("bytes rejection: {}", err.body_text()),
),
CborRejection::SerdeRejection(err) => (
StatusCode::BAD_REQUEST,
[(header::CONTENT_TYPE, PLAIN_CONTENT_TYPE)],
err,
),
CborRejection::DeserializeRejection(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
[(header::CONTENT_TYPE, PLAIN_CONTENT_TYPE)],
err,
),
}
.into_response()
}
}
fn cbor_content_type(headers: &HeaderMap) -> bool {
let Some(content_type) = headers.get(header::CONTENT_TYPE) else {
return false;
};
let Ok(content_type) = content_type.to_str() else {
return false;
};
let Ok(mime) = content_type.parse::<Mime>() else {
return false;
};
mime.type_() == "application"
&& (mime.subtype() == "cbor" || mime.suffix().is_some_and(|name| name == "cbor"))
}

78
api/src/cbor_leptos.rs Normal file
View File

@ -0,0 +1,78 @@
use core::marker::PhantomData;
use std::io::Read;
use bytes::Bytes;
use leptos::{
server::codee::{Decoder, Encoder},
server_fn::{
ContentType, Decodes, Encodes, Format, FormatType,
codec::{Post, Put},
},
};
use serde::{Serialize, de::DeserializeOwned};
pub type CborPost = Post<CborEncoding>;
pub type CborPut = Put<CborEncoding>;
/// Serializes and deserializes JSON with [`serde_json`].
pub struct CborEncoding;
impl<T> Decoder<T> for CborEncoding
where
T: DeserializeOwned,
{
type Error = ciborium::de::Error<std::io::Error>;
type Encoded = [u8];
fn decode(val: &Self::Encoded) -> Result<T, Self::Error> {
ciborium::from_reader::<T, _>(val)
}
}
impl<T> Encoder<T> for CborEncoding
where
T: Serialize,
{
type Error = ciborium::ser::Error<std::io::Error>;
type Encoded = Vec<u8>;
fn encode(val: &T) -> Result<Self::Encoded, Self::Error> {
let mut encoded = vec![];
ciborium::into_writer(val, &mut encoded)?;
Ok(encoded)
}
}
impl ContentType for CborEncoding {
const CONTENT_TYPE: &'static str = "application/cbor";
}
impl FormatType for CborEncoding {
const FORMAT_TYPE: Format = Format::Binary;
}
impl<T> Encodes<T> for CborEncoding
where
T: Serialize,
{
type Error = ciborium::ser::Error<std::io::Error>;
fn encode(output: &T) -> Result<Bytes, Self::Error> {
let mut bytes = Vec::new();
ciborium::into_writer(output, &mut bytes)?;
Ok(Bytes::from_owner(bytes))
}
}
impl<T> Decodes<T> for CborEncoding
where
T: DeserializeOwned,
{
type Error = ciborium::de::Error<std::io::Error>;
fn decode(bytes: Bytes) -> Result<T, Self::Error> {
ciborium::from_reader::<T, _>(&*bytes)
}
}

389
api/src/db/game.rs Normal file
View File

@ -0,0 +1,389 @@
use core::num::NonZeroU8;
use chrono::Utc;
use futures::executor;
use sqlx::{Pool, Postgres, query, query_as};
use werewolves_proto::{
character::CharacterId,
error::GameError,
game::{Game, GameSettings},
message::{CharacterIdentity, Identification, PublicIdentity, dead::DeadChatMessage},
player::PlayerId,
};
use crate::{
ServerResult,
db::{DatabaseResult, IntoDatabaseResult},
error::{DatabaseError, ServerError},
game::{GameId, GameRecord, GameRecordState},
identity::PlayerIdentity,
user::UserId,
};
#[derive(Debug, Clone)]
pub struct GameDatabase {
pub(super) pool: Pool<Postgres>,
}
impl GameDatabase {
pub const fn new(pool: Pool<Postgres>) -> Self {
Self { pool }
}
pub async fn new_game(&self, host: UserId) -> DatabaseResult<GameRecord> {
let record = GameRecord {
host,
id: GameId::new(),
created_at: Utc::now(),
game_state: crate::game::GameRecordState::Lobby(GameSettings::default()),
};
let game_state_json = serde_json::to_value(&record.game_state)
.map_err(|err| DatabaseError::Serialization(err.to_string()))?;
query!(
r#"
insert into
games (id, host, created_at, game_state)
values
($1, $2, $3, $4)
"#,
record.id.into_uuid(),
record.host.into_uuid(),
record.created_at,
game_state_json
)
.execute(&self.pool)
.await?;
Ok(record)
}
pub async fn start_game(&self, game: GameId) -> ServerResult<GameRecord> {
let mut tx = self.pool.begin().await.into_db_result()?;
let mut record = self.get_game_inner(game, &mut *tx).await?;
let game_players = self.get_joined_players_inner(record.id, &mut *tx).await?;
match &record.game_state {
GameRecordState::Lobby(settings) => {
let idents = game_players
.iter()
.map(|p| Identification {
player_id: p.player_id,
public: p.public.clone(),
})
.collect::<Box<_>>();
settings.check_with_player_list(&idents)?;
let with_char_ids = game_players
.iter()
.map(|p| {
(
Identification {
player_id: p.player_id,
public: p.public.clone(),
},
p.character_id,
)
})
.collect::<Box<_>>();
let new_game =
Game::new_with_assigned_character_ids(&with_char_ids, settings.clone())?;
record.game_state = GameRecordState::Started(new_game);
}
GameRecordState::GameOver(_)
| GameRecordState::RoleReveal(_)
| GameRecordState::Started(_) => {
return Err(GameError::GameAlreadyStarted.into());
}
}
self.store_game_state_inner(&record, &mut *tx).await?;
tx.commit().await.into_db_result()?;
Ok(record)
}
pub async fn get_game(&self, game: GameId) -> DatabaseResult<GameRecord> {
self.get_game_inner(game, &self.pool).await
}
async fn get_game_inner<'a, E>(&self, game: GameId, executor: E) -> DatabaseResult<GameRecord>
where
E: ::sqlx::Executor<'a, Database = Postgres>,
{
let r = query!(
r#"
select
id, host, created_at, game_state
from
games
where
id = $1
"#,
game.into_uuid(),
)
.fetch_one(executor)
.await?;
Ok(GameRecord {
id: GameId::from_uuid(r.id),
host: UserId::from_uuid(r.host),
created_at: r.created_at,
game_state: serde_json::from_value(r.game_state)?,
})
}
pub async fn store_game_state(&self, record: &GameRecord) -> DatabaseResult<()> {
self.store_game_state_inner(record, &self.pool).await
}
async fn store_game_state_inner<'a, E>(
&self,
record: &GameRecord,
executor: E,
) -> DatabaseResult<()>
where
E: ::sqlx::Executor<'a, Database = Postgres>,
{
let game_state_json = serde_json::to_value(&record.game_state)
.map_err(|err| DatabaseError::Serialization(err.to_string()))?;
let game_status = match &record.game_state {
GameRecordState::Lobby(_) => "Lobby",
GameRecordState::RoleReveal(_) => "RoleReveal",
GameRecordState::Started(_) => "Started",
GameRecordState::GameOver(_) => "GameOver",
};
query!(
r#"
update
games
set
game_state = $2,
game_status = $3::game_status
where
id = $1
"#,
record.id.into_uuid(),
game_state_json,
game_status as _,
)
.execute(executor)
.await?;
Ok(())
}
pub async fn get_player_number(
&self,
game: GameId,
player: UserId,
) -> DatabaseResult<Option<NonZeroU8>> {
Ok(query!(
r#"
select
number
from
game_characters
where
game_id = $1 and player_id = $2
"#,
game.into_uuid(),
player.into_uuid(),
)
.fetch_one(&self.pool)
.await?
.number
.and_then(|n| NonZeroU8::new(n as u8)))
}
pub async fn set_player_number(
&self,
game: GameId,
player: UserId,
number: Option<NonZeroU8>,
) -> DatabaseResult<()> {
query!(
r#"
update
game_characters
set
number = $3
where
game_id = $1 and player_id = $2
"#,
game.into_uuid(),
player.into_uuid(),
number.map(|n| n.get() as i32),
)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_joined_active_game(&self, player: UserId) -> DatabaseResult<Option<GameId>> {
Ok(query!(
r#"
select
g.id
from
game_characters gc
join
games g on g.id = gc.game_id
where
gc.player_id = $1 and g.game_status in ('Lobby', 'RoleReveal', 'Started')"#,
player.into_uuid(),
)
.fetch_optional(&self.pool)
.await?
.map(|d| GameId::from_uuid(d.id)))
}
pub async fn join_game(
&self,
game: GameId,
player: UserId,
number: Option<NonZeroU8>,
) -> ServerResult<()> {
let game = self.get_game(game).await?;
if game.host == player {
return Err(GameError::CannotJoinOwnGame.into());
}
if !matches!(game.game_state, GameRecordState::Lobby(_)) {
return Err(GameError::CannotJoinStartedGame.into());
}
if let Some(active) = self.get_joined_active_game(player).await? {
return Err(ServerError::AlreadyInActiveGame(active));
};
query!(
r#"
insert into
game_characters (character_id, game_id, player_id, number)
values
($1, $2, $3, $4)
"#,
CharacterId::new().into_uuid(),
game.id.into_uuid(),
player.into_uuid(),
number.map(|n| n.get() as i32)
)
.execute(&self.pool)
.await
.into_db_result()?;
Ok(())
}
pub async fn leave_game(&self, game: GameId, player: UserId) -> ServerResult<()> {
query!(
r#"
delete from
game_characters
where
game_id = $1 and player_id = $2
"#,
game.into_uuid(),
player.into_uuid(),
)
.execute(&self.pool)
.await
.into_db_result()?;
Ok(())
}
pub async fn get_joined_players(&self, game_id: GameId) -> ServerResult<Box<[PlayerIdentity]>> {
self.get_joined_players_inner(game_id, &self.pool).await
}
async fn get_joined_players_inner<'a, E>(
&self,
game_id: GameId,
executor: E,
) -> ServerResult<Box<[PlayerIdentity]>>
where
E: ::sqlx::Executor<'a, Database = Postgres>,
{
Ok(query!(
r#"
select
gc.character_id, gc.player_id, u.pronouns,
case when u.display_name is not null then
u.display_name
else
u.username
end as name,
case when gc.number > 0 then
gc.number
else
null
end as "number: i16"
from
game_characters gc
join
users u on u.id = gc.player_id
where
gc.game_id = $1
"#,
game_id.into_uuid()
)
.fetch_all(executor)
.await
.into_db_result()?
.into_iter()
.map(|r| PlayerIdentity {
player_id: PlayerId::from_uuid(r.player_id),
character_id: CharacterId::from_uuid(r.character_id),
public: PublicIdentity {
name: r.name.unwrap_or_default(),
pronouns: r.pronouns,
number: r.number.map(|r| r as u8).and_then(NonZeroU8::new),
},
})
.collect())
}
pub async fn record_dead_chat_message(
&self,
game_id: GameId,
message: DeadChatMessage,
) -> ServerResult<()> {
let content = serde_json::to_value(&message.message).into_db_result()?;
query!(
r#"
insert into
dead_chat (message_id, game_id, created_at, message)
values
($1, $2, $3, $4)
"#,
message.id,
game_id.into_uuid(),
message.timestamp,
content,
)
.execute(&self.pool)
.await
.into_db_result()?;
Ok(())
}
pub async fn get_dead_chat(&self, game_id: GameId) -> ServerResult<Box<[DeadChatMessage]>> {
Ok(query!(
r#"
select
message_id, created_at, message
from
dead_chat
where
game_id = $1
order by
created_at asc
"#,
game_id.into_uuid(),
)
.fetch_all(&self.pool)
.await
.into_db_result()?
.into_iter()
.map(|r| -> DatabaseResult<_> {
Ok(DeadChatMessage {
id: r.message_id,
timestamp: r.created_at,
message: serde_json::from_value(r.message).into_db_result()?,
})
})
.collect::<Result<Box<[_]>, _>>()?)
}
}

60
api/src/db/mod.rs Normal file
View File

@ -0,0 +1,60 @@
pub mod game;
pub mod user;
use futures::{future::BoxFuture, stream::BoxStream};
use sqlx::{Executor, Pool, Postgres, Transaction};
use crate::{
db::{game::GameDatabase, user::UserDatabase},
error::DatabaseError,
};
pub(crate) type DatabaseResult<T> = core::result::Result<T, DatabaseError>;
trait IntoDatabaseResult<T> {
fn into_db_result(self) -> DatabaseResult<T>;
}
impl<T> IntoDatabaseResult<T> for Result<T, ::sqlx::Error> {
fn into_db_result(self) -> DatabaseResult<T> {
self.map_err(Into::<DatabaseError>::into)
}
}
impl<T> IntoDatabaseResult<T> for Result<T, ::serde_json::Error> {
fn into_db_result(self) -> DatabaseResult<T> {
self.map_err(Into::<DatabaseError>::into)
}
}
#[derive(Debug, Clone)]
pub struct Database {
pool: Pool<Postgres>,
}
impl Database {
pub const fn new(pool: Pool<Postgres>) -> Self {
Self { pool }
}
pub fn user(&self) -> UserDatabase {
UserDatabase {
pool: self.pool.clone(),
}
}
pub fn game(&self) -> GameDatabase {
GameDatabase {
pool: self.pool.clone(),
}
}
pub async fn migrate(&self) {
log::info!("running migrations");
sqlx::migrate!("../migrations")
.run(&self.pool)
.await
.expect("run migrations");
log::info!("migrations done");
}
}

432
api/src/db/user.rs Normal file
View File

@ -0,0 +1,432 @@
use argon2::{
Argon2, PasswordHash, PasswordVerifier,
password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
};
use chrono::{DateTime, TimeDelta, Utc};
use rand::distr::SampleString;
use serde::{Deserialize, Serialize};
use sqlx::{Decode, Encode, Pool, Postgres, prelude::FromRow, query, query_as};
use uuid::Uuid;
use crate::{
db::DatabaseResult,
error::{DatabaseError, ServerError},
limited::FixedLenString,
token::{self, TOKEN_LEN},
user::{Password, ProfileUpdate, Session, UserId},
};
#[derive(Debug, Clone)]
pub struct UserDatabase {
pub(super) pool: Pool<Postgres>,
}
#[derive(Debug, Clone, FromRow)]
pub struct LoginToken {
pub token: String,
pub user_id: UserId,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl LoginToken {
pub const TOKEN_LONGEVITY: TimeDelta = TimeDelta::days(30);
pub fn new(user_id: UserId) -> Self {
let created_at = Utc::now();
let expires_at = created_at
.checked_add_signed(Self::TOKEN_LONGEVITY)
.unwrap_or_else(|| {
panic!(
"could not add {} time to {created_at}",
Self::TOKEN_LONGEVITY
)
});
let token = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), token::TOKEN_LEN);
Self {
token,
user_id,
created_at,
expires_at,
}
}
}
pub enum GetUserBy<'a> {
Username(&'a str),
Id(UserId),
}
impl UserDatabase {
pub const fn new(pool: Pool<Postgres>) -> Self {
Self { pool }
}
pub async fn create_dummy_user(
&self,
display_name: &str,
pronouns: Option<String>,
) -> DatabaseResult<User> {
let username = Uuid::new_v4().to_string();
let id = Uuid::new_v4();
let password_hash = String::new();
let created_at = Utc::now();
let updated_at = created_at;
query!(
r#"
insert into
users (id, username, display_name, pronouns, password_hash,
dummy, created_at, updated_at)
values
($1, $2, $3, $4, $5, true, $6, $6)
"#,
id,
username,
display_name,
pronouns,
password_hash,
created_at
)
.execute(&self.pool)
.await?;
Ok(User {
username,
pronouns,
created_at,
updated_at,
password_hash,
id: UserId::from_uuid(id),
display_name: Some(display_name.to_string()),
})
}
pub async fn create(
&self,
username: &str,
password: &str,
pronouns: Option<String>,
) -> DatabaseResult<User> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(password.as_bytes(), &salt)?
.to_string();
let now = chrono::offset::Utc::now();
let user = User {
pronouns,
id: UserId::new(),
username: username.into(),
password_hash,
display_name: None,
created_at: now,
updated_at: now,
};
query!(
r#"insert into users
(id, username, password_hash, pronouns, created_at, updated_at)
values
($1, $2, $3, $4, $5, $6)"#,
user.id.into_uuid(),
user.username,
user.password_hash,
user.pronouns,
user.created_at,
user.updated_at
)
.execute(&self.pool)
.await
.map_err(|err| {
if let sqlx::Error::Database(db_err) = &err
&& let Some(constraint) = db_err.constraint()
&& constraint == "users_username_unique"
{
DatabaseError::UserAlreadyExists
} else {
err.into()
}
})?;
Ok(user)
}
pub async fn delete_user(&self, user: User) -> DatabaseResult<()> {
query!(
r#"
delete from
users
where
id = $1
"#,
user.id.into_uuid(),
)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn update_profile(
&self,
user_id: UserId,
update: ProfileUpdate,
) -> DatabaseResult<()> {
query!(
r#"
update
users
set
display_name = $2,
pronouns = $3
where
id = $1
"#,
user_id.into_uuid(),
update.display_name,
update.pronouns,
)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn change_password(&self, user: User, password: Password) -> DatabaseResult<User> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(password.as_bytes(), &salt)?
.to_string();
let updated_at = chrono::Utc::now();
query!(
r#"
update
users
set
password_hash = $1,
updated_at = $2
where
id = $3
"#,
password_hash,
updated_at,
user.id.into_uuid(),
)
.execute(&self.pool)
.await?;
Ok(User {
password_hash,
updated_at,
..user
})
}
pub async fn get_session(&self, token: &str) -> DatabaseResult<Session> {
let rec = query!(
r#"
select
u.id, u.username, u.display_name, u.pronouns,
u.created_at, u.updated_at, t.token,
t.created_at as token_created_at, t.expires_at
from
users u
join
login_tokens t on t.user_id = u.id
where
t.token = $1
limit 1
"#,
token,
)
.fetch_one(&self.pool)
.await?;
Ok(Session {
username: rec.username,
display_name: rec.display_name,
pronouns: rec.pronouns,
user_created_at: rec.created_at,
user_updated_at: rec.updated_at,
token_created_at: rec.token_created_at,
token_expires_at: rec.expires_at,
token: unsafe { FixedLenString::new_unchecked(rec.token) },
})
}
pub async fn get_user(&self, get_user_by: GetUserBy<'_>) -> DatabaseResult<User> {
Ok(match get_user_by {
GetUserBy::Username(username) => {
query_as!(
User,
r#"
select
id, username, password_hash,
display_name, pronouns,
created_at, updated_at
from
users
where
username = $1"#,
username
)
.fetch_one(&self.pool)
.await?
}
GetUserBy::Id(id) => {
query_as!(
User,
r#"
select
id, username, password_hash,
display_name, pronouns,
created_at, updated_at
from
users
where
id = $1"#,
id.into_uuid()
)
.fetch_one(&self.pool)
.await?
}
})
}
pub async fn verify_login(&self, username: &str, password: &str) -> Result<User, ServerError> {
let user = self.get_user(GetUserBy::Username(username)).await?;
let parsed_hash = PasswordHash::new(&user.password_hash).map_err(DatabaseError::from)?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.map_err(|err| match err {
argon2::password_hash::Error::Password => ServerError::InvalidCredentials,
err => {
let db_err: DatabaseError = err.into();
db_err.into()
}
})?;
Ok(user)
}
pub async fn replace_token(&self, token: &str) -> Result<LoginToken, ServerError> {
let user = self.check_token(token).await?;
let mut tx = self.pool.begin().await.map_err(|err| {
log::error!("begin transaction: {err}");
ServerError::InternalServerError
})?;
let new_token = LoginToken::new(user.id);
query!(
r#" insert into login_tokens
(token, user_id, created_at, expires_at)
values
($1, $2, $3, $4)"#,
new_token.token,
new_token.user_id.into_uuid(),
new_token.created_at,
new_token.expires_at
)
.execute(&mut *tx)
.await
.map_err(Into::<DatabaseError>::into)?;
query!(
r#"
delete from
login_tokens
where
token = $1
"#,
token
)
.execute(&mut *tx)
.await
.map_err(Into::<DatabaseError>::into)?;
tx.commit().await.map_err(|err| {
log::error!("commit transaction: {err}");
ServerError::InternalServerError
})?;
Ok(new_token)
}
pub async fn login(
&self,
username: &str,
password: &str,
) -> core::result::Result<Session, ServerError> {
let user = self.verify_login(username, password).await?;
let token = LoginToken::new(user.id);
query!(
r#" insert into login_tokens
(token, user_id, created_at, expires_at)
values
($1, $2, $3, $4)"#,
token.token,
token.user_id.into_uuid(),
token.created_at,
token.expires_at
)
.execute(&self.pool)
.await
.map_err(Into::<DatabaseError>::into)?;
Ok(Session {
username: user.username,
display_name: user.display_name,
pronouns: user.pronouns,
user_created_at: user.created_at,
user_updated_at: user.updated_at,
token_created_at: token.created_at,
token_expires_at: token.expires_at,
token: unsafe { FixedLenString::new_unchecked(token.token) },
})
}
pub async fn check_token(&self, token: &str) -> core::result::Result<User, ServerError> {
let token: LoginToken = query_as!(
LoginToken,
r#" select
token, user_id, created_at, expires_at
from
login_tokens
where
token = $1
and
expires_at > now()
"#,
token
)
.fetch_one(&self.pool)
.await
.map_err(Into::<DatabaseError>::into)
.map_err(|err| match err {
DatabaseError::NotFound => ServerError::ExpiredToken,
_ => err.into(),
})?;
if Utc::now() >= token.expires_at {
return Err(ServerError::ExpiredToken);
}
Ok(self.get_user(GetUserBy::Id(token.user_id)).await?)
}
}
#[derive(Debug, Clone, FromRow, Encode, Decode)]
pub struct User {
pub id: UserId,
pub username: String,
pub password_hash: String,
pub display_name: Option<String>,
pub pronouns: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

193
api/src/error.rs Normal file
View File

@ -0,0 +1,193 @@
use leptos::prelude::{ServerFnError, ServerFnErrorErr};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use werewolves_proto::error::GameError;
use crate::game::GameId;
#[derive(Debug, Clone, PartialEq, Error, Serialize, Deserialize)]
pub enum ServerError {
#[error("internal server error")]
InternalServerError,
#[error("not found")]
NotFound,
#[error("user already exists")]
UserAlreadyExists,
#[error("invalid credentials")]
InvalidCredentials,
#[error("token expired")]
ExpiredToken,
#[error("connection error")]
ConnectionError,
#[error("invalid request: {0}")]
InvalidRequest(String),
#[error("you're already in an active game: {0}")]
AlreadyInActiveGame(GameId),
#[error("{0}")]
GameError(#[from] GameError),
}
impl leptos::prelude::FromServerFnError for ServerError {
type Encoder = leptos::server_fn::codec::JsonEncoding;
fn from_server_fn_error(value: ServerFnErrorErr) -> Self {
match value {
ServerFnErrorErr::ServerError(err) => {
log::error!("server error: {err}; truncating to ServerError::InternalServerError");
ServerError::InternalServerError
}
ServerFnErrorErr::MiddlewareError(err) => {
log::error!(
"middleware error: {err}; truncating to ServerError::InternalServerError"
);
ServerError::InternalServerError
}
ServerFnErrorErr::Request(err) => {
const CONN_ERR: &str = "TypeError: NetworkError when attempting to fetch resource.";
if err == CONN_ERR {
Self::ConnectionError
} else {
Self::InvalidRequest(err)
}
}
err => {
let t = match &err {
ServerFnErrorErr::Registration(_) => "Registration",
ServerFnErrorErr::UnsupportedRequestMethod(_) => "UnsupportedRequestMethod",
ServerFnErrorErr::Request(_) => "Request",
ServerFnErrorErr::ServerError(_) => "ServerError",
ServerFnErrorErr::MiddlewareError(_) => "MiddlewareError",
ServerFnErrorErr::Deserialization(_) => "Deserialization",
ServerFnErrorErr::Serialization(_) => "Serialization",
ServerFnErrorErr::Args(_) => "Args",
ServerFnErrorErr::MissingArg(_) => "MissingArg",
ServerFnErrorErr::Response(_) => "Response",
};
Self::InvalidRequest(format!("[{t}]: {err}"))
}
}
}
}
// impl core::str::FromStr for ServerError {
// type Err = core::convert::Infallible;
// fn from_str(s: &str) -> Result<Self, Self::Err> {
// panic!("ServerError::FromStr({s})")
// }
// }
// impl From<ServerFnError> for ServerError {
// fn from(err: ServerFnError) -> Self {
// match err {
// ServerFnError::ServerError(err) => {
// log::error!("server error: {err}; truncating to ServerError::InternalServerError");
// ServerError::InternalServerError
// }
// ServerFnError::MiddlewareError(err) => {
// log::error!(
// "middleware error: {err}; truncating to ServerError::InternalServerError"
// );
// ServerError::InternalServerError
// }
// ServerFnError::Request(err) => {
// log::error!("[{err}]");
// Self::ConnectionError
// }
// err => {
// let t = match &err {
// ServerFnError::Registration(_) => "Registration",
// ServerFnError::Request(_) => "Request",
// ServerFnError::ServerError(_) => "ServerError",
// ServerFnError::MiddlewareError(_) => "MiddlewareError",
// ServerFnError::Deserialization(_) => "Deserialization",
// ServerFnError::Serialization(_) => "Serialization",
// ServerFnError::Args(_) => "Args",
// ServerFnError::MissingArg(_) => "MissingArg",
// ServerFnError::Response(_) => "Response",
// ServerFnError::WrappedServerError(_) => "WrappedServerError",
// };
// Self::InvalidRequest(format!("[{t}]: {err}"))
// }
// }
// }
// }
impl From<DatabaseError> for ServerError {
fn from(err: DatabaseError) -> Self {
match err {
DatabaseError::NotFound => ServerError::NotFound,
DatabaseError::UserAlreadyExists => ServerError::UserAlreadyExists,
#[allow(unreachable_patterns)]
_ => {
log::error!(
"converting database error into ServerError::InternalServerError: {err}"
);
ServerError::InternalServerError
}
}
}
}
#[derive(Debug, Clone, PartialEq, Error)]
pub enum DatabaseError {
#[error("user already exists")]
UserAlreadyExists,
#[error("password hashing error: {0}")]
PasswordHashError(String),
#[error("sqlx error: {0}")]
SqlxError(String),
#[error("not found")]
NotFound,
#[error("(de)serialization error: {0}")]
Serialization(String),
}
#[cfg(feature = "ssr")]
impl From<serde_json::Error> for DatabaseError {
fn from(value: serde_json::Error) -> Self {
Self::Serialization(value.to_string())
}
}
#[cfg(feature = "ssr")]
impl axum::response::IntoResponse for ServerError {
fn into_response(self) -> axum::response::Response {
use axum::{Json, http::StatusCode};
use crate::cbor::Cbor;
(
match self {
ServerError::AlreadyInActiveGame(_)
| ServerError::GameError(_)
| ServerError::InvalidCredentials
| ServerError::InvalidRequest(_)
| ServerError::UserAlreadyExists => StatusCode::BAD_REQUEST,
ServerError::NotFound => StatusCode::NOT_FOUND,
ServerError::ConnectionError | ServerError::InternalServerError => {
StatusCode::INTERNAL_SERVER_ERROR
}
ServerError::ExpiredToken => StatusCode::UNAUTHORIZED,
},
Json(self),
)
.into_response()
}
}
#[cfg(feature = "ssr")]
impl From<sqlx::Error> for DatabaseError {
fn from(err: sqlx::Error) -> Self {
match err {
sqlx::Error::RowNotFound => Self::NotFound,
_ => Self::SqlxError(err.to_string()),
}
}
}
#[cfg(feature = "ssr")]
impl From<argon2::password_hash::Error> for DatabaseError {
fn from(err: argon2::password_hash::Error) -> Self {
Self::PasswordHashError(err.to_string())
}
}

24
api/src/game.rs Normal file
View File

@ -0,0 +1,24 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use werewolves_proto::game::{Game, GameSettings, story::GameStory};
use crate::{id_impl, user::UserId};
id_impl!(GameId);
#[cfg_attr(feature = "ssr", derive(::sqlx::FromRow))]
#[derive(Debug)]
pub struct GameRecord {
pub id: GameId,
pub host: UserId,
pub created_at: DateTime<Utc>,
pub game_state: GameRecordState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GameRecordState {
Lobby(GameSettings),
RoleReveal(Game),
Started(Game),
GameOver(GameStory),
}

9
api/src/identity.rs Normal file
View File

@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
use werewolves_proto::{character::CharacterId, message::PublicIdentity, player::PlayerId};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlayerIdentity {
pub player_id: PlayerId,
pub character_id: CharacterId,
pub public: PublicIdentity,
}

111
api/src/lib.rs Normal file
View File

@ -0,0 +1,111 @@
#[cfg(feature = "ssr")]
pub mod cbor;
pub mod cbor_leptos;
#[cfg(feature = "ssr")]
pub mod db;
pub mod error;
pub mod limited;
// pub mod routes;
pub mod game;
#[cfg(feature = "ssr")]
pub mod identity;
pub mod message;
#[cfg(feature = "ssr")]
pub mod state;
pub mod token;
pub mod user;
pub type ServerResult<T> = core::result::Result<T, error::ServerError>;
#[macro_export]
macro_rules! id_impl {
($name:ident) => {
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, ::serde::Serialize, ::serde::Deserialize,
)]
pub struct $name(uuid::Uuid);
#[cfg(feature = "ssr")]
impl sqlx::TypeInfo for $name {
fn is_null(&self) -> bool {
self.0 == uuid::Uuid::nil()
}
fn name(&self) -> &str {
"uuid"
}
}
#[cfg(feature = "ssr")]
impl sqlx::Type<sqlx::Postgres> for $name {
fn type_info() -> <sqlx::Postgres as sqlx::Database>::TypeInfo {
<uuid::Uuid as sqlx::Type<sqlx::Postgres>>::type_info()
}
}
#[cfg(feature = "ssr")]
impl<'q> sqlx::Encode<'q, sqlx::Postgres> for $name {
fn encode_by_ref(
&self,
buf: &mut <sqlx::Postgres as sqlx::Database>::ArgumentBuffer<'q>,
) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
self.0.encode_by_ref(buf)
}
}
#[cfg(feature = "ssr")]
impl<'r> sqlx::Decode<'r, sqlx::Postgres> for $name {
fn decode(
value: <sqlx::Postgres as sqlx::Database>::ValueRef<'r>,
) -> Result<Self, sqlx::error::BoxDynError> {
Ok(Self(uuid::Uuid::decode(value)?))
}
}
impl From<uuid::Uuid> for $name {
fn from(value: uuid::Uuid) -> Self {
Self::from_uuid(value)
}
}
impl From<$name> for uuid::Uuid {
fn from(value: $name) -> Self {
value.into_uuid()
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl $name {
pub fn new() -> Self {
Self(uuid::Uuid::new_v4())
}
pub const fn from_uuid(uuid: uuid::Uuid) -> Self {
Self(uuid)
}
pub const fn into_uuid(self) -> uuid::Uuid {
self.0
}
}
impl core::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl core::str::FromStr for $name {
type Err = uuid::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(uuid::Uuid::from_str(s)?))
}
}
};
}

139
api/src/limited.rs Normal file
View File

@ -0,0 +1,139 @@
use core::{
fmt::Display,
ops::{Deref, RangeInclusive},
};
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FixedLenString<const LEN: usize>(String);
impl<const LEN: usize> Display for FixedLenString<LEN> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl<const LEN: usize> FixedLenString<LEN> {
pub fn new(s: String) -> Option<Self> {
(s.chars().take(LEN + 1).count() == LEN).then_some(Self(s))
}
pub unsafe fn new_unchecked(s: String) -> Self {
Self(s)
}
}
impl<const LEN: usize> Deref for FixedLenString<LEN> {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'de, const LEN: usize> Deserialize<'de> for FixedLenString<LEN> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct ExpectedLen(usize);
impl serde::de::Expected for ExpectedLen {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "a string exactly {} characters long", self.0)
}
}
<String as Deserialize>::deserialize(deserializer).and_then(|s| {
let char_count = s.chars().take(LEN.saturating_add(1)).count();
if char_count != LEN {
Err(serde::de::Error::invalid_length(
char_count,
&ExpectedLen(LEN),
))
} else {
Ok(Self(s))
}
})
}
}
impl<const LEN: usize> Serialize for FixedLenString<LEN> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.0.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ClampedString<const MIN: usize, const MAX: usize>(String);
impl<const MIN: usize, const MAX: usize> ClampedString<MIN, MAX> {
pub const MIN_LEN: usize = MIN;
pub const MAX_LEN: usize = MAX;
pub fn new(s: String) -> Result<Self, RangeInclusive<usize>> {
let str_len = s.chars().take(MAX.saturating_add(1)).count();
(str_len >= MIN && str_len <= MAX)
.then_some(Self(s))
.ok_or(MIN..=MAX)
}
pub unsafe fn new_unchecked(s: String) -> Self {
Self(s)
}
pub fn into_inner(self) -> String {
self.0
}
}
impl<const MIN: usize, const MAX: usize> Display for ClampedString<MIN, MAX> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl<const MIN: usize, const MAX: usize> Deref for ClampedString<MIN, MAX> {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'de, const MIN: usize, const MAX: usize> Deserialize<'de> for ClampedString<MIN, MAX> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct ExpectedLen(usize, usize);
impl serde::de::Expected for ExpectedLen {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"a string between {} and {} characters long",
self.0, self.1
)
}
}
<String as Deserialize>::deserialize(deserializer).and_then(|s| {
let char_count = s.chars().take(MAX.saturating_add(1)).count();
if char_count < MIN || char_count > MAX {
Err(serde::de::Error::invalid_length(
char_count,
&ExpectedLen(MIN, MAX),
))
} else {
Ok(Self(s))
}
})
}
}
impl<const MIN: usize, const MAX: usize> Serialize for ClampedString<MIN, MAX> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.0.as_str())
}
}

38
api/src/message.rs Normal file
View File

@ -0,0 +1,38 @@
use serde::{Deserialize, Serialize};
use werewolves_proto::message::{
ClientMessage, ServerToClientMessage,
host::{HostMessage, ServerToHostMessage},
};
use crate::token::TokenString;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WrappedServerMessage {
Authentication(TokenString),
HostMessage(HostMessage),
ClientMessage(ClientMessage),
}
impl From<TokenString> for WrappedServerMessage {
fn from(value: TokenString) -> Self {
Self::Authentication(value)
}
}
impl From<HostMessage> for WrappedServerMessage {
fn from(value: HostMessage) -> Self {
Self::HostMessage(value)
}
}
impl From<ClientMessage> for WrappedServerMessage {
fn from(value: ClientMessage) -> Self {
Self::ClientMessage(value)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum IntoClientResponse {
Player(ServerToClientMessage),
Host(ServerToHostMessage),
}

36
api/src/routes.rs Normal file
View File

@ -0,0 +1,36 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Options,
Get,
Post,
Put,
Delete,
Head,
Trace,
Connect,
Patch,
}
pub struct Route {
pub method: Method,
pub path: &'static str,
pub authenticated: bool,
}
impl Route {
const fn new(method: Method, path: &'static str, authenticated: bool) -> Self {
Self {
method,
path,
authenticated,
}
}
}
pub struct Routes {
pub create_user: Route,
}
pub const ROUTES: Routes = Routes {
create_user: Route::new(Method::Post, "/api/users", false),
};

13
api/src/state.rs Normal file
View File

@ -0,0 +1,13 @@
use std::{collections::HashMap, sync::Arc};
use futures::lock::Mutex;
use leptos::config::LeptosOptions;
use crate::{db::Database, game::GameId};
#[cfg_attr(feature = "ssr", derive(axum::extract::FromRef))]
#[derive(Debug, Clone)]
pub struct AppState {
pub db: Database,
pub leptos_options: LeptosOptions,
}

43
api/src/token.rs Normal file
View File

@ -0,0 +1,43 @@
use crate::{limited::FixedLenString, user::Username};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const TOKEN_LEN: usize = 0x20;
pub type TokenString = FixedLenString<TOKEN_LEN>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Token {
pub token: TokenString,
pub username: Username,
pub display_name: Option<String>,
pub pronouns: Option<String>,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl Token {
pub fn login_token(&self) -> TokenLogin {
TokenLogin(self.token.clone())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TokenLogin(pub FixedLenString<TOKEN_LEN>);
#[cfg(feature = "ssr")]
impl axum_extra::headers::authorization::Credentials for TokenLogin {
const SCHEME: &'static str = "Bearer";
fn decode(value: &axum::http::HeaderValue) -> Option<Self> {
value
.to_str()
.ok()
.and_then(|v| FixedLenString::new(v.strip_prefix("Bearer ").unwrap_or(v).to_string()))
.map(Self)
}
fn encode(&self) -> axum::http::HeaderValue {
axum::http::HeaderValue::from_str(self.0.as_str()).expect("bearer token encode")
}
}

57
api/src/user.rs Normal file
View File

@ -0,0 +1,57 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use werewolves_proto::player::PlayerId;
use crate::{limited::ClampedString, token::TokenString};
pub type Username = ClampedString<1, 0x40>;
pub type Password = ClampedString<6, 0x100>;
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct UserLogin {
pub username: Username,
pub password: Password,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ChangePassword {
pub current: Password,
pub new: Password,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct DeleteUserRequest {
pub password: Password,
}
#[cfg_attr(feature = "ssr", derive(::sqlx::FromRow))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub username: String,
pub display_name: Option<String>,
pub pronouns: Option<String>,
pub user_created_at: DateTime<Utc>,
pub user_updated_at: DateTime<Utc>,
pub token_created_at: DateTime<Utc>,
pub token_expires_at: DateTime<Utc>,
pub token: TokenString,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileUpdate {
pub display_name: Option<String>,
pub pronouns: Option<String>,
}
crate::id_impl!(UserId);
impl From<PlayerId> for UserId {
fn from(value: PlayerId) -> Self {
UserId(value.into())
}
}
impl From<UserId> for PlayerId {
fn from(value: UserId) -> Self {
PlayerId::from_uuid(value.0)
}
}

70
migrations/1_init.sql Normal file
View File

@ -0,0 +1,70 @@
drop table if exists users cascade;
create table users (
id uuid not null default gen_random_uuid() primary key,
username text not null,
display_name text,
pronouns text,
password_hash text not null,
dummy boolean not null default false,
created_at timestamp with time zone not null,
updated_at timestamp with time zone not null,
check (created_at <= updated_at)
);
drop index if exists users_username_idx;
create index users_username_idx on users (username);
drop index if exists users_username_unique;
create unique index users_username_unique on users (lower(username));
drop table if exists login_tokens cascade;
create table login_tokens (
token text not null primary key,
user_id uuid not null references users(id) on delete cascade,
created_at timestamp with time zone not null,
expires_at timestamp with time zone not null,
check (created_at < expires_at)
);
drop type if exists game_status cascade;
create type game_status as enum (
'Lobby',
'RoleReveal',
'Started',
'GameOver',
'Cancelled'
);
drop table if exists games cascade;
create table games (
id uuid not null primary key,
host uuid not null references users(id) on delete cascade,
created_at timestamp with time zone not null default now(),
game_state jsonb not null,
game_status game_status not null default 'Lobby'
);
drop table if exists game_characters cascade;
create table game_characters (
character_id uuid not null primary key,
game_id uuid not null references games(id) on delete cascade,
player_id uuid not null references users(id) on delete cascade,
number integer
);
drop index if exists game_characters_player_id_game_id_unique;
create unique index game_characters_player_id_game_id_unique on game_characters (player_id, game_id);
drop table if exists dead_chat cascade;
create table dead_chat (
message_id uuid not null primary key,
game_id uuid not null references games(id) on delete cascade,
created_at timestamp with time zone not null,
message jsonb not null
);
drop index if exists dead_chat_created_at;
create index dead_chat_created_at on dead_chat(created_at);

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 979 B

After

Width:  |  Height:  |  Size: 979 B

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

5293
public/img/icons.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 866 KiB

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 776 B

After

Width:  |  Height:  |  Size: 776 B

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

220
public/img/wolf.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

409
style/faction.scss Normal file
View File

@ -0,0 +1,409 @@
.village {
--faction-color: $village_color;
--faction-border: $village_border;
--faction-color-faint: $village_color_faint;
--faction-border-faint: $village_border_faint;
&.box {
background-color: $village_color;
border: 1px solid $village_border;
.selected:not(.faint) {
color: white;
background-color: $village_border;
}
.selected.faint {
color: white;
background-color: $village_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $village_border;
}
&.faint:not(.selected) {
border: 1px solid $village_border_faint;
background-color: $village_color_faint;
&.hover:hover {
background-color: $village_border_faint;
}
}
}
&.underline {
text-decoration: $village_color underline;
&.faint {
text-decoration: $village_color_faint underline;
}
}
&.text-color {
color: $village_border;
&.faint {
color: $village_border_faint;
}
}
}
.wolves {
--faction-color: $wolves_color;
--faction-border: $wolves_border;
--faction-color-faint: $wolves_color_faint;
--faction-border-faint: $wolves_border_faint;
&.box {
background-color: $wolves_color;
border: 1px solid $wolves_border;
.selected:not(.faint) {
color: white;
background-color: $wolves_border;
}
.selected.faint {
color: white;
background-color: $wolves_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $wolves_border;
}
&.faint:not(.selected) {
border: 1px solid $wolves_border_faint;
background-color: $wolves_color_faint;
&.hover:hover {
background-color: $wolves_border_faint;
}
}
}
&.underline {
text-decoration: $wolves_color underline;
&.faint {
text-decoration: $wolves_color_faint underline;
}
}
&.text-color {
color: $wolves_border;
&.faint {
color: $wolves_border_faint;
}
}
}
.offensive {
--faction-color: $offensive_color;
--faction-border: $offensive_border;
--faction-color-faint: $offensive_color_faint;
--faction-border-faint: $offensive_border_faint;
&.box {
background-color: $offensive_color;
border: 1px solid $offensive_border;
.selected:not(.faint) {
color: white;
background-color: $offensive_border;
}
.selected.faint {
color: white;
background-color: $offensive_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $offensive_border;
}
&.faint:not(.selected) {
border: 1px solid $offensive_border_faint;
background-color: $offensive_color_faint;
&.hover:hover {
background-color: $offensive_border_faint;
}
}
}
&.underline {
text-decoration: $offensive_color underline;
&.faint {
text-decoration: $offensive_color_faint underline;
}
}
&.text-color {
color: $offensive_border;
&.faint {
color: $offensive_border_faint;
}
}
}
.defensive {
--faction-color: $defensive_color;
--faction-border: $defensive_border;
--faction-color-faint: $defensive_color_faint;
--faction-border-faint: $defensive_border_faint;
&.box {
background-color: $defensive_color;
border: 1px solid $defensive_border;
.selected:not(.faint) {
color: white;
background-color: $defensive_border;
}
.selected.faint {
color: white;
background-color: $defensive_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $defensive_border;
}
&.faint:not(.selected) {
border: 1px solid $defensive_border_faint;
background-color: $defensive_color_faint;
&.hover:hover {
background-color: $defensive_border_faint;
}
}
}
&.underline {
text-decoration: $defensive_color underline;
&.faint {
text-decoration: $defensive_color_faint underline;
}
}
&.text-color {
color: $defensive_border;
&.faint {
color: $defensive_border_faint;
}
}
}
.intel {
--faction-color: $intel_color;
--faction-border: $intel_border;
--faction-color-faint: $intel_color_faint;
--faction-border-faint: $intel_border_faint;
&.box {
background-color: $intel_color;
border: 1px solid $intel_border;
.selected:not(.faint) {
color: white;
background-color: $intel_border;
}
.selected.faint {
color: white;
background-color: $intel_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $intel_border;
}
&.faint:not(.selected) {
border: 1px solid $intel_border_faint;
background-color: $intel_color_faint;
&.hover:hover {
background-color: $intel_border_faint;
}
}
}
&.underline {
text-decoration: $intel_color underline;
&.faint {
text-decoration: $intel_color_faint underline;
}
}
&.text-color {
color: $intel_border;
&.faint {
color: $intel_border_faint;
}
}
}
.starts-as-villager {
--faction-color: $starts_as_villager_color;
--faction-border: $starts_as_villager_border;
--faction-color-faint: $starts_as_villager_color_faint;
--faction-border-faint: $starts_as_villager_border_faint;
&.box {
background-color: $starts_as_villager_color;
border: 1px solid $starts_as_villager_border;
.selected:not(.faint) {
color: white;
background-color: $starts_as_villager_border;
}
.selected.faint {
color: white;
background-color: $starts_as_villager_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $starts_as_villager_border;
}
&.faint:not(.selected) {
border: 1px solid $starts_as_villager_border_faint;
background-color: $starts_as_villager_color_faint;
&.hover:hover {
background-color: $starts_as_villager_border_faint;
}
}
}
&.underline {
text-decoration: $starts_as_villager_color underline;
&.faint {
text-decoration: $starts_as_villager_color_faint underline;
}
}
&.text-color {
color: $starts_as_villager_border;
&.faint {
color: $starts_as_villager_border_faint;
}
}
}
.damned {
--faction-color: $damned_color;
--faction-border: $damned_border;
--faction-color-faint: $damned_color_faint;
--faction-border-faint: $damned_border_faint;
&.box {
background-color: $damned_color;
border: 1px solid $damned_border;
.selected:not(.faint) {
color: white;
background-color: $damned_border;
}
.selected.faint {
color: white;
background-color: $damned_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $damned_border;
}
&.faint:not(.selected) {
border: 1px solid $damned_border_faint;
background-color: $damned_color_faint;
&.hover:hover {
background-color: $damned_border_faint;
}
}
}
&.underline {
text-decoration: $damned_color underline;
&.faint {
text-decoration: $damned_color_faint underline;
}
}
&.text-color {
color: $damned_border;
&.faint {
color: $damned_border_faint;
}
}
}
.drunk {
--faction-color: $drunk_color;
--faction-border: $drunk_border;
--faction-color-faint: $drunk_color_faint;
--faction-border-faint: $drunk_border_faint;
&.box {
background-color: $drunk_color;
border: 1px solid $drunk_border;
.selected:not(.faint) {
color: white;
background-color: $drunk_border;
}
.selected.faint {
color: white;
background-color: $drunk_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $drunk_border;
}
&.faint:not(.selected) {
border: 1px solid $drunk_border_faint;
background-color: $drunk_color_faint;
&.hover:hover {
background-color: $drunk_border_faint;
}
}
}
&.underline {
text-decoration: $drunk_color underline;
&.faint {
text-decoration: $drunk_color_faint underline;
}
}
&.text-color {
color: $drunk_border;
&.faint {
color: $drunk_border_faint;
}
}
}

665
style/main.scss Normal file
View File

@ -0,0 +1,665 @@
@use 'sass:color';
$host_nav_height: 36px;
$host_nav_top_pad: 10px;
$host_nav_bottom_pad: 10px;
$host_nav_total_height: $host_nav_height + $host_nav_top_pad + $host_nav_bottom_pad;
$wolves_color: rgba(255, 0, 0, 0.7);
$village_color: rgba(0, 0, 255, 0.7);
$village_border: color.change($village_color, $alpha: 1.0);
$wolves_border: color.change($wolves_color, $alpha: 1.0);
$intel_color: color.adjust($village_color, $hue: -30deg);
$intel_border: color.change($intel_color, $alpha: 1.0);
$defensive_color: color.adjust($village_color, $hue: -60deg);
$defensive_border: color.change($defensive_color, $alpha: 1.0);
$offensive_color: color.adjust($village_color, $hue: 30deg);
$offensive_border: color.change($offensive_color, $alpha: 1.0);
$starts_as_villager_color: color.adjust($village_color, $hue: 60deg);
$starts_as_villager_border: color.change($starts_as_villager_color, $alpha: 1.0);
$damned_color: color.adjust($village_color, $hue: 45deg);
$damned_border: color.change($damned_color, $alpha: 1.0);
$drunk_color: color.adjust($village_color, $hue: 150deg);
$drunk_border: color.change($drunk_color, $alpha: 1.0);
$wolves_border_faint: color.change($wolves_border, $alpha: 0.3);
$village_border_faint: color.change($village_border, $alpha: 0.3);
$offensive_border_faint: color.change($offensive_border, $alpha: 0.3);
$defensive_border_faint: color.change($defensive_border, $alpha: 0.3);
$intel_border_faint: color.change($intel_border, $alpha: 0.3);
$starts_as_villager_border_faint: color.change($starts_as_villager_border, $alpha: 0.3);
$damned_border_faint: color.change($damned_border, $alpha: 0.3);
$drunk_border_faint: color.change($drunk_border, $alpha: 0.3);
$wolves_color_faint: color.change($wolves_color, $alpha: 0.1);
$village_color_faint: color.change($village_color, $alpha: 0.1);
$offensive_color_faint: color.change($offensive_color, $alpha: 0.1);
$defensive_color_faint: color.change($defensive_color, $alpha: 0.1);
$intel_color_faint: color.change($intel_color, $alpha: 0.1);
$starts_as_villager_color_faint: color.change($starts_as_villager_color, $alpha: 0.1);
$damned_color_faint: color.change($damned_color, $alpha: 0.1);
$drunk_color_faint: color.change($drunk_color, $alpha: 0.1);
@import 'faction';
@mixin flexbox() {
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
}
body {
font-family: sans-serif;
text-align: center;
background-color: black;
color: white;
}
.error_container {
position: fixed;
top: 3vh;
left: 37vw;
width: 60vw;
height: 94vh;
display: flex;
justify-content: center;
h5,
p {
margin: 0;
}
.error {
position: fixed;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 1ch;
width: fit-content;
padding: 1ch;
border: 1px solid red;
background-color: rgba(128, 0, 0, 1);
}
}
input,
select {
border: 1px solid rgba(255, 255, 255, 0.7);
background-color: rgba(255, 255, 255, 0.07);
color: white;
font-size: 1em;
&:focus {
outline: 1px solid white;
background-color: white;
color: black;
}
}
button {
background-color: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.7);
color: white;
// border: 1px solid white;
cursor: pointer;
font-size: 1em;
&:focus-visible {
outline: solid rgba(255, 0, 255, 0.5) 2px;
}
&:not(:disabled, .no-hover, .selected):hover {
color: black;
background-color: white;
}
&.selected {
color: black;
background-color: white;
}
&:disabled {
$disabled_color: rgba(255, 255, 255, 0.7);
cursor: default;
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid $disabled_color;
color: $disabled_color;
&.current {
background-color: white;
color: black;
}
}
}
.signup,
.signin {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 1ch;
font-size: 2em;
.optional {
margin-left: 1ch;
font-size: 0.5em;
font-style: italic;
color: rgba(255, 255, 255, 0.5)
}
}
nav.header {
position: sticky;
backdrop-filter: brightness(70%);
display: flex;
align-items: flex-start;
flex-direction: row;
gap: 10px;
height: $host_nav_height;
overflow-x: scroll;
scrollbar-width: none;
white-space: nowrap;
align-items: center;
font-size: 1.5em;
.username {
font-size: 0.5em;
color: rgba(255, 255, 255, 0.5);
margin-left: -1ch;
}
.right-side {
position: absolute;
right: 20px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 10px;
}
}
.click-backdrop {
z-index: 4;
background-color: rgba(0, 0, 0, 0.7);
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
background-size: cover;
}
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.7);
}
.dialog {
z-index: 5;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
position: fixed;
top: 0;
left: 0;
align-items: center;
justify-content: center;
backdrop-filter: blur(1em);
.dialog-box {
border-top: 1px solid white;
background-color: black;
font-size: 1.5em;
input {
font-size: 1em;
width: 60vw;
}
.dialog-main {
border-left: 1px solid white;
border-right: 1px solid white;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
padding-left: 30px;
padding-right: 30px;
padding-top: 10px;
padding-bottom: 40px;
gap: 5px;
color: white;
&.full {
border-bottom: 1px solid white;
}
}
}
.options {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 20px;
width: 100%;
&>button {
min-width: 4cm;
}
$close_color: rgba(255, 0, 0, 1);
.close {
border: 1px solid $close_color;
color: $close_color;
background-color: change-color($color: $close_color, $alpha: 0.1);
&:hover {
background-color: change-color($color: $close_color, $alpha: 0.4);
}
}
}
}
#change-password,
#update-profile {
.pwless-notice {
font-size: 0.7em;
word-wrap: normal;
margin: 0;
}
.dialog-box {
max-width: 70%;
}
form {
font-size: 1em;
display: flex;
flex-direction: column;
gap: 1ch;
align-items: center;
.form-fields {
display: flex;
flex-direction: column;
align-items: center;
// width: 100%;
gap: 1ch;
}
label {
user-select: none;
}
}
}
.user-settings-list {
list-style: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 1ch;
height: calc(100vh - $host_nav_total_height - 3ch);
li {
width: 60%;
margin-left: 0;
.dialog-modal>button,
&>button {
font-size: 2em;
width: 100%;
flex-grow: 1;
}
}
.logout {
margin-top: auto;
}
dialog {
font-size: 1em;
}
}
.welcome-columns {
display: flex;
flex-direction: row;
flex-wrap: wrap;
width: 89%;
gap: 3ch;
&>* {
flex-grow: 1;
}
padding: 0 5vw 0 5vw;
}
.status-bar {
background-color: black;
border: 1px solid white;
padding: 1ch;
margin: 5ch;
user-select: none;
&.disconnected {
border: 1px solid red;
background-color: rgba(255, 0, 0, 0.1);
}
}
.debug-marker {
$debug_color: rgba(255, 255, 255, 0.5);
position: relative;
margin: 0;
width: fit-content;
height: fit-content;
padding: 1px;
user-select: none;
top: 0;
left: 0;
border: 1px solid $debug_color;
color: $debug_color;
background-color: color.change($debug_color, $alpha: 0.1);
}
.game-settings {
padding: 0px 3ch 3ch 3ch;
.top-bar {
font-size: 1.5em;
padding: 1ch;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1ch;
background-color: rgba(255, 0, 255, 0.05);
width: fit-content;
}
.role-add-list {
margin: 1ch 0 1ch 0;
width: 100%;
user-select: none;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 3ch;
justify-content: space-between;
row-gap: 0.5ch;
.category {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 0.5ch;
.title {
padding: 0.5ch;
text-align: center;
min-width: 15ch;
filter: saturate(70%) grayscale(10%);
}
.roles {
min-width: 15ch;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 0.5ch;
.add-role {
padding: 0.5ch;
flex-grow: 1;
width: 100%;
}
}
}
}
.setup-slots {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1ch;
row-gap: 0.5ch;
align-items: flex-start;
}
.setup-slot-container {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 0.25ch;
align-items: flex-start;
.setup-slot {
color: rgba(255, 255, 255, 0.9);
font-size: 1.5em;
}
.aura,
.assignment {
margin: 0px;
font-size: 0.8em;
color: rgba(255, 255, 255, 0.5);
user-select: none;
display: flex;
flex-direction: row;
flex-wrap: wrap;
word-break: normal;
gap: 0.5ch;
&:hover {
color: white;
}
}
}
}
.role-title {
user-select: none;
}
.tabs {
display: flex;
flex-direction: row;
flex-wrap: wrap;
max-width: 80vw;
align-items: flex-start;
.tab {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
.detail {
margin: 0px;
font-size: 0.5em;
color: rgba(255, 255, 255, 0.5);
user-select: none;
display: flex;
flex-direction: row;
flex-wrap: wrap;
word-break: normal;
gap: 0.5ch;
&:hover {
color: white;
}
}
}
}
dialog .tab-content {
max-width: 80vw;
min-height: 40vh;
}
.toggle-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-around;
row-gap: 0.5ch;
gap: 0.5ch;
}
.player-select {
flex-grow: 1;
.identity {
justify-content: space-between;
}
}
.identity,
.identity .name,
.identity .pronouns {
word-wrap: normal;
}
.identity {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5ch;
.number {
text-shadow: yellow 1px 1px;
}
.pronouns {
font-size: 0.5em;
opacity: 50%;
align-self: center;
}
}
.lobby-players {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 0.25ch;
.player-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5ch;
align-items: center;
}
}
.player {
font-size: 1.25em;
border: 1px solid rgba(255, 255, 255, 0.5);
height: fit-content;
&.connected:not(:hover) {
background-color: rgba(0, 128, 0, 0.1);
border: 1px solid rgba(0, 64, 0, 0.7);
.number:not(.red) {
color: green;
text-shadow: none;
font-weight: bold;
}
}
.number.red {
text-shadow: rgb(64, 0, 0) 1px 1px;
font-weight: bold;
}
}
.red {
color: red;
}
.player-lobby {
margin: 5vh 15vw 5vh 15vw;
padding: 3ch;
font-size: 1.5em;
border: 1px solid rgba(128, 0, 0, 0.7);
background-color: rgba(64, 0, 0, 0.3);
&.joined {
border: 1px solid rgba(0, 128, 0, 0.7);
background-color: rgba(0, 64, 0, 0.3);
}
}
.tutorial-box {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
margin: 2ch;
width: fit-content;
&[hidden] {
display: none;
}
.hide,
.show {
align-self: flex-start;
}
.show {
&:hover {
&::after {
content: "show tutorial";
background-color: black;
color: white;
position: fixed;
font-size: 1em;
width: max-content;
height: max-content;
margin-left: 2ch;
}
}
}
.tutorial {
font-size: 1.25em;
padding: 1ch;
border: 1px solid white;
background-color: rgba(0, 0, 64, 0.3);
}
.sample {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5ch;
align-items: center;
}
.equals {
font-size: 2em;
user-select: none;
}
.ok {
user-select: none;
}
}

View File

@ -1,2 +0,0 @@
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']

1572
werewolves/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,45 +3,80 @@ name = "werewolves"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
web-sys = { version = "0.3", features = [ leptos = { workspace = true }
"HtmlTableCellElement", leptos_router = { workspace = true }
"Event", axum = { workspace = true, optional = true }
"EventTarget", console_error_panic_hook = { version = "0.1", optional = true }
"HtmlImageElement", leptos_axum = { workspace = true, optional = true }
"HtmlDivElement", leptos_meta = { workspace = true }
"HtmlSelectElement", tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
"HtmlDialogElement", wasm-bindgen = { workspace = true, optional = true }
"DomRect", getrandom = { version = "=0.3.4", optional = true }
"WheelEvent", colored = { version = "3.0", optional = true }
] } pretty_env_logger = { version = "0.5", optional = true }
wasm-bindgen = { version = "=0.2.100" } sqlx = { workspace = true, optional = true }
log = "0.4" tower-http = { workspace = true, optional = true }
rand = { version = "0.9", features = ["small_rng"] } mime-sniffer = { version = "0.1", optional = true }
getrandom = { version = "0.3", features = ["wasm_js"] } futures = { version = "0.3", optional = true }
uuid = { version = "*", features = ["js"] } wasm-logger = { version = "0.2" }
yew = { version = "0.22", features = ["csr"] } gloo = { version = "0.11" }
yew-router = "0.19" leptos-use = { workspace = true }
serde = { version = "1.0", features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { version = "1.0", optional = true } rand = { workspace = true }
gloo = "0.11" reactive_stores = { version = "0.3" }
wasm-logger = "0.2" axum-extra = { workspace = true, optional = true }
instant = { version = "0.1", features = ["wasm-bindgen"] } anyhow = { workspace = true, optional = true }
once_cell = "1" bytes = { workspace = true, optional = true }
chrono = { version = "0.4" } fast_qr = { workspace = true, optional = true }
werewolves-macros = { path = "../werewolves-macros" } log.workspace = true
werewolves-proto = { path = "../werewolves-proto" } api.workspace = true
futures = "0.3" uuid.workspace = true
wasm-bindgen-futures = "0.4" chrono.workspace = true
thiserror = { version = "2" } werewolves-macros.workspace = true
convert_case = { version = "0.10" } werewolves-proto.workspace = true
ciborium = { version = "0.2", optional = true } codee.workspace = true
chrono-humanize = { version = "0.2.3", features = ["wasmbind"] } convert_case.workspace = true
[features] [features]
default = ["cbor"] hydrate = [
# default = ["json"] "leptos/hydrate",
cbor = ["dep:ciborium"] "dep:console_error_panic_hook",
json = ["dep:serde_json"] "dep:wasm-bindgen",
"uuid/js",
"dep:getrandom",
"getrandom/wasm_js",
]
ssr = [
"dep:axum",
"dep:tokio",
"dep:leptos_axum",
"dep:colored",
"dep:pretty_env_logger",
"dep:sqlx",
"dep:tower-http",
"dep:mime-sniffer",
"dep:futures",
"dep:axum-extra",
"dep:anyhow",
"dep:bytes",
"dep:fast_qr",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"leptos-use/ssr",
"leptos-use/axum",
"api/ssr",
]
# Defines a size-optimized profile for the WASM bundle in release mode
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1
panic = "abort"

View File

@ -1,14 +0,0 @@
[build]
target = "index.html" # The index HTML file to drive the bundling process.
html_output = "index.html" # The name of the output HTML file.
release = true # Build in release mode.
dist = "dist" # The output dir for all final assets.
public_url = "/" # The public URL from which assets are to be served.
filehash = true # Whether to include hash values in the output file names.
inject_scripts = true # Whether to inject scripts (and module preloads) into the finalized output.
offline = false # Run without network access
frozen = false # Require Cargo.lock and cache are up to date
locked = false # Require Cargo.lock is up to date
# minify = "on_release" # Control minification: can be one of: never, on_release, always
minify = "always" # Control minification: can be one of: never, on_release, always
no_sri = false # Allow disabling sub-resource integrity (SRI)

File diff suppressed because one or more lines are too long

82
werewolves/build.rs Normal file
View File

@ -0,0 +1,82 @@
use std::{
fs::File,
io::{Read, Write},
};
const STYLESHEET_PATH: &str = "../style/faction.scss";
fn main() {
println!("cargo::rerun-if-changed=../style/main.scss");
println!("cargo::rerun-if-changed=../target/site/pkg/werewolves.wasm");
let mut sheet_file = File::create(STYLESHEET_PATH).unwrap();
let mut out = String::new();
for faction in [
"village",
"wolves",
"offensive",
"defensive",
"intel",
"starts-as-villager",
"damned",
"drunk",
] {
let name = faction.replace("-", "_");
out += format!(
r#"
.{faction} {{
--faction-color: ${name}_color;
--faction-border: ${name}_border;
--faction-color-faint: ${name}_color_faint;
--faction-border-faint: ${name}_border_faint;
&.box {{
background-color: ${name}_color;
border: 1px solid ${name}_border;
.selected:not(.faint) {{
color: white;
background-color: ${name}_border;
}}
.selected.faint {{
color: white;
background-color: ${name}_border_faint;
}}
&.hover:not(.selected):hover {{
color: white;
background-color: ${name}_border;
}}
&.faint:not(.selected) {{
border: 1px solid ${name}_border_faint;
background-color: ${name}_color_faint;
&.hover:hover {{
background-color: ${name}_border_faint;
}}
}}
}}
&.underline {{
text-decoration: ${name}_color underline;
&.faint {{
text-decoration: ${name}_color_faint underline;
}}
}}
&.text-color {{
color: ${name}_border;
&.faint {{
color: ${name}_border_faint;
}}
}}
}}
"#
)
.as_str();
}
sheet_file.write_all(out.as_bytes()).unwrap();
}

View File

@ -1,20 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>werewolves</title>
<link rel="icon" href="/img/wolf.svg" />
<link rel="stylesheet" href="/assets/fonts/liberation-serif.css" />
<link data-trunk rel="sass" href="index.scss" />
<link data-trunk rel="copy-dir" href="img">
<link data-trunk rel="copy-dir" href="assets">
</head>
<body>
<app></app>
<error></error>
</body>
</html>

File diff suppressed because it is too large Load Diff

130
werewolves/src/app.rs Normal file
View File

@ -0,0 +1,130 @@
pub mod pages {
werewolves_macros::include_path!("werewolves/src/app/pages");
}
pub mod components {
werewolves_macros::include_path!("werewolves/src/app/components");
pub mod input {
werewolves_macros::include_path!("werewolves/src/app/components/input");
}
}
pub mod class;
pub mod storage;
use codee::string::JsonSerdeCodec;
use gloo::storage::Storage;
use leptos::prelude::*;
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_router::{
components::{ProtectedRoute, Route, Router, Routes},
path,
};
use leptos_use::storage::{UseStorageOptions, use_local_storage, use_local_storage_with_options};
use reactive_stores::Store;
use serde::{Deserialize, Serialize};
use crate::{
app::{
components::Nav,
pages::{GamePage, Main, NotFound, Signin, Signup, UserSettings},
storage::{
LocalStorage, SessionStorage, Stored,
user::{AuthContext, AuthContextStoreFields, PasswordlessUser, UserSession, UserToken},
},
},
state::{InitOrUpdateStore, SessionState},
};
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<AutoReload options=options.clone() />
<HydrationScripts options />
<MetaTags />
</head>
<body>
<App />
</body>
</html>
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)]
pub struct TutorialSettings {
pub enabled: bool,
}
impl Default for TutorialSettings {
fn default() -> Self {
Self { enabled: true }
}
}
impl Stored for TutorialSettings {
const STORAGE_KEY: &str = "tutorial-settings";
}
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
let auth_store = Store::new(AuthContext::new());
provide_context(auth_store);
let session_store = Store::new(SessionState::new());
provide_context(session_store);
Effect::new(move || auth_store.init_or_update());
Effect::new(move || session_store.init_or_update());
let (tut_read, tut_write, _) =
use_local_storage::<TutorialSettings, JsonSerdeCodec>(TutorialSettings::STORAGE_KEY);
provide_context((tut_read, tut_write));
let is_logged_in = move || {
auth_store
.initialized()
.get()
.then_some(auth_store.session().get().is_some())
};
let not_logged_in = move || Some(auth_store.session().get().is_none());
view! {
<Stylesheet id="leptos" href="/pkg/werewolves.css" />
// sets the document title
<Title text="werewolves" />
// content for this welcome page
<Router>
<main>
<Nav />
<Routes fallback=NotFound>
<Route path=path!("/") view=Main />
<ProtectedRoute
path=path!("/signin")
view=|| view! { <Signin /> }
condition=not_logged_in
redirect_path=|| "/"
/>
<ProtectedRoute
path=path!("/signup")
view=|| view! { <Signup /> }
condition=not_logged_in
redirect_path=|| "/"
/>
<ProtectedRoute
path=path!("/user/settings")
view=UserSettings
condition=is_logged_in
redirect_path=|| "/"
/>
<Route path=path!("/games/:id") view=|| view! { <GamePage /> } />
</Routes>
</main>
</Router>
}
}

166
werewolves/src/app/class.rs Normal file
View File

@ -0,0 +1,166 @@
// Copyright (C) 2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use werewolves_proto::{
aura::AuraTitle, character::Character, game::Category, role::RoleTitle, team::Team,
};
pub trait PartialClass {
fn partial_class(&self) -> Option<&'static str>;
}
impl PartialClass for AuraTitle {
fn partial_class(&self) -> Option<&'static str> {
match self {
AuraTitle::Damned => Some("damned"),
AuraTitle::Drunk => Some("drunk"),
AuraTitle::Insane
| AuraTitle::Bloodlet
| AuraTitle::Scapegoat
| AuraTitle::RedeemableScapegoat
| AuraTitle::VindictiveScapegoat
| AuraTitle::SpitefulScapegoat
| AuraTitle::InevitableScapegoat => None,
}
}
}
pub trait Class {
fn class(&self) -> &'static str;
}
impl Class for Character {
fn class(&self) -> &'static str {
if let Team::AnyEvil = self.team() {
return "damned";
}
self.role_title().category().class()
}
}
impl Class for RoleTitle {
fn class(&self) -> &'static str {
self.category().class()
}
}
impl Class for Category {
fn class(&self) -> &'static str {
match self {
Category::Wolves => "wolves",
Category::Villager => "village",
Category::Intel => "intel",
Category::Defensive => "defensive",
Category::Offensive => "offensive",
Category::StartsAsVillager => "starts-as-villager",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Classes(Vec<String>);
impl<I> From<I> for Classes
where
I: Into<Vec<String>>,
{
fn from(value: I) -> Self {
Self(value.into())
}
}
impl core::fmt::Display for Classes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0.join(" ").as_str())
}
}
pub trait AsClasses {
fn as_classes(&self) -> Classes;
}
impl AsClasses for [&str] {
fn as_classes(&self) -> Classes {
Classes(self.iter().map(|s| s.to_string()).collect())
}
}
impl leptos::tachys::html::class::IntoClass for Classes {
type AsyncOutput = Self;
type State = (leptos::tachys::renderer::types::Element, Self);
type Cloneable = Self;
type CloneableOwned = Self;
fn html_len(&self) -> usize {
let len = self.0.len();
self.0.iter().map(|c| c.len()).sum::<usize>()
+ if len == 2 {
1
} else if len > 2 {
len - 1
} else {
0
}
}
fn to_html(self, class: &mut String) {
class.push_str(self.0.join(" ").as_str());
}
fn should_overwrite(&self) -> bool {
true
}
fn hydrate<const FROM_SERVER: bool>(
self,
el: &leptos::tachys::renderer::types::Element,
) -> Self::State {
if !FROM_SERVER {
leptos::tachys::renderer::Rndr::set_attribute(el, "class", self.to_string().as_str());
}
(el.clone(), self)
}
fn build(self, el: &leptos::tachys::renderer::types::Element) -> Self::State {
leptos::tachys::renderer::Rndr::set_attribute(el, "class", self.to_string().as_str());
(el.clone(), self)
}
fn rebuild(self, state: &mut Self::State) {
let (el, prev) = state;
if self != *prev {
leptos::tachys::renderer::Rndr::set_attribute(el, "class", self.to_string().as_str());
}
*prev = self;
}
fn into_cloneable(self) -> Self::Cloneable {
self
}
fn into_cloneable_owned(self) -> Self::CloneableOwned {
self.into()
}
fn dry_resolve(&mut self) {}
async fn resolve(self) -> Self::AsyncOutput {
self
}
fn reset(state: &mut Self::State) {
let (el, _prev) = state;
leptos::tachys::renderer::Rndr::remove_attribute(el, "class");
}
}

View File

@ -0,0 +1,8 @@
use leptos::prelude::*;
#[component]
pub fn DebugMarker() -> impl IntoView {
option_env!("LOCAL_DEBUG").map(|_| {
view! { <div class="debug-marker">"DEBUG"</div> }
})
}

View File

@ -0,0 +1,37 @@
use api::error::ServerError;
use leptos::{html::Div, prelude::*};
use leptos_use::{
UseDraggableOptions, UseDraggableReturn, core::Position, use_draggable_with_options,
};
pub trait ViewError: core::error::Error {
fn view(&self) -> impl IntoView;
}
#[component]
pub fn ErrorBox(msg: RwSignal<Option<String>>) -> impl IntoView {
let el = NodeRef::<Div>::new();
// `style` is a helper string "left: {x}px; top: {y}px;"
let UseDraggableReturn { style, .. } = use_draggable_with_options(
el,
UseDraggableOptions::default().initial_value(Position { x: 40.0, y: 40.0 }),
);
let content = move || {
msg.get().map(|text| {
view! {
<div class="error_container" hidden=move || msg.get().is_none()>
<div node_ref=el style=move || style.get() class="error">
<h5>"error"</h5>
<p>{text.to_string()}</p>
<button on:click=move |ev| {
ev.prevent_default();
msg.set(None);
}>"close"</button>
</div>
</div>
}
})
};
view! { {content} }
}

View File

@ -0,0 +1,41 @@
use leptos::prelude::*;
use werewolves_proto::message::{Identification, PublicIdentity};
#[component]
pub fn IdentityInline(ident: ReadSignal<PublicIdentity>) -> impl IntoView {
let number = move || {
ident
.read()
.number
.as_ref()
.map(|num| view! { <span class="number">{num.get()}</span> }.into_any())
.unwrap_or_else(|| {
view! { <span class="number red">"?"</span> }
.into_any()
})
};
let pronouns = move || {
ident.read().pronouns.as_ref().map(|p| {
view! { <span class="pronouns">"("{p.clone()}")"</span> }
})
};
view! {
<span class="identity">
{number} <span class="name">{move || ident.read().name.clone()}</span> {pronouns}
</span>
}
}
#[component]
pub fn IdentificationInline(ident: Identification) -> impl IntoView {
if !ident.public.name.trim().is_empty() {
return view! { <IdentityInline ident=RwSignal::new(ident.public).read_only() /> }
.into_any();
}
view! {
<span class="identity">
<span class="player-id">{ident.player_id.to_string()}</span>
</span>
}
.into_any()
}

View File

@ -0,0 +1,44 @@
use core::fmt::Display;
use leptos::prelude::*;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InputType {
#[default]
Text,
Password,
}
impl Display for InputType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InputType::Text => f.write_str("text"),
InputType::Password => f.write_str("password"),
}
}
}
#[component]
pub fn TextInput(
#[prop(optional)] label: Option<String>,
value: RwSignal<String>,
#[prop(optional)] autocomplete: bool,
#[prop(optional)] r#type: InputType,
) -> impl IntoView {
let id = Uuid::new_v4().to_string();
let label = label.map(|label| {
view! { <label for=id.clone()>{label}</label> }
});
let initial_value = move || value.read().to_string();
view! {
{label}
<input
type=r#type.to_string()
id=id
value=initial_value
on:input:target=move |ev| value.set(ev.target().value())
autocomplete=autocomplete
/>
}
}

View File

@ -0,0 +1,29 @@
use leptos::prelude::*;
use leptos_router::hooks::use_url;
#[component]
pub fn LinkButton(href: String, mut children: ChildrenFnMut) -> impl IntoView {
let url = use_url();
let link = move || {
let already_open = url.get().path() == href;
match already_open {
true => view! {
<button
class:current=already_open
disabled=already_open
class:no-hover=already_open
>
{children()}
</button>
}
.into_any(),
false => view! {
<a href=href.clone()>
<button>{children()}</button>
</a>
}
.into_any(),
}
};
view! { {link} }
}

View File

@ -0,0 +1,107 @@
use core::ops::Not;
use leptos::ev::MouseEvent;
use leptos::prelude::*;
#[derive(Debug, Clone, Default)]
pub enum DialogMode {
#[default]
Close,
Box,
ConfirmOrClose(Callback<()>),
}
#[component]
pub fn DialogModal(
text: String,
#[prop(optional)] open: Option<RwSignal<bool>>,
#[prop(optional)] button_class: String,
#[prop(default = Box::new(|| ().into_any()))] mut children: ChildrenFnMut,
#[prop(optional)] mode: DialogMode,
#[prop(default = true)] close_backdrop: bool,
) -> impl IntoView {
// use generated id if not supplied
let open = open.unwrap_or_else(|| RwSignal::new(false));
let close_cb = {
move |ev: MouseEvent| {
ev.prevent_default();
open.set(false);
}
};
let close = {
view! {
<button on:click=close_cb.clone() class="close">
"close"
</button>
}
};
let mode_buttons = match mode {
DialogMode::Box => ().into_any(),
DialogMode::Close => view! { <div class="options">{close}</div> }.into_any(),
DialogMode::ConfirmOrClose(on_confirm) => view! {
<div class="options">
<button on:click=move |_| on_confirm.run(())>{"confirm"}</button>
{close}
</div>
}
.into_any(),
};
let dialog_element: NodeRef<leptos::html::Dialog> = NodeRef::new();
let on_backdrop_click = {
let close_cb = close_cb.clone();
move |ev: MouseEvent| {
ev.prevent_default();
if !close_backdrop {
return;
}
#[cfg(feature = "hydrate")]
{
let Some(dialog) = dialog_element.get() else {
log::error!("dialog_element is None");
return;
};
let Ok(Some(dialog_box)) = dialog.query_selector(".dialog-box") else {
log::error!(".dialog-box is None");
return;
};
let rect: leptos::web_sys::DomRect = dialog_box.get_bounding_client_rect();
let is_in_dialog = rect.top() as i32 <= ev.client_y()
&& ev.client_y() <= rect.top() as i32 + rect.height() as i32
&& rect.left() as i32 <= ev.client_x()
&& ev.client_x() <= rect.left() as i32 + rect.width() as i32;
if !is_in_dialog {
close_cb(ev);
}
}
}
};
let is_open = move || open.get();
let modal = view! {
<dialog on:click=on_backdrop_click open=is_open node_ref=dialog_element>
<div class="dialog">
<div class="dialog-box">
<div class="dialog-main" class:full=matches!(mode, DialogMode::Box)>
{children()}
</div>
{mode_buttons}
</div>
</div>
</dialog>
};
let on_click = move |ev: MouseEvent| {
ev.prevent_default();
open.set(true);
};
let button = text.is_empty().not().then_some(view! {
<button class=button_class on:click=on_click>
{text.clone()}
</button>
});
view! { <div class="dialog-modal">{button} {modal}</div> }
}

View File

@ -0,0 +1,122 @@
use core::ops::Not;
use api::{error::ServerError, game::GameId, token::TokenString};
use leptos::{ev::MouseEvent, prelude::*};
use leptos_router::hooks::use_url;
use reactive_stores::Store;
use crate::app::{
components::LinkButton,
storage::user::{AuthContext, AuthContextStoreFields},
};
#[server]
pub async fn get_active_game(token: TokenString) -> Result<Option<GameId>, ServerError> {
let db = use_context::<api::state::AppState>()
.expect("no app state")
.db;
let user = db.user().check_token(&token).await?;
Ok(db.game().get_joined_active_game(user.id).await?)
}
#[server]
pub async fn new_game(token: TokenString) -> Result<GameId, ServerError> {
let db = use_context::<api::state::AppState>()
.expect("no app state")
.db;
let user = db.user().check_token(&token).await?;
Ok(db.game().new_game(user.id).await?.id)
}
#[component]
pub fn Nav() -> impl IntoView {
let auth = expect_context::<Store<AuthContext>>();
let home_button = move || view! { <LinkButton href="/".into()>"home"</LinkButton> }.into_any();
let active_game = ServerAction::<GetActiveGame>::new();
let url = use_url();
let default_view = move || {
view! {
{home_button}
<LinkButton href="/signin".into()>"signin"</LinkButton>
<LinkButton href="/signup".into()>"signup"</LinkButton>
}
};
let buttons = move || {
auth.session().get().map(|session| {
let username = session.display_name.is_some().then_some(
view! { <span class="username">"("{session.username.clone()}")"</span> },
);
let new_game_action = ServerAction::<NewGame>::new();
Effect::new(move || {
if let Some(Ok(game_id)) = new_game_action.value().get()
&& let Err(err) = gloo::utils::window()
.location()
.replace(format!("/games/{game_id}").as_str())
{
log::error!("setting window href to [/]: {err:?}");
}
});
let new_game_button = move || {
url.read()
.path()
.strip_prefix("/games/")
.map(|url| !url.trim().is_empty())
.unwrap_or_default()
.not()
.then_some({
let create_game = move |ev: MouseEvent| {
ev.prevent_default();
if let Some(session) = auth.session().get() {
new_game_action.dispatch(NewGame {
token: session.token.clone(),
});
} else {
log::error!("not signed in!");
}
};
view! { <button on:click=create_game>"new game"</button> }
})
};
let active_game_button = move || {
let Some(session) = auth.session().get() else {
return None;
};
if active_game.value().read_untracked().is_none() {
active_game.dispatch(GetActiveGame {
token: session.token,
});
}
active_game.value().get().and_then(|resp| match resp {
Ok(Some(g)) => {
let active_path = format!("/games/{g}");
Some(view! { <LinkButton href=active_path>"active game"</LinkButton> })
}
Ok(None) => None,
Err(err) => {
log::error!("getting active game: {err}");
None
}
})
};
view! {
{home_button}
<span class="display-name">{session.name().to_string()}</span>
{username}
{active_game_button}
{new_game_button}
<div class="right-side">
<LinkButton href="/user/settings".into()>{"⚙️"}</LinkButton>
</div>
}
})
};
view! {
<nav class="header">
<Show when=move || auth.session().get().is_some() fallback=default_view>
{buttons}
</Show>
</nav>
}
}

View File

@ -0,0 +1,88 @@
use core::num::NonZeroU8;
use leptos::prelude::*;
use werewolves_proto::{
message::{Identification, PublicIdentity},
player::PlayerId,
};
use crate::app::TutorialSettings;
pub trait Sample {
fn sample() -> Self;
}
const NAMES: &[&str] = &[
"Jeff",
"Baroque",
"worm",
"Fort",
"Bejeweled",
"Bedazzled",
"Comfort Sus",
];
const PRONOUNS: &[&str] = &[
"he/him",
"she/her",
"they/them",
"she/they",
"he/they",
"it/its",
];
impl Sample for PublicIdentity {
fn sample() -> Self {
PublicIdentity {
name: NAMES[rand::random_range(0..NAMES.len())].to_string(),
pronouns: Some(PRONOUNS[rand::random_range(0..PRONOUNS.len())].to_string()),
number: NonZeroU8::new(rand::random_range(1..=20)),
}
}
}
impl Sample for Identification {
fn sample() -> Self {
Identification {
player_id: PlayerId::new(),
public: PublicIdentity::sample(),
}
}
}
#[component]
pub fn TutorialBox(children: Children) -> impl IntoView {
let sample_hidden = RwSignal::new(false);
let (tut_read, _) =
expect_context::<(Signal<TutorialSettings>, WriteSignal<TutorialSettings>)>();
view! {
<div class="tutorial-box" hidden=move || !tut_read.read().enabled>
<button
class="hide"
hidden=move || sample_hidden.get()
on:click=move |_| sample_hidden.set(true)
>
"hide"
</button>
<button
class="show"
hidden=move || !sample_hidden.get()
on:click=move |_| sample_hidden.set(false)
>
"show"
</button>
<div class="tutorial" hidden=move || sample_hidden.get()>
{children()}
</div>
</div>
}
}
#[component]
pub fn Sample(children: Children) -> impl IntoView {
view! { <div class="sample">{children()}</div> }
}
#[component]
pub fn Equals() -> impl IntoView {
view! { <span class="equals">{"="}</span> }
}

View File

@ -0,0 +1,189 @@
pub mod big;
mod host;
mod player;
use api::{
cbor_leptos::CborEncoding,
message::{IntoClientResponse, WrappedServerMessage},
};
use codee::{HybridEncoder, binary::MsgpackSerdeCodec};
use leptos::prelude::*;
use leptos_router::hooks;
use leptos_use::{
ReconnectLimit, UseEventSourceOptions, UseEventSourceReturn, UseWebSocketOptions,
UseWebSocketReturn, core::ConnectionReadyState, use_event_source,
use_event_source_with_options, use_websocket, use_websocket_with_options,
};
use reactive_stores::Store;
use werewolves_proto::message::{
ClientMessage,
host::{HostMessage, ServerToHostMessage},
};
use crate::{
ConsoleLogError, LogError,
app::{
components::DebugMarker,
pages::game::{host::HostGamePage, player::PlayerGamePage},
storage::user::{AuthContext, AuthContextStoreFields},
},
state::{RedirectAfterSignin, SessionState, SessionStateStoreFields},
};
#[component]
pub fn GamePage() -> impl IntoView {
let params = hooks::use_params_map();
let auth = expect_context::<Store<AuthContext>>();
let state = expect_context::<Store<SessionState>>();
Effect::new(move || {
params.read().get("id").unwrap_or_default();
});
let UseWebSocketReturn {
ready_state,
message,
send,
open,
close,
..
} = use_websocket_with_options::<
WrappedServerMessage,
IntoClientResponse,
MsgpackSerdeCodec,
_,
_,
>(
format!(
"/api/games/{}",
params.read_untracked().get("id").unwrap_or_default()
)
.as_str(),
UseWebSocketOptions::default().reconnect_limit(ReconnectLimit::Infinite),
);
let opened = RwSignal::new(false);
let host_message = RwSignal::new(None);
let player_message = RwSignal::new(None);
let host_reply: RwSignal<Option<HostMessage>> = RwSignal::new(None);
let player_reply: RwSignal<Option<ClientMessage>> = RwSignal::new(None);
let disconnect = RwSignal::new(false);
Effect::new({
let close = close.clone();
move || {
if disconnect.get() {
close();
}
}
});
Effect::watch(
move || message.get(),
move |message, prev, _| {
if let Some(prev) = prev
&& message == prev
{
return;
}
match message.clone() {
Some(IntoClientResponse::Host(host_msg)) => {
log::debug!("got host message: {:?}", host_msg.title());
host_message.set(Some(host_msg));
}
Some(IntoClientResponse::Player(player_msg)) => {
log::debug!("got player message: {:?}", player_msg.title());
player_message.set(Some(player_msg));
}
None => {}
}
},
true,
);
Effect::new({
let send = send.clone();
move || {
let Some(reply) = host_reply.get() else {
return;
};
log::debug!("sending host message: {reply:?}");
send(&WrappedServerMessage::HostMessage(reply.clone()));
host_reply.write_untracked().take();
}
});
Effect::new({
let send = send.clone();
move || {
let Some(reply) = player_reply.get() else {
return;
};
log::debug!("sending client message: {reply:?}");
send(&WrappedServerMessage::ClientMessage(reply.clone()));
player_reply.write_untracked().take();
}
});
let status = move || match ready_state.get() {
ConnectionReadyState::Connecting => view! {
<div class="status-bar">
<DebugMarker />
<h1>"connecting..."</h1>
</div>
}
.into_any(),
ConnectionReadyState::Closing => view! {
<div class="status-bar">
<DebugMarker />
<h1>"closing"</h1>
</div>
}
.into_any(),
ConnectionReadyState::Open => {
Effect::new({
let send = send.clone();
let close = close.clone();
move || {
let o = opened.get();
if o {
return;
}
if let Some(token) = auth.token().get() {
let auth_message = WrappedServerMessage::Authentication(token.token);
log::debug!("sending auth message: {auth_message:?}");
send(&auth_message);
opened.set(true);
} else {
close();
if let Ok(href) = gloo::utils::window().location().href() {
state
.redirect_after_signin()
.set(Some(RedirectAfterSignin::new(href)));
}
#[cfg(feature = "hydrate")]
gloo::utils::window()
.location()
.set_href("/")
.console_log_err();
}
}
});
().into_any()
}
ConnectionReadyState::Closed => view! {
<div class="status-bar disconnected">
<DebugMarker />
<h1>"disconnected"</h1>
</div>
}
.into_any(),
};
let status = option_env!("LOCAL_DEBUG").map(|_| status);
view! {
{status}
<HostGamePage message=host_message.into() reply=host_reply.write_only() />
<PlayerGamePage
message=player_message.into()
reply=player_reply.write_only()
disconnect=disconnect
/>
}
}

View File

@ -0,0 +1,4 @@
use leptos::prelude::*;
#[component]
pub fn BigScreen() -> impl IntoView {}

View File

@ -0,0 +1,76 @@
werewolves_macros::include_path!("werewolves/src/app/pages/game/host");
use std::collections::HashMap;
use leptos::prelude::*;
use werewolves_proto::{
game::{Category, GameSettings},
message::{
PlayerState,
host::{HostLobbyMessage, HostMessage, ServerToHostMessage as Srv2Host},
},
};
#[component]
pub fn HostGamePage(
message: Signal<Option<Srv2Host>>,
reply: WriteSignal<Option<HostMessage>>,
) -> impl IntoView {
let settings = RwSignal::new(GameSettings::default());
let qr_mode = RwSignal::new(false);
let players: RwSignal<Box<[PlayerState]>> = RwSignal::new(Box::new([]));
let dialog_open = RwSignal::new(false);
let open_categories = RwSignal::new(
Category::ALL
.into_iter()
.map(|c| (c, false))
.collect::<HashMap<_, _>>(),
);
Effect::watch(
move || settings.get(),
move |s: &GameSettings, _, _| {
reply.set(Some(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(
s.clone(),
))));
},
false,
);
Effect::watch(
move || qr_mode.get(),
move |q, _, _| reply.set(Some(HostMessage::Lobby(HostLobbyMessage::SetQrMode(*q)))),
false,
);
let content = move || {
if let Some(message) = message.get() {
match message {
Srv2Host::Lobby {
players: p,
settings: s,
qr_mode: q,
} => {
log::info!("setting setties");
settings.set(s);
*qr_mode.write_untracked() = q;
players.set(p);
view! {
<Settings
open_categories=open_categories
settings=settings
players=players.read_only()
qr_mode=qr_mode
dialog_open=dialog_open
/>
}
.into_any()
}
_ => view! { <h2>{format!("{message:#?}")}</h2> }.into_any(),
}
} else {
().into_any()
}
};
view! { {content} }
}

View File

@ -0,0 +1,58 @@
use leptos::{ev::MouseEvent, prelude::*};
use werewolves_proto::{
message::{Identification, PlayerState, PublicIdentity},
player::PlayerId,
};
use crate::app::components::{Equals, IdentificationInline, Sample, TutorialBox};
#[component]
pub fn HostPlayerList(players: ReadSignal<Box<[PlayerState]>>) -> impl IntoView {
let players = move || {
players
.read()
.iter()
.map(|p| {
view! {
<button class="player" class:connected=p.connected>
<IdentificationInline ident=p.identification.clone() />
</button>
}
})
.collect_view()
};
let sample_no_number = {
let mut id = Identification::sample();
id.public.number.take();
id
};
view! {
<div class="lobby-players">
<div class="player-list">{players}</div>
</div>
<TutorialBox>
<Sample>
<button class="player">
<IdentificationInline ident=Identification::sample() />
</button>
<Equals />
<span class="ok">"doesn't have lobby open on phone"</span>
</Sample>
<Sample>
<button class="player connected">
<IdentificationInline ident=Identification::sample() />
</button>
<Equals />
<span class="ok">"lobby open on phone"</span>
</Sample>
<Sample>
<button class="player">
<IdentificationInline ident=sample_no_number />
</button>
<Equals />
<span class="ok">"no number assigned"</span>
</Sample>
</TutorialBox>
}
}

View File

@ -0,0 +1,330 @@
use core::ops::Not;
use std::{collections::HashMap, sync::Arc};
use convert_case::{Case, Casing};
use leptos::{ev::MouseEvent, prelude::*};
use werewolves_proto::{
aura::AuraTitle,
game::{Category, GameSettings, SetupSlot},
message::{PlayerState, host::HostMessage},
role::{Role, RoleTitle},
};
use crate::app::{
class::{AsClasses, Class, PartialClass},
components::{DialogModal, DialogMode, IdentityInline},
pages::game::host::HostPlayerList,
};
#[component]
pub fn Settings(
settings: RwSignal<GameSettings>,
players: ReadSignal<Box<[PlayerState]>>,
qr_mode: RwSignal<bool>,
dialog_open: RwSignal<bool>,
open_categories: RwSignal<HashMap<Category, bool>>,
) -> impl IntoView {
let slots = move || {
settings
.read()
.slots()
.iter()
.cloned()
.map(move |s| {
let signal = RwSignal::new(s);
Effect::watch(
move || signal.get(),
move |slot_update, _, _| settings.write().update_slot(slot_update.clone()),
false,
);
view! { <SettingsSetupSlot setup_slot=signal players=players dialog_open=dialog_open /> }
})
.collect_view()
};
let qr_mode_btn = move || {
let qr_toggle = move |ev: MouseEvent| {
ev.prevent_default();
qr_mode.set(!qr_mode.get());
};
match qr_mode.get() {
true => view! { <button on:click=qr_toggle>"disable qr mode"</button> }.into_any(),
false => view! { <button on:click=qr_toggle>"enable qr mode"</button> }.into_any(),
}
};
let roles_by_category = {
let mut r: HashMap<Category, Vec<RoleTitle>> = HashMap::new();
for role in RoleTitle::ALL {
if let Some(existing) = r.get_mut(&role.category()) {
existing.push(role);
} else {
r.insert(role.category(), vec![role]);
}
}
r
};
let ordered_keys = {
let mut k = roles_by_category.keys().copied().collect::<Box<_>>();
k.sort();
k
};
Effect::new(|| log::debug!("rendering settings"));
let categories = ordered_keys
.into_iter()
.map(|c| {
let roles_by_category = roles_by_category.clone();
let roles = move || {
open_categories
.with(|open| open.get(&c).copied().unwrap_or_default())
.then(|| {
roles_by_category
.get(&c)
.unwrap()
.iter()
.copied()
.map(|r| {
let add_role = move |ev: MouseEvent| {
ev.prevent_default();
settings.write().new_slot(r);
};
let classes =
["add-role", r.class(), "faint", "hover", "box"].as_classes();
view! {
<button class=classes on:click=add_role>
{r.to_string().to_case(Case::Title)}
</button>
}
})
.collect_view()
})
};
let toggle = move |ev: MouseEvent| {
ev.prevent_default();
let is_open = open_categories
.with_untracked(|open| open.get(&c).copied().unwrap_or_default());
open_categories.write().insert(c, !is_open);
};
let classes = ["title", c.class(), "hover", "box"].as_classes();
view! {
<div class="category">
<button class=classes on:click=toggle>
{c.to_string()}
</button>
<div class="roles">{roles}</div>
</div>
}
})
.collect_view();
view! {
<div class="game-settings">
<div class="top-bar">{qr_mode_btn}</div>
<div class="role-add-list">{categories}</div>
<div class="setup-slots">{slots}</div>
</div>
<HostPlayerList players=players />
}
}
#[component]
fn SettingsSetupSlot(
setup_slot: RwSignal<SetupSlot>,
players: ReadSignal<Box<[PlayerState]>>,
dialog_open: RwSignal<bool>,
) -> impl IntoView {
let auras = move || {
let slot = setup_slot.read();
slot.auras.is_empty().not().then(|| {
slot.auras
.iter()
.map(|a| {
view! { <span class="aura">{a.to_string().to_case(Case::Title)}</span> }
})
.collect_view()
})
};
let assigned_to = move || {
setup_slot.read().assign_to.map(|a| {
match players
.read()
.iter()
.find(|p| p.identification.player_id == a)
{
Some(player) => {
let ident = RwSignal::new(player.identification.public.clone());
view! {
<span class="assignment">
<IdentityInline ident=ident.read_only() />
</span>
}
.into_any()
}
None => {
view! { <span class="missing error">"missing player "{a.to_string()}</span> }
.into_any()
}
}
})
};
move || {
view! {
<div class="setup-slot-container">
<DialogModal
open=dialog_open
mode=DialogMode::Box
button_class=[
"setup-slot",
setup_slot.read().role.category().class(),
"faint",
"hover",
"box",
]
.as_classes()
.to_string()
text=setup_slot.read().role.title().to_string().to_case(Case::Title)
close_backdrop=true
>
<SlotSettingsDialogBody setup_slot=setup_slot players=players />
</DialogModal>
{assigned_to}
{auras}
</div>
}
}
}
#[component]
fn SlotSettingsDialogBody(
setup_slot: RwSignal<SetupSlot>,
players: ReadSignal<Box<[PlayerState]>>,
) -> impl IntoView {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum OpenTab {
#[default]
Auras,
PlayerAssignment,
}
let tab = RwSignal::new(OpenTab::default());
let tab_view = move || match tab.get() {
OpenTab::Auras => view! { <AuraSelection setup_slot=setup_slot /> }.into_any(),
OpenTab::PlayerAssignment => {
view! { <AssignmentSelection setup_slot=setup_slot players=players /> }.into_any()
}
};
move || {
let assigned_to = setup_slot
.read()
.assign_to
.as_ref()
.and_then(|pid| {
players
.read()
.iter()
.find(|p| p.identification.player_id == *pid)
.cloned()
})
.map(|p| p.identification.public)
.map(|id| view! { <IdentityInline ident=RwSignal::new(id).read_only() /> }.into_any())
.unwrap_or_else(|| view! { "none" }.into_any());
view! {
<span class=[
"role-title",
setup_slot.read().role.category().class(),
"underline",
"text-color",
]
.as_classes()>
{setup_slot.read().role.title().to_string().to_case(Case::Title)}
</span>
<div class="tabs">
<div class="tab">
<button
on:click=move |_| tab.set(OpenTab::Auras)
class:selected=move || matches!(*tab.read(), OpenTab::Auras)
>
"auras"
</button>
<span class="detail">"currently: "{setup_slot.read().auras.len()}</span>
</div>
<div class="tab">
<button
on:click=move |_| { tab.set(OpenTab::PlayerAssignment) }
class:selected=move || matches!(*tab.read(), OpenTab::PlayerAssignment)
>
"assignments"
</button>
<span class="detail">"currently: "{assigned_to}</span>
</div>
</div>
<div class="tab-content">{tab_view}</div>
}
}
}
#[component]
fn AuraSelection(setup_slot: RwSignal<SetupSlot>) -> impl IntoView {
let auras = move || {
AuraTitle::ALL
.iter()
.copied()
.filter(|a| setup_slot.read().role.title().can_assign_aura(*a))
.map(|aura| {
let toggle = move |ev: MouseEvent| {
ev.prevent_default();
let mut slot = setup_slot.write();
if slot.auras.contains(&aura) {
slot.auras.retain(|a| aura != *a);
} else {
slot.auras.push(aura);
}
};
view! {
<button
class=["faint", "box", "hover", aura.partial_class().unwrap_or_default()]
.as_classes()
class:selected=setup_slot.read().auras.contains(&aura)
on:click=toggle
>
{aura.to_string().to_case(Case::Title)}
</button>
}
})
.collect_view()
};
view! { <div class="toggle-list">{auras}</div> }
}
#[component]
fn AssignmentSelection(
setup_slot: RwSignal<SetupSlot>,
players: ReadSignal<Box<[PlayerState]>>,
) -> impl IntoView {
let players = move || {
players
.read()
.iter()
.map(|p| {
let assigned = setup_slot
.read()
.assign_to
.as_ref()
.map(|assigned_id| p.identification.player_id == *assigned_id)
.unwrap_or_default();
let ident = RwSignal::new(p.identification.public.clone());
let pid = p.identification.player_id;
let assign = move |ev: MouseEvent| {
ev.prevent_default();
setup_slot.write().assign_to = assigned.not().then_some(pid);
};
view! {
<button on:click=assign class:selected=assigned class="player-select">
<IdentityInline ident=ident.read_only() />
</button>
}
})
.collect_view()
};
view! { <div class="toggle-list">{players}</div> }
}

View File

@ -0,0 +1,125 @@
werewolves_macros::include_path!("werewolves/src/app/pages/game/player");
use convert_case::{Case, Casing};
use leptos::{ev::MouseEvent, prelude::*};
use werewolves_proto::{
message::{
ClientDeadChat, ClientMessage, ServerToClientMessage as Srv2Client, dead::DeadChatMessage,
},
role::RoleTitle,
};
use crate::{ConsoleLogError, app::components::ErrorBox};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Page {
Lobby { joined: bool },
RoleReveal { role: RoleTitle },
GameInProgress,
Sleep,
DeadChat,
}
#[component]
pub fn PlayerGamePage(
message: Signal<Option<Srv2Client>>,
reply: WriteSignal<Option<ClientMessage>>,
disconnect: RwSignal<bool>,
) -> impl IntoView {
let error = RwSignal::new(None);
let dead_chat: RwSignal<Option<Vec<DeadChatMessage>>> = RwSignal::new(None);
let page: RwSignal<Option<Page>> = RwSignal::new(None);
Effect::new(move || {
let Some(message) = message.get() else {
return;
};
match message {
Srv2Client::Disconnect => disconnect.set(true),
Srv2Client::LobbyInfo { joined, .. } => page.set(Some(Page::Lobby { joined })),
Srv2Client::GameInProgress => page.set(Some(Page::GameInProgress)),
Srv2Client::GameStart { role } => page.set(Some(Page::RoleReveal { role })),
Srv2Client::Story(game_story) => todo!("{game_story:#?}"),
Srv2Client::Update(_) => {}
Srv2Client::DeadChat(dead_chat_messages) => {
dead_chat.set(Some(dead_chat_messages.to_vec()));
page.set(Some(Page::DeadChat));
}
Srv2Client::DeadChatMessage(dead_chat_message) => {
if let Some(chat) = dead_chat.write().as_mut() {
chat.push(dead_chat_message);
page.set(Some(Page::DeadChat));
} else {
reply.set(Some(ClientMessage::DeadChat(ClientDeadChat::GetHistory)));
}
}
Srv2Client::Sleep => page.set(Some(Page::Sleep)),
Srv2Client::Reset => {
#[cfg(feature = "hydrate")]
gloo::utils::window().location().reload().console_log_err();
}
Srv2Client::Error(err) => error.set(Some(err.to_string())),
}
// match message {}
});
let content = move || {
let Some(page) = page.get() else {
return ().into_any();
};
match page {
Page::DeadChat => todo!("dead chat"),
Page::Sleep => view! { <h1>"go to sleep"</h1> }.into_any(),
Page::Lobby { joined } => {
let click = move |ev: MouseEvent| {
ev.prevent_default();
reply.set(Some(if joined {
ClientMessage::Goodbye
} else {
ClientMessage::Hello
}));
};
let text = match joined {
true => view! {
<h2>"you are in the lobby"</h2>
<p>"you're all good c:"</p>
}
.into_any(),
false => view! {
<h2>"you are not in the lobby"</h2>
<p>"join if you want to play"</p>
}
.into_any(),
};
view! {
<div class="player-lobby" class:joined=joined>
{text}
<button on:click=click>{if joined { "leave" } else { "join" }}</button>
</div>
}
.into_any()
}
Page::GameInProgress => view! {
<div class="status-box">
<h1>"this game is currently in progress"</h1>
</div>
}
.into_any(),
Page::RoleReveal { role } => view! {
<div class="role-reveal">
<span class="role-text">
"your role: "
<span class="role">{role.to_string().to_case(Case::Title)}</span>
</span>
<button on:click=move |_| {
reply.set(Some(ClientMessage::RoleAck))
}>"got it"</button>
</div>
}
.into_any(),
}
};
view! {
<ErrorBox msg=error />
{content}
}
.into_any()
}

View File

@ -0,0 +1,4 @@
use leptos::prelude::*;
#[component]
pub fn HostLobby() -> impl IntoView {}

View File

@ -0,0 +1,39 @@
use leptos::prelude::*;
use reactive_stores::Store;
use crate::app::{
pages::{Signin, Signup},
storage::user::{AuthContext, AuthContextStoreFields, UserSession},
};
#[component]
pub fn Main() -> impl IntoView {
let auth = expect_context::<Store<AuthContext>>();
move || match auth.session().get() {
Some(session) => view! { <SignedInMain session=session /> }.into_any(),
None => view! { <SignedOutMain /> }.into_any(),
}
}
#[component]
pub fn SignedInMain(session: UserSession) -> impl IntoView {
view! { <h1>"welcome "<strong>{session.name().to_string()}</strong></h1> }
}
#[component]
pub fn SignedOutMain() -> impl IntoView {
view! {
<h1>"welcome"</h1>
<div class="welcome-columns">
<div>
<h2>"create a new account"</h2>
<h4>"with just a username"</h4>
<Signup redirect=false header=false />
</div>
<div>
<h2>"sign in"</h2>
<h4>"with an existing account"</h4>
<Signin redirect=false header=false />
</div>
</div>
}
}

View File

@ -0,0 +1,13 @@
use leptos::prelude::*;
use leptos_meta::provide_meta_context;
#[component]
pub fn NotFound() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
view! {
<h2>{"not found"}</h2>
<h4>"specifically, this is the 404 page"</h4>
}
}

View File

@ -0,0 +1,128 @@
use core::ops::Not;
use api::user::{Password, Session, Username};
use chrono::Utc;
use gloo::history::History;
use leptos::{ev::MouseEvent, prelude::*};
use crate::{
app::{
components::ErrorBox,
storage::user::{AuthContext, PasswordlessUser, UserSession, UserToken},
},
auth::Signin,
};
use reactive_stores::Store;
#[component]
pub fn Signin(
#[prop(default = true)] redirect: bool,
#[prop(optional)] header: bool,
) -> impl IntoView {
let submit = ServerAction::<Signin>::new();
use crate::app::storage::user::AuthContextStoreFields;
let error: RwSignal<Option<String>> = RwSignal::new(None);
let auth = expect_context::<Store<AuthContext>>();
if redirect {
Effect::new(move || {
if let Some(sess) = auth.session().get()
&& sess.expiry > Utc::now()
&& let Err(err) = gloo::utils::window().location().replace("/")
{
log::error!("setting window href to [/]: {err:?}");
}
});
}
let user = RwSignal::new(String::new());
let pass = RwSignal::new(String::new());
Effect::new(move || {
if let Some(pwless) = auth.passwordless().get() {
user.set(pwless.username.to_string());
pass.set(pwless.password.to_string());
}
});
let click = move |ev: MouseEvent| {
ev.prevent_default();
let username = match Username::new(user.get()) {
Ok(u) => u,
Err(exp_range) => {
error.set(Some(format!(
"username must be between {} and {} characters",
exp_range.start(),
exp_range.end()
)));
return;
}
};
let password = match Password::new(pass.get()) {
Ok(p) => p,
Err(exp_range) => {
error.set(Some(format!(
"password must be between {} and {} characters",
exp_range.start(),
exp_range.end()
)));
return;
}
};
submit.dispatch(Signin { username, password });
};
Effect::new(move || match submit.value().get() {
Some(Ok(sess)) => {
log::info!("user logged in");
auth.token().write().replace(UserToken {
token: sess.token.clone(),
expiry: sess.token_expires_at,
});
auth.session().write().replace(sess.into());
if let Err(err) = gloo::utils::window().location().set_href("/") {
log::error!("setting window href to [/]: {err:?}");
submit.clear();
}
}
Some(Err(err)) => error.set(Some(err.to_string())),
None => {}
});
let submit = move || {
view! { <button on:click=click>"sign in"</button> }
};
let user_input = move || {
view! {
<input
type="text"
id="username"
value=move || user.get()
on:input:target=move |ev| user.set(ev.target().value())
autocomplete=false
/>
}
};
let pass_input = move || {
view! {
<input
type="password"
id="password"
value=move || pass.get()
on:input:target=move |ev| pass.set(ev.target().value())
autocomplete=false
/>
}
};
view! {
<form class="signin">
<h3 hidden=header.not()>"sign in"</h3>
<label for="username">"username"</label>
{user_input}
<label for="password">"password"</label>
{pass_input}
{submit}
<ErrorBox msg=error />
</form>
}
}

View File

@ -0,0 +1,157 @@
use core::ops::Not;
use api::{
cbor_leptos::CborPost,
error::ServerError,
user::{Password, Session, Username},
};
use chrono::Utc;
use gloo::history::History;
use leptos::{
ev::{Event, MouseEvent},
prelude::*,
};
use leptos_meta::*;
use rand::distr::SampleString;
use reactive_stores::Store;
use crate::{
app::{
components::{ErrorBox, input::TextInput},
storage::{
LocalStorage,
user::{AuthContext, AuthContextStoreFields, PasswordlessUser, UserSession, UserToken},
},
},
auth::Signin,
};
#[leptos::server(CreateUser, input = CborPost)]
pub async fn create_user(
username: Username,
password: Password,
pronouns: Option<String>,
) -> core::result::Result<api::user::UserId, ServerError> {
let user = use_context::<api::state::AppState>()
.expect("no app state")
.db
.user()
.create(&username, &password, pronouns)
.await
.map_err(Into::<ServerError>::into)?;
log::info!("created user: {user:?}");
Ok(user.id)
}
#[component]
pub fn Signup(
#[prop(default = true)] redirect: bool,
#[prop(default = true)] header: bool,
) -> impl IntoView {
move || {
let auth = expect_context::<Store<AuthContext>>();
let user = RwSignal::new(String::new());
let pronouns = RwSignal::new(String::new());
if redirect {
Effect::new(move || {
if let Some(sess) = auth.session().get()
&& sess.expiry > Utc::now()
&& let Err(err) = gloo::utils::window().location().replace("/")
{
log::error!("setting window href to [/]: {err:?}");
}
});
}
let submit = ServerAction::<CreateUser>::new();
let error = RwSignal::new(None);
let generated_password = Password::new(
rand::distr::Alphanumeric.sample_string(&mut rand::rng(), Password::MAX_LEN),
)
.expect("generate password max length");
let click = {
let user = user.read_only();
let password = generated_password.clone();
move |ev: MouseEvent| {
ev.prevent_default();
let username = match Username::new(user.get().clone()) {
Ok(u) => u,
Err(exp_range) => {
error.set(Some(format!(
"username must be between {} and {} characters",
exp_range.start(),
exp_range.end()
)));
return;
}
};
let pronouns = {
let p = pronouns.get();
p.trim().is_empty().not().then_some(p.trim().to_string())
};
submit.dispatch(CreateUser {
username,
pronouns,
password: password.clone(),
});
}
};
let login_action = ServerAction::<Signin>::new();
let result = move || {
let value = submit.value().get()?;
match value {
Ok(_) => {
let creds = PasswordlessUser {
username: unsafe { Username::new_unchecked(user.get_untracked()) },
password: generated_password.clone(),
};
auth.passwordless().write().replace(creds.clone());
login_action.dispatch(Signin {
username: creds.username,
password: creds.password,
});
Some(view! {
<h2>"created user successfully"</h2>
<a href="/signin">
<button>"sign in"</button>
</a>
})
}
Err(err) => {
error.write().replace(err.to_string());
None
}
}
};
Effect::new(move || match login_action.value().get() {
Some(Ok(sess)) => {
auth.token().set(Some(UserToken {
token: sess.token.clone(),
expiry: sess.token_expires_at,
}));
auth.session().set(Some(sess.into()));
}
Some(Err(err)) => {
error.set(Some(format!("error signing in after signup: {err}")));
}
None => {}
});
view! {
<form class="signup">
<h3 hidden=header.not()>"sign up"</h3>
<TextInput value=user label="username".into() />
<label for="pronouns">"pronouns"<span class="optional">"(optional)"</span></label>
<input id="pronouns" on:input:target=move |ev| pronouns.set(ev.target().value()) />
{{
view! { <button on:click=click.clone()>"sign up"</button> }
}}
{result}
<ErrorBox msg=error />
</form>
}
}
}

View File

@ -0,0 +1,44 @@
werewolves_macros::include_path!("werewolves/src/app/pages/user_settings");
use leptos::{ev::MouseEvent, prelude::*};
use reactive_stores::Store;
use crate::app::{
TutorialSettings,
storage::user::{AuthContext, AuthContextStoreFields},
};
#[component]
pub fn UserSettings() -> impl IntoView {
let auth = expect_context::<Store<AuthContext>>();
let log_out = {
let click = move |e: MouseEvent| {
e.prevent_default();
auth.session().set(None);
auth.token().set(None);
};
view! { <button on:click=click>"log out"</button> }
};
let (tut_read, tut_write) =
expect_context::<(Signal<TutorialSettings>, WriteSignal<TutorialSettings>)>();
let tutorial_toggle_button = move || {
match tut_read.read().enabled {
true => view! { <button on:click=move |_| tut_write.write().enabled = false>"disable tutorials"</button> }
.into_any(),
false => view! {
<button on:click=move |_| tut_write.write().enabled = true>"enable tutorials"</button>
}.into_any(),
}
};
view! {
<ul class="user-settings-list">
<li>
<UpdateProfileButton />
</li>
<li>
<ChangePasswordButton />
</li>
<li>{tutorial_toggle_button}</li>
<li class="logout">{log_out}</li>
</ul>
}
}

View File

@ -0,0 +1,144 @@
use api::{cbor_leptos::CborPost, error::ServerError, user::Password};
use leptos::{ev::MouseEvent, prelude::*};
use reactive_stores::Store;
use crate::app::{
components::{DialogModal, ErrorBox},
storage::user::{AuthContext, AuthContextStoreFields},
};
#[server(input = CborPost)]
pub async fn change_password(
token: String,
current: Password,
new: Password,
) -> Result<(), ServerError> {
let db = use_context::<api::state::AppState>()
.expect("no app state")
.db
.user();
let user = db.check_token(token.as_str()).await?;
db.verify_login(&user.username, &current).await?;
db.change_password(user.clone(), new).await?;
log::info!("changed password for {}", user.username);
Ok(())
}
#[component]
pub fn ChangePasswordButton() -> impl IntoView {
let auth = expect_context::<Store<AuthContext>>();
let error = RwSignal::new(None);
let action = ServerAction::<ChangePassword>::new();
let current = RwSignal::new(String::new());
Effect::new(move || {
if let Some(pwless) = auth.passwordless().get() {
current.set(pwless.password.to_string());
}
});
let new = RwSignal::new(String::new());
let new_confirm = RwSignal::new(String::new());
let submit_click = move |ev: MouseEvent| {
ev.prevent_default();
error.set(None);
let current = match Password::new(current.get()) {
Ok(c) => c,
Err(exp_range) => {
error.set(Some(format!(
"current password must be between {} and {} characters",
exp_range.start(),
exp_range.end()
)));
return;
}
};
if new.get() != new_confirm.get() {
error.set(Some("password confirmation does not match".into()));
return;
}
let new = match Password::new(new.get()) {
Ok(c) => c,
Err(exp_range) => {
error.set(Some(format!(
"new password must be between {} and {} characters",
exp_range.start(),
exp_range.end()
)));
return;
}
};
action.dispatch(ChangePassword {
new,
current,
token: auth
.token()
.get()
.map(|t| t.token.to_string())
.unwrap_or_default(),
});
log::info!("in submit_click");
};
Effect::new(move || {
let resp = action.value().get();
if let Some(resp) = resp.as_ref() {
log::debug!("response: {resp:?}");
}
match resp {
Some(Ok(_)) => {
log::info!("password changed!");
auth.passwordless().set(None);
}
Some(Err(err)) => {
error.set(Some(err.to_string()));
}
None => {}
}
});
let pwless_info = move || {
auth.passwordless().get().map(|_| {
view! {
<p class="pwless-notice">
<strong>"notice:"</strong>
<em>
" once you change your password for the first time, this site will no longer"
" autofill the signin/password fields for you, as you (or your browser) "
"will be responsible for managing that infomation"
</em>
</p>
}
})
};
view! {
<DialogModal text="change password".into() close_backdrop=false>
<ErrorBox msg=error />
<form>
<div class="form-fields">
<label for="current-password">"current password"</label>
<input
id="current-password"
type="password"
autocomplete="current-password"
on:input:target=move |ev| current.set(ev.target().value())
value=move || current.get()
/>
<label for="new-password">"new password"</label>
<input
id="new-password"
type="password"
autocomplete="new-password"
on:input:target=move |ev| new.set(ev.target().value())
/>
<label for="confirm-new-password">"confirm new password"</label>
<input
id="confirm-new-password"
type="password"
autocomplete="new-password"
on:input:target=move |ev| new_confirm.set(ev.target().value())
/>
</div>
{pwless_info}
<button on:click=submit_click>"submit"</button>
</form>
</DialogModal>
}
}

View File

@ -0,0 +1,111 @@
use core::ops::Not;
use api::{
cbor_leptos::CborPost,
error::ServerError,
token::TokenString,
user::{ProfileUpdate, Session},
};
use leptos::{ev::MouseEvent, prelude::*};
use reactive_stores::Store;
use crate::app::{
components::{DialogModal, ErrorBox},
storage::user::{AuthContext, AuthContextStoreFields},
};
#[server(input = CborPost)]
pub async fn update_profile(token: TokenString, update: ProfileUpdate) -> Result<(), ServerError> {
let db = use_context::<api::state::AppState>()
.expect("no app state")
.db
.user();
let user = db.check_token(&token).await?;
db.update_profile(user.id, update).await?;
Ok(())
}
fn trim_or_none(s: String) -> Option<String> {
s.trim().is_empty().not().then_some(s.trim().to_string())
}
#[component]
pub fn UpdateProfileButton() -> impl IntoView {
let auth = expect_context::<Store<AuthContext>>();
let error = RwSignal::new(None);
let display_name = RwSignal::new(String::new());
let pronouns = RwSignal::new(String::new());
Effect::new(move || {
if let Some(session) = auth.session().get() {
if let Some(d) = session.display_name {
display_name.set(d);
}
if let Some(p) = session.pronouns {
pronouns.set(p);
}
}
});
let update_req = RwSignal::new(None);
let update_action = ServerAction::<UpdateProfile>::new();
let submit = move |ev: MouseEvent| {
ev.prevent_default();
update_action.clear();
let Some(session) = auth.session().get() else {
return;
};
let update = UpdateProfile {
token: session.token.clone(),
update: ProfileUpdate {
display_name: trim_or_none(display_name.get()),
pronouns: trim_or_none(pronouns.get()),
},
};
update_req.set(Some(update.clone()));
update_action.dispatch(update.clone());
};
Effect::new(move || match update_action.value().get() {
Some(Ok(_)) => {
if let Some(input) = update_req.get()
&& let Some(sess) = auth.session().write().as_mut()
{
sess.display_name = input.update.display_name;
sess.pronouns = input.update.pronouns;
}
}
Some(Err(err)) => error.set(Some(err.to_string())),
None => {}
});
let on_success = move || {
update_action
.value()
.get()
.map(|v| v.is_ok())
.unwrap_or_default()
.then_some(view! { <p>"profile updated"</p> })
};
view! {
<DialogModal text="update profile".into() close_backdrop=false>
<ErrorBox msg=error />
<form>
<label for="display-name">"display name"</label>
<input
id="display-name"
type="text"
autocomplete="nickname"
value=move || display_name.get()
on:input:target=move |ev| display_name.set(ev.target().value())
/>
<label for="pronouns">"pronouns"</label>
<input
id="pronouns"
type="text"
autocomplete="pronouns"
value=move || pronouns.get()
on:input:target=move |ev| pronouns.set(ev.target().value())
/>
<button on:click=submit>"update"</button>
{on_success}
</form>
</DialogModal>
}
}

View File

@ -0,0 +1,70 @@
pub mod user;
use core::fmt::Debug;
use gloo::storage::Storage;
use leptos::{
prelude::{ArcReadSignal, ArcWriteSignal, Effect, Signal, WriteSignal},
server::codee::string::JsonSerdeCodec,
};
use serde::{Serialize, de::DeserializeOwned};
pub trait Stored:
Debug + Clone + PartialEq + Send + Sync + Serialize + DeserializeOwned + 'static
{
const STORAGE_KEY: &str;
}
pub trait DeleteFn: Fn() + Clone + Send + Sync {}
impl<F: Fn() + Clone + Send + Sync> DeleteFn for F {}
pub trait LocalStorage: Stored {
fn from_local_storage() -> Option<Self> {
gloo::storage::LocalStorage::get(Self::STORAGE_KEY).ok()
}
fn store_in_local_storage(&self) {
gloo::storage::LocalStorage::set(Self::STORAGE_KEY, self)
.expect(format!("storing object at [{}] in local storage", Self::STORAGE_KEY).as_str())
}
fn delete_from_local_storage() {
gloo::storage::LocalStorage::delete(Self::STORAGE_KEY);
}
fn set_in_local_storage(value: Option<Self>) {
gloo::storage::LocalStorage::set(Self::STORAGE_KEY, value)
.expect(format!("storing object at [{}] in local storage", Self::STORAGE_KEY).as_str())
}
}
/// Uses session storage which ends when the tab closes
pub trait SessionStorage: Stored {
fn from_session_storage() -> Option<Self> {
gloo::storage::SessionStorage::get(Self::STORAGE_KEY).ok()
}
fn store_in_session_storage(&self) {
gloo::storage::SessionStorage::set(Self::STORAGE_KEY, self).expect(
format!(
"storing object at [{}] in session storage",
Self::STORAGE_KEY
)
.as_str(),
)
}
fn delete_from_session_storage() {
gloo::storage::SessionStorage::delete(Self::STORAGE_KEY);
}
fn set_in_session_storage(value: Option<Self>) {
gloo::storage::SessionStorage::set(Self::STORAGE_KEY, value).expect(
format!(
"storing object at [{}] in session storage",
Self::STORAGE_KEY
)
.as_str(),
)
}
}
pub trait CookiesStorage: Stored {
fn from_cookie() -> (Signal<Option<Self>>, WriteSignal<Option<Self>>) {
leptos_use::use_cookie::<Self, JsonSerdeCodec>(Self::STORAGE_KEY)
}
}

View File

@ -0,0 +1,121 @@
use api::{
limited::FixedLenString,
token::TOKEN_LEN,
user::{Password, Session, Username},
};
use chrono::{DateTime, Utc};
use leptos::{
prelude::{Action, ArcRwSignal, Get, GetUntracked, Set},
server::codee::string::JsonSerdeCodec,
};
use reactive_stores::{Field, Patch, Store};
use serde::{Deserialize, Serialize};
use crate::{
app::storage::{LocalStorage, SessionStorage, Stored},
state::InitOrUpdateStore,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Store)]
pub struct UserToken {
pub token: FixedLenString<TOKEN_LEN>,
pub expiry: DateTime<Utc>,
}
impl Stored for UserToken {
const STORAGE_KEY: &str = "user-login-token";
}
impl LocalStorage for UserToken {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UserSession {
pub expiry: DateTime<Utc>,
pub username: String,
pub display_name: Option<String>,
pub pronouns: Option<String>,
pub token: FixedLenString<TOKEN_LEN>,
}
impl From<Session> for UserSession {
fn from(value: Session) -> Self {
Self {
expiry: value.token_expires_at,
username: value.username,
display_name: value.display_name,
pronouns: value.pronouns,
token: value.token,
}
}
}
impl UserSession {
pub fn name(&self) -> &str {
self.display_name
.as_ref()
.unwrap_or(&self.username)
.as_str()
}
}
impl Stored for UserSession {
const STORAGE_KEY: &str = "user-session";
}
impl SessionStorage for UserSession {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PasswordlessUser {
pub username: Username,
pub password: Password,
}
impl Stored for PasswordlessUser {
const STORAGE_KEY: &str = "passwordless-user-details";
}
impl LocalStorage for PasswordlessUser {}
#[derive(Debug, Clone, PartialEq, Store)]
pub struct AuthContext {
/// `token` is stored in [LocalStorage] and does not contain
/// up-to-date user info that's stored in `session` (using [SessionStorage])
token: Option<UserToken>,
session: Option<UserSession>,
passwordless: Option<PasswordlessUser>,
initialized: bool,
}
impl Default for AuthContext {
fn default() -> Self {
Self::new()
}
}
impl AuthContext {
pub fn new() -> Self {
Self {
token: None,
session: None,
passwordless: None,
initialized: false,
}
}
}
impl InitOrUpdateStore for Store<AuthContext> {
fn init_or_update(&self) {
if !self.initialized().get() {
self.session().set(UserSession::from_session_storage());
self.token().set(UserToken::from_local_storage());
self.passwordless()
.set(PasswordlessUser::from_local_storage());
self.initialized().set(true);
Action::new(move |auth: &Store<AuthContext>| crate::auth::try_auto_signin(*auth))
.dispatch(*self);
} else {
UserSession::set_in_session_storage(self.session().get());
UserToken::set_in_local_storage(self.token().get());
PasswordlessUser::set_in_local_storage(self.passwordless().get());
}
}
}

View File

@ -1,15 +0,0 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
werewolves_macros::static_links!("werewolves/assets" relative to "werewolves/");

144
werewolves/src/auth.rs Normal file
View File

@ -0,0 +1,144 @@
use api::{
cbor_leptos::CborPost,
error::ServerError,
limited::{ClampedString, FixedLenString},
token::{TOKEN_LEN, Token, TokenString},
user::{Password, Session, Username},
};
use chrono::Utc;
use leptos::prelude::*;
use reactive_stores::Store;
use crate::app::storage::{
LocalStorage,
user::{AuthContext, AuthContextStoreFields, PasswordlessUser, UserSession, UserToken},
};
pub async fn try_auto_signin(ctx: Store<AuthContext>) -> Result<(), ServerError> {
if let Some(sess) = ctx.session().read().as_ref() {
if sess.expiry > Utc::now() {
return Ok(());
} else {
ctx.session().write().take();
// ctx.token().write().take();
}
}
if let Some(token) = ctx.token().get() {
if token.expiry > Utc::now() {
match replace_token(ctx, token.token).await {
Ok(_) => return Ok(()),
Err(ServerError::ExpiredToken) => {
log::warn!("login token expired (though recorded as not?)");
ctx.token().write().take();
}
Err(err) => {
ctx.token().write().take();
return Err(err);
}
}
}
}
let Some(PasswordlessUser { username, password }) = PasswordlessUser::from_local_storage()
else {
return Ok(());
};
let token = signin(username.clone(), password).await?;
ctx.token().set(Some(UserToken {
token: token.token.clone(),
expiry: token.token_expires_at,
}));
ctx.session().set(Some(UserSession {
expiry: token.token_expires_at,
username: username.to_string(),
display_name: token.display_name,
pronouns: token.pronouns,
token: token.token,
}));
Ok(())
}
pub async fn replace_token(
ctx: Store<AuthContext>,
token: TokenString,
) -> Result<UserSession, ServerError> {
let new_token = replace_auth_token(token).await?;
let session = UserSession {
expiry: new_token.expires_at,
username: new_token.username.to_string(),
display_name: new_token.display_name,
pronouns: new_token.pronouns,
token: new_token.token.clone(),
};
ctx.token().set(Some(UserToken {
token: new_token.token,
expiry: new_token.expires_at,
}));
ctx.session().set(Some(session.clone()));
Ok(session)
}
#[leptos::server(Signin, input = CborPost)]
pub async fn signin(username: Username, password: Password) -> Result<Session, ServerError> {
let db = use_context::<api::state::AppState>()
.expect("no app state")
.db
.user();
let sess = db.login(&username, &password).await?;
log::info!("user logged in: {}", sess.username);
Ok(sess)
}
#[server(VerifyToken, input = CborPost)]
pub async fn verify_token(token: FixedLenString<TOKEN_LEN>) -> Result<(), ServerError> {
use_context::<api::state::AppState>()
.expect("no app state")
.db
.user()
.check_token(token.as_str())
.await?;
Ok(())
}
#[server(input = CborPost)]
pub async fn get_me(token: FixedLenString<TOKEN_LEN>) -> Result<Session, ServerError> {
let db = use_context::<api::state::AppState>()
.expect("no app state")
.db
.user();
db.check_token(&token).await?;
let sess = db.get_session(&token).await?;
Ok(sess)
}
#[server(input = CborPost)]
pub async fn replace_auth_token(token: FixedLenString<TOKEN_LEN>) -> Result<Token, ServerError> {
let db = use_context::<api::state::AppState>()
.expect("no app state")
.db
.user();
let token = db.replace_token(&token).await?;
let user = db
.get_user(api::db::user::GetUserBy::Id(token.user_id))
.await
.map_err(Into::<ServerError>::into)?;
Ok(Token {
token: FixedLenString::new(token.token.clone()).ok_or_else(|| {
log::error!(
"token [{}] was not of fixed length {TOKEN_LEN}",
token.token,
);
ServerError::InternalServerError
})?,
username: unsafe { ClampedString::new_unchecked(user.username) },
display_name: user.display_name,
pronouns: user.pronouns,
created_at: token.created_at,
expires_at: token.expires_at,
})
}

Some files were not shown because too many files have changed in this diff Show More