69 Commits

Author SHA1 Message Date
5eab7c3e4f Process events list client side 2025-11-25 09:48:49 +01:00
a7bfd713c3 Ready to implement room widget 2025-11-24 17:59:12 +01:00
4be661d999 Fix appearance of unread conversations 2025-11-24 17:55:26 +01:00
1f4e374e66 Display rooms list 2025-11-24 17:50:31 +01:00
cce9b3de5d Hide menu by default on desktop 2025-11-24 16:36:36 +01:00
820b095be0 Display the list of spaces 2025-11-24 16:05:01 +01:00
0a37688116 Can react to event 2025-11-24 13:40:14 +01:00
4d72644a31 Can edit message 2025-11-24 13:18:23 +01:00
0a395b0d26 Can redact message 2025-11-24 13:06:31 +01:00
639cc6c737 Can send text message 2025-11-24 12:54:59 +01:00
bf119a34fb Can get room messages 2025-11-24 12:36:59 +01:00
7562a7fc61 Get latest message for a room 2025-11-24 11:20:20 +01:00
d23190f9d2 Can get spaces of user 2025-11-21 18:38:20 +01:00
35b53fee5c Can request any media file 2025-11-21 17:55:09 +01:00
934e6a4cc1 Can get multiple profiles information 2025-11-21 17:49:41 +01:00
b744265242 Can get single profile information 2025-11-21 17:14:23 +01:00
e8ce97eea0 Can get room avatar 2025-11-21 15:43:15 +01:00
ecbe4885c1 Can get information about rooms 2025-11-21 14:52:21 +01:00
1385afc974 Add more information to websocket messages 2025-11-21 11:40:51 +01:00
8d2cea5f82 Refactor messages propagation 2025-11-21 10:30:48 +01:00
751e3b8654 Redact more events 2025-11-21 09:35:21 +01:00
24f06a78a9 Block WS access if Matrix account is not linked 2025-11-21 09:12:13 +01:00
6b70842b61 Display state in color 2025-11-20 19:31:17 +01:00
7203671b18 Pretty rendering of JSON messages 2025-11-20 19:30:09 +01:00
055ab3759c Remove frontend messages 2025-11-20 19:15:42 +01:00
3ecfc6b470 Add base debug WS route 2025-11-20 19:14:02 +01:00
a1b22699e9 Basic implementation of websocket 2025-11-20 16:06:00 +01:00
0d8905d842 Can stop sync thread from UI 2025-11-19 18:41:26 +01:00
564e606ac7 Properly handle start sync thread issue 2025-11-19 17:15:54 +01:00
7b691962a0 Can get sync thread status 2025-11-19 16:34:00 +01:00
1e00d24a8b Can request sync thread stop 2025-11-19 15:51:15 +01:00
cfdf98b47a Matrix messages are broadcasted 2025-11-19 14:02:51 +01:00
75b6b224bc Notify Matrix manager directly if sync thread is terminated 2025-11-19 13:39:28 +01:00
07f6544a4a WIP sync thread implementation 2025-11-19 11:37:57 +01:00
5bf7c7f8df Do not start sync thread if user is disconnected 2025-11-19 10:49:26 +01:00
79d4482ea4 Sync threads can be interrupted 2025-11-19 10:27:46 +01:00
c9b703bea3 Ready to implement sync thread logic 2025-11-18 22:17:39 +01:00
5c13cffe08 Send broadcast message when an API token is deleted 2025-11-18 15:09:27 +01:00
b5832df746 Can delete API token from UI 2025-11-18 14:51:05 +01:00
02e5575892 Display the list of API tokens 2025-11-14 09:07:22 +01:00
2683268042 Load the list of API tokens 2025-11-13 21:16:45 +01:00
72aaf7b082 Add token creation dialog 2025-11-13 21:03:38 +01:00
c8a48488fc Fix session disconnection issue by removing automatic refresh on client initialization 2025-11-13 18:38:27 +01:00
3b7b368e13 Attempt to fix session restoration issues 2025-11-12 08:14:16 +01:00
5ca126eef7 Split recovery key dialog in new file 2025-11-12 08:06:47 +01:00
7c78eb541e Fix example API client 2025-11-11 21:24:19 +01:00
8fdf1d57eb Create & list tokens 2025-11-11 21:19:54 +01:00
b10ec9ce92 Cleanup code 2025-11-11 17:58:24 +01:00
7925785c8b Fix issue 2025-11-11 17:19:23 +01:00
84c90ea033 Can set user recovery key from UI 2025-11-10 17:42:32 +01:00
a23d671376 Can set recovery key of user 2025-11-10 08:47:02 +01:00
4a72411d65 Return encryption recovery status on API 2025-11-10 08:32:17 +01:00
70a246355b Can disconnect user from UI 2025-11-06 21:33:09 +01:00
8bbbe7022f Automatically disconnect user when token is invalid 2025-11-06 21:18:27 +01:00
1ba5372468 Restore user session on restart 2025-11-06 18:58:43 +01:00
1438e2de0e Can save Matrix session after authentication 2025-11-05 22:53:55 +01:00
1eaec9d319 Can finalize Matrix authentication 2025-11-05 19:32:11 +01:00
37fad9ff55 Can start Matrix authentication from UI 2025-11-05 18:27:41 +01:00
3dab9f41d2 Start Matrix client authentication 2025-11-05 16:30:06 +01:00
a44327ddb0 Display user name in application header 2025-11-05 08:31:47 +01:00
f9fb99cdb5 Add TODO marker 2025-11-04 22:04:12 +01:00
3de26c0fff Simplify dashboard code 2025-11-04 22:02:56 +01:00
79b5a767f3 Improve sidebar 2025-11-04 21:51:20 +01:00
fdcd565431 Add base authenticated route 2025-11-04 21:31:54 +01:00
d9c96e85f7 Authentication flow is functional 2025-11-04 20:51:07 +01:00
9ed711777c Add webapp skeletons 2025-11-04 19:43:51 +01:00
20a42f3c55 Add custom theme 2025-11-04 19:20:17 +01:00
d05747e60e Add base web app 2025-11-04 18:58:41 +01:00
1cdd3d9e60 Add backend README 2025-11-03 22:28:38 +01:00
104 changed files with 11507 additions and 302 deletions

View File

@@ -19,6 +19,8 @@ docker run --rm -it docker.io/pierre42100/matrix_gateway --help
### Dependencies
```
sudo apt install -y libsqlite3-dev
cd matrixgw_backend
mkdir -p storage/maspostgres storage/synapse
docker compose up

File diff suppressed because it is too large Load Diff

View File

@@ -14,10 +14,10 @@ tokio = { version = "1.48.0", features = ["full"] }
actix-web = "4.11.0"
actix-session = { version = "0.11.0", features = ["redis-session"] }
actix-remote-ip = "0.1.0"
actix-cors = "0.7.1"
light-openid = "1.0.4"
bytes = "1.10.1"
sha2 = "0.10.9"
urlencoding = "2.1.3"
base16ct = { version = "0.3.0", features = ["alloc"] }
futures-util = "0.3.31"
jwt-simple = { version = "0.12.13", default-features = false, features = ["pure-rust"] }
@@ -27,3 +27,10 @@ ipnet = { version = "2.11.0", features = ["serde"] }
rand = "0.9.2"
hex = "0.4.3"
mailchecker = "6.0.19"
matrix-sdk = { version = "0.14.0" }
url = "2.5.7"
ractor = "0.15.9"
serde_json = "1.0.145"
lazy-regex = "3.4.2"
actix-ws = "0.3.0"
infer = "0.19.0"

View File

@@ -0,0 +1,2 @@
# Matrix Gateway backend
Backend component, written in Rust using Actix.

View File

@@ -2,11 +2,13 @@ use clap::Parser;
use jwt_simple::algorithms::HS256Key;
use jwt_simple::prelude::{Clock, Duration, JWTClaims, MACLike};
use matrixgw_backend::constants;
use matrixgw_backend::extractors::auth_extractor::TokenClaims;
use matrixgw_backend::extractors::auth_extractor::{MatrixJWTKID, TokenClaims};
use matrixgw_backend::users::{APITokenID, UserEmail};
use matrixgw_backend::utils::rand_utils::rand_string;
use std::ops::Add;
use std::os::unix::prelude::CommandExt;
use std::process::Command;
use matrixgw_backend::utils::rand_utils::rand_string;
use std::str::FromStr;
/// cURL wrapper to query MatrixGW
#[derive(Parser, Debug)]
@@ -20,9 +22,9 @@ struct Args {
#[arg(short('i'), long, env)]
token_id: String,
/// User ID
/// User email
#[arg(short('u'), long, env)]
user_id: String,
user_mail: String,
/// Token secret
#[arg(short('t'), long, env)]
@@ -69,11 +71,14 @@ fn main() {
};
let jwt = key
.with_key_id(&format!(
"{}#{}",
urlencoding::encode(&args.user_id),
urlencoding::encode(&args.token_id)
))
.with_key_id(
&MatrixJWTKID {
user_email: UserEmail(args.user_mail),
id: APITokenID::from_str(args.token_id.as_str())
.expect("Failed to decode token ID!"),
}
.to_string(),
)
.authenticate(claims)
.expect("Failed to sign JWT!");

View File

@@ -1,7 +1,11 @@
use crate::users::{APITokenID, UserEmail};
use crate::utils::crypt_utils::sha256str;
use clap::Parser;
use matrix_sdk::authentication::oauth::registration::{
ApplicationType, ClientMetadata, Localized, OAuthGrantType,
};
use std::path::{Path, PathBuf};
use url::Url;
/// Matrix gateway backend API
#[derive(Parser, Debug, Clone)]
@@ -76,6 +80,10 @@ pub struct AppConfig {
#[arg(long, env, default_value = "APP_ORIGIN/oidc_cb")]
oidc_redirect_url: String,
/// Matrix oauth redirect URL
#[arg(long, env, default_value = "APP_ORIGIN/matrix_auth_cb")]
matrix_oauth_redirect_url: String,
/// Application storage path
#[arg(long, env, default_value = "app_storage")]
storage_path: String,
@@ -146,6 +154,38 @@ impl AppConfig {
}
}
/// Matrix OAuth redirect URL
pub fn matrix_oauth_redirect_url(&self) -> String {
self.matrix_oauth_redirect_url
.replace("APP_ORIGIN", &self.website_origin)
}
/// Get Matrix client metadata information
pub fn matrix_client_metadata(&self) -> ClientMetadata {
let client_uri = Localized::new(
Url::parse(&self.website_origin).expect("Invalid website origin!"),
[],
);
ClientMetadata {
application_type: ApplicationType::Native,
grant_types: vec![OAuthGrantType::AuthorizationCode {
redirect_uris: vec![
Url::parse(&self.matrix_oauth_redirect_url())
.expect("Failed to parse matrix auth redirect URI!"),
],
}],
client_name: Some(Localized::new("MatrixGW".to_string(), [])),
logo_uri: Some(Localized::new(
Url::parse(&format!("{}/favicon.png", self.website_origin))
.expect("Invalid website origin!"),
[],
)),
policy_uri: Some(client_uri.clone()),
tos_uri: Some(client_uri.clone()),
client_uri,
}
}
/// Get storage path
pub fn storage_path(&self) -> &Path {
Path::new(self.storage_path.as_str())
@@ -170,6 +210,21 @@ impl AppConfig {
pub fn user_api_token_metadata_file(&self, mail: &UserEmail, id: &APITokenID) -> PathBuf {
self.user_api_token_directory(mail).join(id.0.to_string())
}
/// Get user Matrix database path
pub fn user_matrix_db_path(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("matrix-db")
}
/// Get user Matrix database passphrase path
pub fn user_matrix_passphrase_path(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("matrix-db-passphrase")
}
/// Get user Matrix session file path
pub fn user_matrix_session_file_path(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("matrix-session.json")
}
}
#[derive(Debug, Clone, serde::Serialize)]

View File

@@ -0,0 +1,37 @@
use crate::matrix_connection::sync_thread::MatrixSyncTaskID;
use crate::users::{APIToken, UserEmail};
use matrix_sdk::Room;
use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent;
use matrix_sdk::ruma::events::room::message::OriginalSyncRoomMessageEvent;
use matrix_sdk::ruma::events::room::redaction::OriginalSyncRoomRedactionEvent;
use matrix_sdk::sync::SyncResponse;
pub type BroadcastSender = tokio::sync::broadcast::Sender<BroadcastMessage>;
#[derive(Debug, Clone)]
pub struct BxRoomEvent<E> {
pub user: UserEmail,
pub data: Box<E>,
pub room: Room,
}
/// Broadcast messages
#[derive(Debug, Clone)]
pub enum BroadcastMessage {
/// User is or has been disconnected from Matrix
UserDisconnectedFromMatrix(UserEmail),
/// API token has been deleted
APITokenDeleted(APIToken),
/// Request a Matrix sync thread to be interrupted
StopSyncThread(MatrixSyncTaskID),
/// Matrix sync thread has been interrupted
SyncThreadStopped(MatrixSyncTaskID),
/// New room message
RoomMessageEvent(BxRoomEvent<OriginalSyncRoomMessageEvent>),
/// New reaction message
ReactionEvent(BxRoomEvent<OriginalSyncReactionEvent>),
/// New room redaction
RoomRedactionEvent(BxRoomEvent<OriginalSyncRoomRedactionEvent>),
/// Raw Matrix sync response
MatrixSyncResponse { user: UserEmail, sync: SyncResponse },
}

View File

@@ -1,9 +1,14 @@
use std::time::Duration;
/// Auth header
pub const API_AUTH_HEADER: &str = "x-client-auth";
/// Max token validity, in seconds
pub const API_TOKEN_JWT_MAX_DURATION: u64 = 15 * 60;
/// Length of generated tokens
pub const TOKENS_LEN: usize = 50;
/// Session-specific constants
pub mod sessions {
/// OpenID auth session state key
@@ -13,3 +18,11 @@ pub mod sessions {
/// Authenticated ID
pub const USER_ID: &str = "uid";
}
/// How often heartbeat pings are sent.
///
/// Should be half (or less) of the acceptable client timeout.
pub const WS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
/// How long before lack of client response causes a timeout.
pub const WS_CLIENT_TIMEOUT: Duration = Duration::from_secs(10);

View File

@@ -1,6 +1,8 @@
use crate::app_config::AppConfig;
use crate::broadcast_messages::BroadcastSender;
use crate::controllers::{HttpFailure, HttpResult};
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::extractors::session_extractor::MatrixGWSession;
use crate::users::{User, UserEmail};
use actix_remote_ip::RemoteIP;
@@ -107,19 +109,23 @@ pub async fn finish_oidc(
}
/// Get current user information
pub async fn auth_info(auth: AuthExtractor) -> HttpResult {
Ok(HttpResponse::Ok().json(auth.user))
pub async fn auth_info(client: MatrixClientExtractor) -> HttpResult {
Ok(HttpResponse::Ok().json(client.to_extended_user_info().await?))
}
/// Sign out user
pub async fn sign_out(auth: AuthExtractor, session: MatrixGWSession) -> HttpResult {
pub async fn sign_out(
auth: AuthExtractor,
session: MatrixGWSession,
tx: web::Data<BroadcastSender>,
) -> HttpResult {
match auth.method {
AuthenticatedMethod::Cookie => {
session.unset_current_user()?;
}
AuthenticatedMethod::Token(token) => {
token.delete(&auth.user.email).await?;
token.delete(&auth.user.email, &tx).await?;
}
AuthenticatedMethod::Dev => {

View File

@@ -0,0 +1,204 @@
use crate::controllers::HttpResult;
use crate::controllers::matrix::matrix_room_controller::RoomIdInPath;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::{HttpResponse, web};
use futures_util::{StreamExt, stream};
use matrix_sdk::Room;
use matrix_sdk::deserialized_responses::{TimelineEvent, TimelineEventKind};
use matrix_sdk::room::MessagesOptions;
use matrix_sdk::room::edit::EditedContent;
use matrix_sdk::ruma::events::reaction::ReactionEventContent;
use matrix_sdk::ruma::events::relation::Annotation;
use matrix_sdk::ruma::events::room::message::{
RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
};
use matrix_sdk::ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UInt};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
#[derive(Serialize)]
pub struct APIEvent {
id: OwnedEventId,
time: MilliSecondsSinceUnixEpoch,
sender: OwnedUserId,
data: Box<RawValue>,
}
impl APIEvent {
pub async fn from_evt(msg: TimelineEvent, room_id: &RoomId) -> anyhow::Result<Self> {
let (event, raw) = match &msg.kind {
TimelineEventKind::Decrypted(d) => (d.event.deserialize()?, d.event.json()),
TimelineEventKind::UnableToDecrypt { event, .. }
| TimelineEventKind::PlainText { event } => (
event.deserialize()?.into_full_event(room_id.to_owned()),
event.json(),
),
};
Ok(Self {
id: event.event_id().to_owned(),
time: event.origin_server_ts(),
sender: event.sender().to_owned(),
data: raw.to_owned(),
})
}
}
#[derive(Serialize)]
pub struct APIEventsList {
pub start: String,
pub end: Option<String>,
pub events: Vec<APIEvent>,
}
/// Get messages for a given room
pub(super) async fn get_events(
room: &Room,
limit: u32,
from: Option<&str>,
) -> anyhow::Result<APIEventsList> {
let mut msg_opts = MessagesOptions::backward();
msg_opts.from = from.map(str::to_string);
msg_opts.limit = UInt::from(limit);
let messages = room.messages(msg_opts).await?;
Ok(APIEventsList {
start: messages.start,
end: messages.end,
events: stream::iter(messages.chunk)
.then(async |msg| APIEvent::from_evt(msg, room.room_id()).await)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?,
})
}
#[derive(Deserialize)]
pub struct GetRoomEventsQuery {
#[serde(default)]
limit: Option<u32>,
#[serde(default)]
from: Option<String>,
}
/// Get the events for a room
pub async fn get_for_room(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
query: web::Query<GetRoomEventsQuery>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
Ok(HttpResponse::Ok()
.json(get_events(&room, query.limit.unwrap_or(500), query.from.as_deref()).await?))
}
#[derive(Deserialize)]
struct SendTextMessageRequest {
content: String,
}
pub async fn send_text_message(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
) -> HttpResult {
let req = client.auth.decode_json_body::<SendTextMessageRequest>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
room.send(RoomMessageEventContent::text_plain(req.content))
.await?;
Ok(HttpResponse::Accepted().finish())
}
#[derive(serde::Deserialize)]
pub struct EventIdInPath {
pub(crate) event_id: OwnedEventId,
}
pub async fn set_text_content(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let req = client.auth.decode_json_body::<SendTextMessageRequest>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
let edit_event = match room
.make_edit_event(
&event_path.event_id,
EditedContent::RoomMessage(RoomMessageEventContentWithoutRelation::text_plain(
req.content,
)),
)
.await
{
Ok(msg) => msg,
Err(e) => {
log::error!(
"Failed to created edit message event {}: {e}",
event_path.event_id
);
return Ok(HttpResponse::InternalServerError()
.json(format!("Failed to create edit message event! {e}")));
}
};
Ok(match room.send(edit_event).await {
Ok(_) => HttpResponse::Accepted().finish(),
Err(e) => {
log::error!("Failed to edit event message {}: {e}", event_path.event_id);
HttpResponse::InternalServerError().json(format!("Failed to edit event! {e}"))
}
})
}
#[derive(Deserialize)]
struct EventReactionBody {
key: String,
}
pub async fn react_to_event(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let body = client.auth.decode_json_body::<EventReactionBody>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
let annotation = Annotation::new(event_path.event_id.to_owned(), body.key.to_owned());
room.send(ReactionEventContent::from(annotation)).await?;
Ok(HttpResponse::Accepted().finish())
}
pub async fn redact_event(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
Ok(match room.redact(&event_path.event_id, None, None).await {
Ok(_) => HttpResponse::Accepted().finish(),
Err(e) => {
log::error!("Failed to redact event {}: {e}", event_path.event_id);
HttpResponse::InternalServerError().json(format!("Failed to redact event! {e}"))
}
})
}

View File

@@ -0,0 +1,67 @@
use crate::controllers::HttpResult;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::utils::crypt_utils::sha512;
use actix_web::dev::Payload;
use actix_web::http::header;
use actix_web::{FromRequest, HttpRequest, HttpResponse, web};
use matrix_sdk::media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings};
use matrix_sdk::ruma::events::room::MediaSource;
use matrix_sdk::ruma::{OwnedMxcUri, UInt};
#[derive(serde::Deserialize)]
struct MediaQuery {
#[serde(default)]
thumbnail: bool,
}
/// Serve a media file
pub async fn serve_media(req: HttpRequest, media: OwnedMxcUri) -> HttpResult {
let query = web::Query::<MediaQuery>::from_request(&req, &mut Payload::None).await?;
let client = MatrixClientExtractor::from_request(&req, &mut Payload::None).await?;
let media = client
.client
.client
.media()
.get_media_content(
&MediaRequestParameters {
source: MediaSource::Plain(media),
format: match query.thumbnail {
true => MediaFormat::Thumbnail(MediaThumbnailSettings::new(
UInt::new(100).unwrap(),
UInt::new(100).unwrap(),
)),
false => MediaFormat::File,
},
},
true,
)
.await?;
let digest = sha512(&media);
let mime_type = infer::get(&media).map(|x| x.mime_type());
// Check if the browser already knows the etag
if let Some(c) = req.headers().get(header::IF_NONE_MATCH)
&& c.to_str().unwrap_or("") == digest
{
return Ok(HttpResponse::NotModified().finish());
}
Ok(HttpResponse::Ok()
.content_type(mime_type.unwrap_or("application/octet-stream"))
.insert_header(("etag", digest))
.insert_header(("cache-control", "max-age=360000"))
.body(media))
}
#[derive(serde::Deserialize)]
pub struct MediaMXCInPath {
mxc: OwnedMxcUri,
}
/// Save media resource handler
pub async fn serve_media_res(req: HttpRequest, media: web::Path<MediaMXCInPath>) -> HttpResult {
serve_media(req, media.into_inner().mxc).await
}

View File

@@ -0,0 +1,67 @@
use crate::controllers::HttpResult;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::{HttpResponse, web};
use futures_util::{StreamExt, stream};
use matrix_sdk::ruma::api::client::profile::{AvatarUrl, DisplayName, get_profile};
use matrix_sdk::ruma::{OwnedMxcUri, OwnedUserId};
#[derive(serde::Deserialize)]
pub struct UserIDInPath {
user_id: OwnedUserId,
}
#[derive(serde::Serialize)]
struct ProfileResponse {
user_id: OwnedUserId,
display_name: Option<String>,
avatar: Option<OwnedMxcUri>,
}
impl ProfileResponse {
pub fn from(user_id: OwnedUserId, r: get_profile::v3::Response) -> anyhow::Result<Self> {
Ok(Self {
user_id,
display_name: r.get_static::<DisplayName>()?,
avatar: r.get_static::<AvatarUrl>()?,
})
}
}
/// Get user profile
pub async fn get_profile(
client: MatrixClientExtractor,
path: web::Path<UserIDInPath>,
) -> HttpResult {
let profile = client
.client
.client
.account()
.fetch_user_profile_of(&path.user_id)
.await?;
Ok(HttpResponse::Ok().json(ProfileResponse::from(path.user_id.clone(), profile)?))
}
/// Get multiple users profiles
pub async fn get_multiple(client: MatrixClientExtractor) -> HttpResult {
let users = client.auth.decode_json_body::<Vec<OwnedUserId>>()?;
let list = stream::iter(users)
.then(async |user_id| {
client
.client
.client
.account()
.fetch_user_profile_of(&user_id)
.await
.map(|r| ProfileResponse::from(user_id, r))
})
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
Ok(HttpResponse::Ok().json(list))
}

View File

@@ -0,0 +1,115 @@
use crate::controllers::HttpResult;
use crate::controllers::matrix::matrix_event_controller::{APIEvent, get_events};
use crate::controllers::matrix::matrix_media_controller;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::{HttpRequest, HttpResponse, web};
use futures_util::{StreamExt, stream};
use matrix_sdk::room::ParentSpace;
use matrix_sdk::ruma::{OwnedMxcUri, OwnedRoomId, OwnedUserId};
use matrix_sdk::{Room, RoomMemberships};
#[derive(serde::Serialize)]
pub struct APIRoomInfo {
id: OwnedRoomId,
name: Option<String>,
members: Vec<OwnedUserId>,
avatar: Option<OwnedMxcUri>,
is_space: bool,
parents: Vec<OwnedRoomId>,
number_unread_messages: u64,
latest_event: Option<APIEvent>,
}
impl APIRoomInfo {
async fn from_room(r: &Room) -> anyhow::Result<Self> {
// Get parent spaces
let parent_spaces = r
.parent_spaces()
.await?
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter_map(|d| match d {
ParentSpace::Reciprocal(r) | ParentSpace::WithPowerlevel(r) => {
Some(r.room_id().to_owned())
}
_ => None,
})
.collect::<Vec<_>>();
Ok(Self {
id: r.room_id().to_owned(),
name: r.name(),
members: r
.members(RoomMemberships::ACTIVE)
.await?
.into_iter()
.map(|r| r.user_id().to_owned())
.collect::<Vec<_>>(),
avatar: r.avatar_url(),
is_space: r.is_space(),
parents: parent_spaces,
number_unread_messages: r.unread_notification_counts().notification_count,
latest_event: get_events(r, 1, None).await?.events.into_iter().next(),
})
}
}
/// Get the list of joined rooms of the user
pub async fn joined_rooms(client: MatrixClientExtractor) -> HttpResult {
let list = stream::iter(client.client.client.joined_rooms())
.then(async |room| APIRoomInfo::from_room(&room).await)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
Ok(HttpResponse::Ok().json(list))
}
/// Get joined spaces rooms of user
pub async fn get_joined_spaces(client: MatrixClientExtractor) -> HttpResult {
let list = stream::iter(client.client.client.joined_space_rooms())
.then(async |room| APIRoomInfo::from_room(&room).await)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
Ok(HttpResponse::Ok().json(list))
}
#[derive(serde::Deserialize)]
pub struct RoomIdInPath {
pub(crate) room_id: OwnedRoomId,
}
/// Get the list of joined rooms of the user
pub async fn single_room_info(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
) -> HttpResult {
Ok(match client.client.client.get_room(&path.room_id) {
None => HttpResponse::NotFound().json("Room not found"),
Some(r) => HttpResponse::Ok().json(APIRoomInfo::from_room(&r).await?),
})
}
/// Get room avatar
pub async fn room_avatar(
req: HttpRequest,
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found"));
};
let Some(uri) = room.avatar_url() else {
return Ok(HttpResponse::NotFound().json("Room has no avatar"));
};
matrix_media_controller::serve_media(req, uri).await
}

View File

@@ -0,0 +1,4 @@
pub mod matrix_event_controller;
pub mod matrix_media_controller;
pub mod matrix_profile_controller;
pub mod matrix_room_controller;

View File

@@ -0,0 +1,59 @@
use crate::controllers::HttpResult;
use crate::extractors::auth_extractor::AuthExtractor;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::matrix_connection::matrix_client::FinishMatrixAuth;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use actix_web::{HttpResponse, web};
use ractor::ActorRef;
#[derive(serde::Serialize)]
struct StartAuthResponse {
url: String,
}
/// Start user authentication on Matrix server
pub async fn start_auth(client: MatrixClientExtractor) -> HttpResult {
let url = client.client.initiate_login().await?.to_string();
Ok(HttpResponse::Ok().json(StartAuthResponse { url }))
}
/// Finish user authentication on Matrix server
pub async fn finish_auth(client: MatrixClientExtractor) -> HttpResult {
match client
.client
.finish_login(client.auth.decode_json_body::<FinishMatrixAuth>()?)
.await
{
Ok(_) => Ok(HttpResponse::Accepted().finish()),
Err(e) => {
log::error!("Failed to finish Matrix authentication: {e}");
Err(e.into())
}
}
}
/// Logout user from Matrix server
pub async fn logout(
auth: AuthExtractor,
manager: web::Data<ActorRef<MatrixManagerMsg>>,
) -> HttpResult {
manager
.cast(MatrixManagerMsg::DisconnectClient(auth.user.email))
.expect("Failed to communicate with matrix manager!");
Ok(HttpResponse::Ok().finish())
}
#[derive(serde::Deserialize)]
struct SetRecoveryKeyRequest {
key: String,
}
/// Set recovery key of user
pub async fn set_recovery_key(client: MatrixClientExtractor) -> HttpResult {
let key = client.auth.decode_json_body::<SetRecoveryKeyRequest>()?.key;
client.client.set_recovery_key(&key).await?;
Ok(HttpResponse::Accepted().finish())
}

View File

@@ -0,0 +1,59 @@
use crate::controllers::HttpResult;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use actix_web::{HttpResponse, web};
use ractor::ActorRef;
/// Start sync thread
pub async fn start_sync(
client: MatrixClientExtractor,
manager: web::Data<ActorRef<MatrixManagerMsg>>,
) -> HttpResult {
match ractor::cast!(
manager,
MatrixManagerMsg::StartSyncThread(client.auth.user.email.clone())
) {
Ok(_) => Ok(HttpResponse::Accepted().finish()),
Err(e) => {
log::error!("Failed to start sync: {e}");
Ok(HttpResponse::InternalServerError().finish())
}
}
}
/// Stop sync thread
pub async fn stop_sync(
client: MatrixClientExtractor,
manager: web::Data<ActorRef<MatrixManagerMsg>>,
) -> HttpResult {
match ractor::cast!(
manager,
MatrixManagerMsg::StopSyncThread(client.auth.user.email.clone())
) {
Ok(_) => Ok(HttpResponse::Accepted().finish()),
Err(e) => {
log::error!("Failed to stop sync thread: {e}");
Ok(HttpResponse::InternalServerError().finish())
}
}
}
#[derive(serde::Serialize)]
struct GetSyncStatusResponse {
started: bool,
}
/// Get sync thread status
pub async fn status(
client: MatrixClientExtractor,
manager: web::Data<ActorRef<MatrixManagerMsg>>,
) -> HttpResult {
let started = ractor::call!(
manager.as_ref(),
MatrixManagerMsg::SyncThreadGetStatus,
client.auth.user.email
)
.expect("RPC to Matrix Manager failed");
Ok(HttpResponse::Ok().json(GetSyncStatusResponse { started }))
}

View File

@@ -3,7 +3,12 @@ use actix_web::{HttpResponse, ResponseError};
use std::error::Error;
pub mod auth_controller;
pub mod matrix;
pub mod matrix_link_controller;
pub mod matrix_sync_thread_controller;
pub mod server_controller;
pub mod tokens_controller;
pub mod ws_controller;
#[derive(thiserror::Error, Debug)]
pub enum HttpFailure {
@@ -15,6 +20,10 @@ pub enum HttpFailure {
OpenID(Box<dyn Error>),
#[error("an unspecified internal error occurred: {0}")]
InternalError(#[from] anyhow::Error),
#[error("Actix web error: {0}")]
ActixError(#[from] actix_web::Error),
#[error("Matrix error: {0}")]
MatrixError(#[from] matrix_sdk::Error),
}
impl ResponseError for HttpFailure {
@@ -27,7 +36,9 @@ impl ResponseError for HttpFailure {
}
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code()).body(self.to_string())
HttpResponse::build(self.status_code())
.content_type("text/plain")
.body(self.to_string())
}
}

View File

@@ -0,0 +1,53 @@
use crate::broadcast_messages::BroadcastSender;
use crate::controllers::HttpResult;
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
use crate::users::{APIToken, APITokenID, BaseAPIToken};
use actix_web::{HttpResponse, web};
/// Create a new token
pub async fn create(auth: AuthExtractor) -> HttpResult {
if matches!(auth.method, AuthenticatedMethod::Token(_)) {
return Ok(HttpResponse::Forbidden()
.json("It is not allowed to create a token using another token!"));
}
let base = auth.decode_json_body::<BaseAPIToken>()?;
if let Some(err) = base.check() {
return Ok(HttpResponse::BadRequest().json(err));
}
let token = APIToken::create(&auth.as_ref().email, base).await?;
Ok(HttpResponse::Ok().json(token))
}
/// Get the list of tokens of current user
pub async fn get_list(auth: AuthExtractor) -> HttpResult {
Ok(HttpResponse::Ok().json(
APIToken::list_user(&auth.as_ref().email)
.await?
.into_iter()
.map(|mut t| {
t.secret = String::new();
t
})
.collect::<Vec<_>>(),
))
}
#[derive(serde::Deserialize)]
pub struct TokenIDInPath {
id: APITokenID,
}
/// Delete an API access token
pub async fn delete(
auth: AuthExtractor,
path: web::Path<TokenIDInPath>,
tx: web::Data<BroadcastSender>,
) -> HttpResult {
let token = APIToken::load(&auth.user.email, &path.id).await?;
token.delete(&auth.user.email, &tx).await?;
Ok(HttpResponse::Accepted().finish())
}

View File

@@ -0,0 +1,252 @@
use crate::broadcast_messages::BroadcastMessage;
use crate::constants;
use crate::controllers::HttpResult;
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::matrix_connection::matrix_client::MatrixClient;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use crate::users::UserEmail;
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpRequest, HttpResponse, web};
use actix_ws::Message;
use futures_util::StreamExt;
use matrix_sdk::ruma::events::reaction::ReactionEventContent;
use matrix_sdk::ruma::events::room::message::RoomMessageEventContent;
use matrix_sdk::ruma::events::room::redaction::RoomRedactionEventContent;
use matrix_sdk::ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedUserId};
use ractor::ActorRef;
use std::time::Instant;
use tokio::sync::broadcast;
use tokio::sync::broadcast::Receiver;
use tokio::time::interval;
#[derive(Debug, serde::Serialize)]
pub struct WsRoomEvent<E> {
pub room_id: OwnedRoomId,
pub event_id: OwnedEventId,
pub sender: OwnedUserId,
pub origin_server_ts: MilliSecondsSinceUnixEpoch,
pub data: Box<E>,
}
/// Messages sent to the client
#[derive(Debug, serde::Serialize)]
#[serde(tag = "type")]
pub enum WsMessage {
/// Room message event
RoomMessageEvent(WsRoomEvent<RoomMessageEventContent>),
/// Room reaction event
RoomReactionEvent(WsRoomEvent<ReactionEventContent>),
/// Room reaction event
RoomRedactionEvent(WsRoomEvent<RoomRedactionEventContent>),
}
impl WsMessage {
pub fn from_bx_message(msg: &BroadcastMessage, user: &UserEmail) -> Option<Self> {
match msg {
BroadcastMessage::RoomMessageEvent(evt) if &evt.user == user => {
Some(Self::RoomMessageEvent(WsRoomEvent {
room_id: evt.room.room_id().to_owned(),
event_id: evt.data.event_id.clone(),
sender: evt.data.sender.clone(),
origin_server_ts: evt.data.origin_server_ts,
data: Box::new(evt.data.content.clone()),
}))
}
BroadcastMessage::ReactionEvent(evt) if &evt.user == user => {
Some(Self::RoomReactionEvent(WsRoomEvent {
room_id: evt.room.room_id().to_owned(),
event_id: evt.data.event_id.clone(),
sender: evt.data.sender.clone(),
origin_server_ts: evt.data.origin_server_ts,
data: Box::new(evt.data.content.clone()),
}))
}
BroadcastMessage::RoomRedactionEvent(evt) if &evt.user == user => {
Some(Self::RoomRedactionEvent(WsRoomEvent {
room_id: evt.room.room_id().to_owned(),
event_id: evt.data.event_id.clone(),
sender: evt.data.sender.clone(),
origin_server_ts: evt.data.origin_server_ts,
data: Box::new(evt.data.content.clone()),
}))
}
_ => None,
}
}
}
/// Main WS route
pub async fn ws(
req: HttpRequest,
stream: web::Payload,
tx: web::Data<broadcast::Sender<BroadcastMessage>>,
manager: web::Data<ActorRef<MatrixManagerMsg>>,
) -> HttpResult {
// Forcefully ignore request payload by manually extracting authentication information
let client = MatrixClientExtractor::from_request(&req, &mut Payload::None).await?;
// Check if Matrix link has been established first
if !client.client.is_client_connected() {
return Ok(HttpResponse::ExpectationFailed().json("Matrix link not established yet!"));
}
// Ensure sync thread is started
ractor::cast!(
manager,
MatrixManagerMsg::StartSyncThread(client.auth.user.email.clone())
)
.expect("Failed to start sync thread prior to running WebSocket!");
let rx = tx.subscribe();
let (res, session, msg_stream) = actix_ws::handle(&req, stream)?;
// spawn websocket handler (and don't await it) so that the response is returned immediately
actix_web::rt::spawn(ws_handler(
session,
msg_stream,
client.auth,
client.client,
rx,
));
Ok(res)
}
pub async fn ws_handler(
mut session: actix_ws::Session,
mut msg_stream: actix_ws::MessageStream,
auth: AuthExtractor,
client: MatrixClient,
mut rx: Receiver<BroadcastMessage>,
) {
log::info!(
"WS connected for user {:?} / auth method={}",
client.email,
auth.method.light_str()
);
let mut last_heartbeat = Instant::now();
let mut interval = interval(constants::WS_HEARTBEAT_INTERVAL);
let reason = loop {
// waits for either `msg_stream` to receive a message from the client, the broadcast channel
// to send a message, or the heartbeat interval timer to tick, yielding the value of
// whichever one is ready first
tokio::select! {
ws_msg = rx.recv() => {
let msg = match ws_msg {
Ok(msg) => msg,
Err(broadcast::error::RecvError::Closed) => break None,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
};
match (&msg, WsMessage::from_bx_message(&msg, &auth.user.email)) {
(BroadcastMessage::APITokenDeleted(t), _) => {
match &auth.method{
AuthenticatedMethod::Token(tok) if tok.id == t.id => {
log::info!(
"closing WS session of user {:?} as associated token was deleted {:?}",
client.email,
t.base.name
);
break None;
}
_=>{}
}
},
(BroadcastMessage::UserDisconnectedFromMatrix(mail), _) if mail == &auth.user.email => {
log::info!(
"closing WS session of user {mail:?} as user was disconnected from Matrix"
);
break None;
}
(_, Some(message)) => {
// Send the message to the websocket
if let Ok(msg) = serde_json::to_string(&message)
&& let Err(e) = session.text(msg).await {
log::error!("Failed to send SyncEvent: {e}");
}
}
_ => {}
};
}
// heartbeat interval ticked
_tick = interval.tick() => {
// if no heartbeat ping/pong received recently, close the connection
if Instant::now().duration_since(last_heartbeat) > constants::WS_CLIENT_TIMEOUT {
log::info!(
"client has not sent heartbeat in over {:?}; disconnecting",constants::WS_CLIENT_TIMEOUT
);
break None;
}
// send heartbeat ping
let _ = session.ping(b"").await;
},
// Websocket messages
msg = msg_stream.next() => {
let msg = match msg {
// received message from WebSocket client
Some(Ok(msg)) => msg,
// client WebSocket stream error
Some(Err(err)) => {
log::error!("{err}");
break None;
}
// client WebSocket stream ended
None => break None
};
log::debug!("msg: {msg:?}");
match msg {
Message::Text(s) => {
log::info!("Text message from WS: {s}");
}
Message::Binary(_) => {
// drop client's binary messages
}
Message::Close(reason) => {
break reason;
}
Message::Ping(bytes) => {
last_heartbeat = Instant::now();
let _ = session.pong(&bytes).await;
}
Message::Pong(_) => {
last_heartbeat = Instant::now();
}
Message::Continuation(_) => {
log::warn!("no support for continuation frames");
}
// no-op; ignore
Message::Nop => {}
};
}
}
};
// attempt to close connection gracefully
let _ = session.close(reason).await;
log::info!("WS disconnected for user {:?}", client.email);
}

View File

@@ -11,6 +11,8 @@ use anyhow::Context;
use bytes::Bytes;
use jwt_simple::common::VerificationOptions;
use jwt_simple::prelude::{Duration, HS256Key, MACLike};
use jwt_simple::reexports::serde_json;
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use std::fmt::Display;
use std::net::IpAddr;
@@ -26,12 +28,38 @@ pub enum AuthenticatedMethod {
Token(APIToken),
}
impl AuthenticatedMethod {
pub fn light_str(&self) -> String {
match self {
AuthenticatedMethod::Cookie => "Cookie".to_string(),
AuthenticatedMethod::Dev => "DevAuthentication".to_string(),
AuthenticatedMethod::Token(t) => format!("Token({:?} - {})", t.id, t.base.name),
}
}
}
pub struct AuthExtractor {
pub user: User,
pub method: AuthenticatedMethod,
pub payload: Option<Vec<u8>>,
}
impl AsRef<User> for AuthExtractor {
fn as_ref(&self) -> &User {
&self.user
}
}
impl AuthExtractor {
pub fn decode_json_body<E: DeserializeOwned + Send>(&self) -> anyhow::Result<E> {
let payload = self
.payload
.as_ref()
.context("Failed to decode request as json: missing payload!")?;
Ok(serde_json::from_slice(payload)?)
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct MatrixJWTKID {
pub user_email: UserEmail,
@@ -144,8 +172,9 @@ impl AuthExtractor {
}
// Check IP restriction
if let Some(net) = token.network
&& !net.contains(&remote_ip)
if let Some(nets) = &token.base.networks
&& !nets.is_empty()
&& !nets.iter().any(|n| n.contains(&remote_ip))
{
log::error!(
"Trying to use token {:?} from unauthorized IP address: {remote_ip:?}",
@@ -157,7 +186,7 @@ impl AuthExtractor {
}
// Check for write access
if token.read_only && !req.method().is_safe() {
if token.base.read_only && !req.method().is_safe() {
return Err(actix_web::error::ErrorBadRequest(
"Read only token cannot perform write operations!",
));

View File

@@ -0,0 +1,54 @@
use crate::extractors::auth_extractor::AuthExtractor;
use crate::matrix_connection::matrix_client::MatrixClient;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use crate::users::ExtendedUserInfo;
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpRequest, web};
use ractor::ActorRef;
pub struct MatrixClientExtractor {
pub auth: AuthExtractor,
pub client: MatrixClient,
}
impl MatrixClientExtractor {
pub async fn to_extended_user_info(&self) -> anyhow::Result<ExtendedUserInfo> {
Ok(ExtendedUserInfo {
user: self.auth.user.clone(),
matrix_account_connected: self.client.is_client_connected(),
matrix_user_id: self.client.user_id().map(|id| id.to_string()),
matrix_device_id: self.client.device_id().map(|id| id.to_string()),
matrix_recovery_state: self.client.recovery_state(),
})
}
}
impl FromRequest for MatrixClientExtractor {
type Error = actix_web::Error;
type Future = futures_util::future::LocalBoxFuture<'static, Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
let req = req.clone();
let mut payload = payload.take();
Box::pin(async move {
let auth = AuthExtractor::from_request(&req, &mut payload).await?;
let matrix_manager_actor =
web::Data::<ActorRef<MatrixManagerMsg>>::from_request(&req, &mut Payload::None)
.await?;
let client = ractor::call!(
matrix_manager_actor,
MatrixManagerMsg::GetClient,
auth.user.email.clone()
);
let client = match client {
Ok(Ok(client)) => client,
Ok(Err(err)) => panic!("Failed to get client! {err:?}"),
Err(err) => panic!("Failed to query manager actor! {err:#?}"),
};
Ok(Self { auth, client })
})
}
}

View File

@@ -1,2 +1,3 @@
pub mod auth_extractor;
pub mod matrix_client_extractor;
pub mod session_extractor;

View File

@@ -1,6 +1,8 @@
pub mod app_config;
pub mod broadcast_messages;
pub mod constants;
pub mod controllers;
pub mod extractors;
pub mod matrix_connection;
pub mod users;
pub mod utils;

View File

@@ -1,12 +1,25 @@
use actix_cors::Cors;
use actix_remote_ip::RemoteIPConfig;
use actix_session::SessionMiddleware;
use actix_session::config::SessionLifecycle;
use actix_session::storage::RedisSessionStore;
use actix_web::cookie::Key;
use actix_web::middleware::Logger;
use actix_web::{App, HttpServer, web};
use matrixgw_backend::app_config::AppConfig;
use matrixgw_backend::controllers::{auth_controller, server_controller};
use matrixgw_backend::broadcast_messages::BroadcastMessage;
use matrixgw_backend::constants;
use matrixgw_backend::controllers::matrix::{
matrix_event_controller, matrix_media_controller, matrix_profile_controller,
matrix_room_controller,
};
use matrixgw_backend::controllers::{
auth_controller, matrix_link_controller, matrix_sync_thread_controller, server_controller,
tokens_controller, ws_controller,
};
use matrixgw_backend::matrix_connection::matrix_manager::MatrixManagerActor;
use matrixgw_backend::users::User;
use ractor::Actor;
#[tokio::main]
async fn main() -> std::io::Result<()> {
@@ -14,10 +27,13 @@ async fn main() -> std::io::Result<()> {
let secret_key = Key::from(AppConfig::get().secret().as_bytes());
log::info!("Connect to Redis session store...");
let redis_store = RedisSessionStore::new(AppConfig::get().redis_connection_string())
.await
.expect("Failed to connect to Redis!");
let (ws_tx, _) = tokio::sync::broadcast::channel::<BroadcastMessage>(16);
// Auto create default account, if requested
if let Some(mail) = &AppConfig::get().unsecure_auto_login_email() {
User::create_or_update_user(mail, "Anonymous")
@@ -25,23 +41,45 @@ async fn main() -> std::io::Result<()> {
.expect("Failed to create auto-login account!");
}
// Create matrix clients manager actor
let (manager_actor, manager_actor_handle) = Actor::spawn(
Some("matrix-clients-manager".to_string()),
MatrixManagerActor,
ws_tx.clone(),
)
.await
.expect("Failed to start Matrix manager actor!");
log::info!(
"Starting to listen on {} for {}",
AppConfig::get().listen_address,
AppConfig::get().website_origin
);
let manager_actor_clone = manager_actor.clone();
HttpServer::new(move || {
let session_mw = SessionMiddleware::builder(redis_store.clone(), secret_key.clone())
.cookie_name("matrixgw-session".to_string())
.session_lifecycle(SessionLifecycle::BrowserSession(Default::default()))
.build();
let cors = Cors::default()
.allowed_origin(&AppConfig::get().website_origin)
.allowed_methods(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
.allowed_header(constants::API_AUTH_HEADER)
.allow_any_header()
.supports_credentials()
.max_age(3600);
App::new()
.wrap(
SessionMiddleware::builder(redis_store.clone(), secret_key.clone())
.cookie_name("matrixgw-session".to_string())
.session_lifecycle(SessionLifecycle::BrowserSession(Default::default()))
.build(),
)
.wrap(Logger::default())
.wrap(session_mw)
.wrap(cors)
.app_data(web::Data::new(manager_actor_clone.clone()))
.app_data(web::Data::new(RemoteIPConfig {
proxy: AppConfig::get().proxy_ip.clone(),
}))
.app_data(web::Data::new(ws_tx.clone()))
// Server controller
.route("/robots.txt", web::get().to(server_controller::robots_txt))
.route(
@@ -62,9 +100,105 @@ async fn main() -> std::io::Result<()> {
"/api/auth/sign_out",
web::get().to(auth_controller::sign_out),
)
// Matrix link controller
.route(
"/api/matrix_link/start_auth",
web::post().to(matrix_link_controller::start_auth),
)
.route(
"/api/matrix_link/finish_auth",
web::post().to(matrix_link_controller::finish_auth),
)
.route(
"/api/matrix_link/logout",
web::post().to(matrix_link_controller::logout),
)
.route(
"/api/matrix_link/set_recovery_key",
web::post().to(matrix_link_controller::set_recovery_key),
)
// API Tokens controller
.route("/api/token", web::post().to(tokens_controller::create))
.route("/api/tokens", web::get().to(tokens_controller::get_list))
.route(
"/api/token/{id}",
web::delete().to(tokens_controller::delete),
)
// Matrix synchronization controller
.route(
"/api/matrix_sync/start",
web::post().to(matrix_sync_thread_controller::start_sync),
)
.route(
"/api/matrix_sync/stop",
web::post().to(matrix_sync_thread_controller::stop_sync),
)
.route(
"/api/matrix_sync/status",
web::get().to(matrix_sync_thread_controller::status),
)
.service(web::resource("/api/ws").route(web::get().to(ws_controller::ws)))
// Matrix room controller
.route(
"/api/matrix/room/joined",
web::get().to(matrix_room_controller::joined_rooms),
)
.route(
"/api/matrix/room/joined_spaces",
web::get().to(matrix_room_controller::get_joined_spaces),
)
.route(
"/api/matrix/room/{room_id}",
web::get().to(matrix_room_controller::single_room_info),
)
.route(
"/api/matrix/room/{room_id}/avatar",
web::get().to(matrix_room_controller::room_avatar),
)
// Matrix profile controller
.route(
"/api/matrix/profile/{user_id}",
web::get().to(matrix_profile_controller::get_profile),
)
.route(
"/api/matrix/profile/get_multiple",
web::post().to(matrix_profile_controller::get_multiple),
)
// Matrix events controller
.route(
"/api/matrix/room/{room_id}/events",
web::get().to(matrix_event_controller::get_for_room),
)
.route(
"/api/matrix/room/{room_id}/send_text_message",
web::post().to(matrix_event_controller::send_text_message),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}/set_text_content",
web::post().to(matrix_event_controller::set_text_content),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}/react",
web::post().to(matrix_event_controller::react_to_event),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}",
web::delete().to(matrix_event_controller::redact_event),
)
// Matrix media controller
.route(
"/api/matrix/media/{mxc}",
web::get().to(matrix_media_controller::serve_media_res),
)
})
.workers(4)
.bind(&AppConfig::get().listen_address)?
.run()
.await
.await?;
// Terminate manager actor
manager_actor.stop(None);
manager_actor_handle.await?;
Ok(())
}

View File

@@ -0,0 +1,401 @@
use crate::app_config::AppConfig;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use crate::users::UserEmail;
use crate::utils::rand_utils::rand_string;
use anyhow::Context;
use futures_util::Stream;
use matrix_sdk::authentication::oauth::error::OAuthDiscoveryError;
use matrix_sdk::authentication::oauth::{
ClientId, OAuthError, OAuthSession, UrlOrQuery, UserSession,
};
use matrix_sdk::config::SyncSettings;
use matrix_sdk::encryption::recovery::RecoveryState;
use matrix_sdk::event_handler::{EventHandler, EventHandlerHandle, SyncEvent};
use matrix_sdk::ruma::presence::PresenceState;
use matrix_sdk::ruma::serde::Raw;
use matrix_sdk::ruma::{DeviceId, UserId};
use matrix_sdk::sync::SyncResponse;
use matrix_sdk::{Client, ClientBuildError, SendOutsideWasm};
use ractor::ActorRef;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::pin::Pin;
use url::Url;
/// The full session to persist.
#[derive(Debug, Serialize, Deserialize, Clone)]
struct StoredSession {
/// The OAuth 2.0 user session.
user_session: UserSession,
/// The OAuth 2.0 client ID.
client_id: ClientId,
}
#[derive(Debug, Serialize, Deserialize, Copy, Clone)]
pub enum EncryptionRecoveryState {
Unknown,
Enabled,
Disabled,
Incomplete,
}
/// Matrix Gateway session errors
#[derive(thiserror::Error, Debug)]
enum MatrixClientError {
#[error("Failed to destroy previous client data! {0}")]
DestroyPreviousData(Box<MatrixClientError>),
#[error("Failed to create Matrix database storage directory! {0}")]
CreateMatrixDbDir(std::io::Error),
#[error("Failed to create database passphrase! {0}")]
CreateDbPassphrase(std::io::Error),
#[error("Failed to read database passphrase! {0}")]
ReadDbPassphrase(std::io::Error),
#[error("Failed to build Matrix client! {0}")]
BuildMatrixClient(ClientBuildError),
#[error("Failed to clear Matrix session file! {0}")]
ClearMatrixSessionFile(std::io::Error),
#[error("Failed to clear Matrix database storage directory! {0}")]
ClearMatrixDbDir(std::io::Error),
#[error("Failed to remove database passphrase! {0}")]
ClearDbPassphrase(std::io::Error),
#[error("Failed to fetch server metadata! {0}")]
FetchServerMetadata(OAuthDiscoveryError),
#[error("Failed to load stored session! {0}")]
LoadStoredSession(std::io::Error),
#[error("Failed to decode stored session! {0}")]
DecodeStoredSession(serde_json::Error),
#[error("Failed to restore stored session! {0}")]
RestoreSession(matrix_sdk::Error),
#[error("Failed to parse auth redirect URL! {0}")]
ParseAuthRedirectURL(url::ParseError),
#[error("Failed to build auth request! {0}")]
BuildAuthRequest(OAuthError),
#[error("Failed to finalize authentication! {0}")]
FinishLogin(matrix_sdk::Error),
#[error("Failed to write session file! {0}")]
WriteSessionFile(std::io::Error),
#[error("Failed to rename device! {0}")]
RenameDevice(matrix_sdk::HttpError),
#[error("Failed to set recovery key! {0}")]
SetRecoveryKey(matrix_sdk::encryption::recovery::RecoveryError),
}
#[derive(serde::Deserialize)]
pub struct FinishMatrixAuth {
code: String,
state: String,
}
#[derive(Clone)]
pub struct MatrixClient {
manager: ActorRef<MatrixManagerMsg>,
pub email: UserEmail,
pub client: Client,
}
impl MatrixClient {
/// Start to build Matrix client to initiate user authentication
pub async fn build_client(
manager: ActorRef<MatrixManagerMsg>,
email: &UserEmail,
) -> anyhow::Result<Self> {
// Check if we are restoring a previous state
let session_file_path = AppConfig::get().user_matrix_session_file_path(email);
let is_restoring = session_file_path.is_file();
if !is_restoring {
Self::destroy_data(email).map_err(MatrixClientError::DestroyPreviousData)?;
}
// Determine Matrix database path
let db_path = AppConfig::get().user_matrix_db_path(email);
std::fs::create_dir_all(&db_path).map_err(MatrixClientError::CreateMatrixDbDir)?;
// Generate or load passphrase
let passphrase_path = AppConfig::get().user_matrix_passphrase_path(email);
if !passphrase_path.exists() {
std::fs::write(&passphrase_path, rand_string(32))
.map_err(MatrixClientError::CreateDbPassphrase)?;
}
let passphrase = std::fs::read_to_string(passphrase_path)
.map_err(MatrixClientError::ReadDbPassphrase)?;
let client = Client::builder()
.homeserver_url(&AppConfig::get().matrix_homeserver)
// Automatically refresh tokens if needed
.handle_refresh_tokens()
.sqlite_store(&db_path, Some(&passphrase))
.build()
.await
.map_err(MatrixClientError::BuildMatrixClient)?;
let client = Self {
manager,
email: email.clone(),
client,
};
// Check metadata
if !is_restoring {
let oauth = client.client.oauth();
let server_metadata = oauth
.server_metadata()
.await
.map_err(MatrixClientError::FetchServerMetadata)?;
log::info!("OAuth2 server issuer: {:?}", server_metadata.issuer);
} else {
let session: StoredSession = serde_json::from_str(
std::fs::read_to_string(session_file_path)
.map_err(MatrixClientError::LoadStoredSession)?
.as_str(),
)
.map_err(MatrixClientError::DecodeStoredSession)?;
// Restore session
client
.client
.restore_session(OAuthSession {
client_id: session.client_id,
user: session.user_session,
})
.await
.map_err(MatrixClientError::RestoreSession)?;
// Wait for encryption tasks to complete
client
.client
.encryption()
.wait_for_e2ee_initialization_tasks()
.await;
// Save stored session once
client.save_stored_session().await?;
}
// Automatically save session when token gets refreshed
client.setup_background_session_save().await;
Ok(client)
}
/// Destroy Matrix client related data
fn destroy_data(email: &UserEmail) -> anyhow::Result<(), Box<MatrixClientError>> {
let session_path = AppConfig::get().user_matrix_session_file_path(email);
if session_path.is_file() {
std::fs::remove_file(&session_path)
.map_err(MatrixClientError::ClearMatrixSessionFile)?;
}
let db_path = AppConfig::get().user_matrix_db_path(email);
if db_path.is_dir() {
std::fs::remove_dir_all(&db_path).map_err(MatrixClientError::ClearMatrixDbDir)?;
}
let passphrase_path = AppConfig::get().user_matrix_passphrase_path(email);
if passphrase_path.is_file() {
std::fs::remove_file(passphrase_path).map_err(MatrixClientError::ClearDbPassphrase)?;
}
Ok(())
}
/// Initiate OAuth authentication
pub async fn initiate_login(&self) -> anyhow::Result<Url> {
let oauth = self.client.oauth();
let metadata = AppConfig::get().matrix_client_metadata();
let client_metadata = Raw::new(&metadata).expect("Couldn't serialize client metadata");
let auth = oauth
.login(
Url::parse(&AppConfig::get().matrix_oauth_redirect_url())
.map_err(MatrixClientError::ParseAuthRedirectURL)?,
None,
Some(client_metadata.into()),
None,
)
.build()
.await
.map_err(MatrixClientError::BuildAuthRequest)?;
Ok(auth.url)
}
/// Finish OAuth authentication
pub async fn finish_login(&self, info: FinishMatrixAuth) -> anyhow::Result<()> {
let oauth = self.client.oauth();
oauth
.finish_login(UrlOrQuery::Query(format!(
"state={}&code={}",
info.state, info.code
)))
.await
.map_err(MatrixClientError::FinishLogin)?;
log::info!(
"User successfully authenticated as {}!",
self.client.user_id().unwrap()
);
// Persist session tokens
self.save_stored_session().await?;
// Rename created session to give it a more explicit name
self.client
.rename_device(
self.client
.session_meta()
.context("Missing device ID!")?
.device_id
.as_ref(),
&AppConfig::get().website_origin,
)
.await
.map_err(MatrixClientError::RenameDevice)?;
Ok(())
}
/// Automatically persist session onto disk
pub async fn setup_background_session_save(&self) {
let this = self.clone();
tokio::spawn(async move {
loop {
match this.client.subscribe_to_session_changes().recv().await {
Ok(update) => match update {
matrix_sdk::SessionChange::UnknownToken { soft_logout } => {
log::warn!(
"Received an unknown token error; soft logout? {soft_logout:?}"
);
if let Err(e) = this
.manager
.cast(MatrixManagerMsg::DisconnectClient(this.email))
{
log::warn!("Failed to propagate invalid token error: {e}");
}
break;
}
matrix_sdk::SessionChange::TokensRefreshed => {
// The tokens have been refreshed, persist them to disk.
if let Err(err) = this.save_stored_session().await {
log::error!("Unable to store a session in the background: {err}");
}
}
},
Err(e) => {
log::error!("[!] Session change error: {e}");
log::error!("Session change background service INTERRUPTED!");
return;
}
}
}
});
}
/// Update the session stored on the filesystem.
async fn save_stored_session(&self) -> anyhow::Result<()> {
log::debug!("Save the stored session for {:?}...", self.email);
let full_session = self
.client
.oauth()
.full_session()
.context("A logged in client must have a session")?;
let stored_session = StoredSession {
user_session: full_session.user,
client_id: full_session.client_id,
};
let serialized_session = serde_json::to_string(&stored_session)?;
std::fs::write(
AppConfig::get().user_matrix_session_file_path(&self.email),
serialized_session,
)
.map_err(MatrixClientError::WriteSessionFile)?;
log::debug!("Updating the stored session: done!");
Ok(())
}
/// Check whether a user is currently connected to this client or not
pub fn is_client_connected(&self) -> bool {
self.client.is_active()
}
/// Disconnect user from client
pub async fn disconnect(self) -> anyhow::Result<()> {
if let Err(e) = self.client.logout().await {
log::warn!("Failed to send logout request: {e}");
}
// Destroy user associated data
Self::destroy_data(&self.email)?;
Ok(())
}
/// Get client Matrix device id
pub fn device_id(&self) -> Option<&DeviceId> {
self.client.device_id()
}
/// Get client Matrix user id
pub fn user_id(&self) -> Option<&UserId> {
self.client.user_id()
}
/// Get current encryption keys recovery state
pub fn recovery_state(&self) -> EncryptionRecoveryState {
match self.client.encryption().recovery().state() {
RecoveryState::Unknown => EncryptionRecoveryState::Unknown,
RecoveryState::Enabled => EncryptionRecoveryState::Enabled,
RecoveryState::Disabled => EncryptionRecoveryState::Disabled,
RecoveryState::Incomplete => EncryptionRecoveryState::Incomplete,
}
}
/// Set new encryption key recovery key
pub async fn set_recovery_key(&self, key: &str) -> anyhow::Result<()> {
Ok(self
.client
.encryption()
.recovery()
.recover(key)
.await
.map_err(MatrixClientError::SetRecoveryKey)?)
}
/// Get matrix synchronization settings to use
fn sync_settings() -> SyncSettings {
SyncSettings::default().set_presence(PresenceState::Offline)
}
/// Perform initial synchronization
pub async fn perform_initial_sync(&self) -> anyhow::Result<()> {
self.client.sync_once(Self::sync_settings()).await?;
Ok(())
}
/// Perform routine synchronization
pub async fn sync_stream(
&self,
) -> Pin<Box<impl Stream<Item = matrix_sdk::Result<SyncResponse>>>> {
Box::pin(self.client.sync_stream(Self::sync_settings()).await)
}
/// Add new Matrix event handler
#[must_use]
pub fn add_event_handler<Ev, Ctx, H>(&self, handler: H) -> EventHandlerHandle
where
Ev: SyncEvent + DeserializeOwned + SendOutsideWasm + 'static,
H: EventHandler<Ev, Ctx>,
{
self.client.add_event_handler(handler)
}
/// Remove Matrix event handler
pub fn remove_event_handler(&self, handle: EventHandlerHandle) {
self.client.remove_event_handler(handle)
}
}

View File

@@ -0,0 +1,164 @@
use crate::broadcast_messages::{BroadcastMessage, BroadcastSender};
use crate::matrix_connection::matrix_client::MatrixClient;
use crate::matrix_connection::sync_thread::{MatrixSyncTaskID, start_sync_thread};
use crate::users::UserEmail;
use ractor::{Actor, ActorProcessingErr, ActorRef, RpcReplyPort};
use std::collections::HashMap;
pub struct MatrixManagerState {
pub broadcast_sender: BroadcastSender,
pub clients: HashMap<UserEmail, MatrixClient>,
pub running_sync_threads: HashMap<UserEmail, MatrixSyncTaskID>,
}
pub enum MatrixManagerMsg {
GetClient(UserEmail, RpcReplyPort<anyhow::Result<MatrixClient>>),
DisconnectClient(UserEmail),
StartSyncThread(UserEmail),
StopSyncThread(UserEmail),
SyncThreadGetStatus(UserEmail, RpcReplyPort<bool>),
SyncThreadTerminated(UserEmail, MatrixSyncTaskID),
}
pub struct MatrixManagerActor;
impl Actor for MatrixManagerActor {
type Msg = MatrixManagerMsg;
type State = MatrixManagerState;
type Arguments = BroadcastSender;
async fn pre_start(
&self,
_myself: ActorRef<Self::Msg>,
args: Self::Arguments,
) -> Result<Self::State, ActorProcessingErr> {
Ok(MatrixManagerState {
broadcast_sender: args,
clients: HashMap::new(),
running_sync_threads: Default::default(),
})
}
async fn post_stop(
&self,
_myself: ActorRef<Self::Msg>,
_state: &mut Self::State,
) -> Result<(), ActorProcessingErr> {
log::error!("[!] [!] Matrix Manager Actor stopped!");
Ok(())
}
async fn handle(
&self,
myself: ActorRef<Self::Msg>,
message: Self::Msg,
state: &mut Self::State,
) -> Result<(), ActorProcessingErr> {
match message {
// Get client information
MatrixManagerMsg::GetClient(email, port) => {
let res = port.send(match state.clients.get(&email) {
None => {
// Generate client if required
log::info!("Building new client for {:?}", &email);
match MatrixClient::build_client(myself, &email).await {
Ok(c) => {
state.clients.insert(email.clone(), c.clone());
Ok(c)
}
Err(e) => Err(e),
}
}
Some(c) => Ok(c.clone()),
});
if let Err(e) = res {
log::warn!("Failed to send client information: {e}")
}
}
MatrixManagerMsg::DisconnectClient(email) => {
if let Some(c) = state.clients.remove(&email) {
// Stop sync thread (if running)
if let Some(id) = state.running_sync_threads.remove(&email) {
state
.broadcast_sender
.send(BroadcastMessage::StopSyncThread(id))
.ok();
}
// Disconnect client
if let Err(e) = c.disconnect().await {
log::error!("Failed to disconnect client: {e}");
}
if let Err(e) = state
.broadcast_sender
.send(BroadcastMessage::UserDisconnectedFromMatrix(email))
{
log::warn!(
"Failed to notify that user has been disconnected from Matrix! {e}"
);
}
}
}
MatrixManagerMsg::StartSyncThread(email) => {
// Do nothing if task is already running
if state.running_sync_threads.contains_key(&email) {
log::debug!("Not starting sync thread for {email:?} as it is already running");
return Ok(());
}
let Some(client) = state.clients.get(&email) else {
log::warn!(
"Cannot start sync thread for {email:?} because client is not initialized!"
);
return Ok(());
};
if !client.is_client_connected() {
log::warn!(
"Cannot start sync thread for {email:?} because Matrix account is not set!"
);
return Ok(());
}
// Start thread
log::debug!("Starting sync thread for {email:?}");
let thread_id =
match start_sync_thread(client.clone(), state.broadcast_sender.clone(), myself)
.await
{
Ok(thread_id) => thread_id,
Err(e) => {
log::error!("Failed to start sync thread! {e}");
return Ok(());
}
};
state.running_sync_threads.insert(email, thread_id);
}
MatrixManagerMsg::StopSyncThread(email) => {
if let Some(thread_id) = state.running_sync_threads.get(&email)
&& let Err(e) = state
.broadcast_sender
.send(BroadcastMessage::StopSyncThread(thread_id.clone()))
{
log::error!("Failed to request sync thread stop: {e}");
}
}
MatrixManagerMsg::SyncThreadGetStatus(email, reply) => {
let started = state.running_sync_threads.contains_key(&email);
if let Err(e) = reply.send(started) {
log::error!("Failed to send sync thread status! {e}");
}
}
MatrixManagerMsg::SyncThreadTerminated(email, task_id) => {
if state.running_sync_threads.get(&email) == Some(&task_id) {
log::info!(
"Sync thread {task_id:?} has been terminated, removing it from the list..."
);
state.running_sync_threads.remove(&email);
}
}
}
Ok(())
}
}

View File

@@ -0,0 +1,3 @@
pub mod matrix_client;
pub mod matrix_manager;
pub mod sync_thread;

View File

@@ -0,0 +1,155 @@
//! # Matrix sync thread
//!
//! This file contains the logic performed by the threads that synchronize with Matrix account.
use crate::broadcast_messages::{BroadcastMessage, BroadcastSender, BxRoomEvent};
use crate::matrix_connection::matrix_client::MatrixClient;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use futures_util::StreamExt;
use matrix_sdk::Room;
use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent;
use matrix_sdk::ruma::events::room::message::OriginalSyncRoomMessageEvent;
use matrix_sdk::ruma::events::room::redaction::OriginalSyncRoomRedactionEvent;
use ractor::ActorRef;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MatrixSyncTaskID(uuid::Uuid);
/// Start synchronization thread for a given user
pub async fn start_sync_thread(
client: MatrixClient,
tx: BroadcastSender,
manager: ActorRef<MatrixManagerMsg>,
) -> anyhow::Result<MatrixSyncTaskID> {
// Perform initial synchronization here, so in case of error the sync task does not get registered
log::info!("Perform initial synchronization...");
if let Err(e) = client.perform_initial_sync().await {
log::error!("Failed to perform initial Matrix synchronization! {e:?}");
return Err(e);
}
let task_id = MatrixSyncTaskID(uuid::Uuid::new_v4());
let task_id_clone = task_id.clone();
tokio::task::spawn(async move {
sync_thread_task(task_id_clone, client, tx, manager).await;
});
Ok(task_id)
}
/// Sync thread function for a single function
async fn sync_thread_task(
id: MatrixSyncTaskID,
client: MatrixClient,
tx: BroadcastSender,
manager: ActorRef<MatrixManagerMsg>,
) {
let mut rx = tx.subscribe();
log::info!("Sync thread {id:?} started for user {:?}", client.email);
let mut sync_stream = client.sync_stream().await;
let mut handlers = vec![];
let tx_msg_handle = tx.clone();
let user_msg_handle = client.email.clone();
handlers.push(client.add_event_handler(
async move |event: OriginalSyncRoomMessageEvent, room: Room| {
if let Err(e) = tx_msg_handle.send(BroadcastMessage::RoomMessageEvent(BxRoomEvent {
user: user_msg_handle.clone(),
data: Box::new(event),
room,
})) {
log::warn!("Failed to forward room message event! {e}");
}
},
));
let tx_reac_handle = tx.clone();
let user_reac_handle = client.email.clone();
handlers.push(client.add_event_handler(
async move |event: OriginalSyncReactionEvent, room: Room| {
if let Err(e) = tx_reac_handle.send(BroadcastMessage::ReactionEvent(BxRoomEvent {
user: user_reac_handle.clone(),
data: Box::new(event),
room,
})) {
log::warn!("Failed to forward reaction event! {e}");
}
},
));
let tx_redac_handle = tx.clone();
let user_redac_handle = client.email.clone();
handlers.push(client.add_event_handler(
async move |event: OriginalSyncRoomRedactionEvent, room: Room| {
if let Err(e) =
tx_redac_handle.send(BroadcastMessage::RoomRedactionEvent(BxRoomEvent {
user: user_redac_handle.clone(),
data: Box::new(event),
room,
}))
{
log::warn!("Failed to forward reaction event! {e}");
}
},
));
loop {
tokio::select! {
// Message from tokio broadcast
msg = rx.recv() => {
match msg {
Ok(BroadcastMessage::StopSyncThread(task_id)) if task_id == id => {
log::info!("A request was received to stop sync task! {id:?} for user {:?}", client.email);
break;
}
Err(e) => {
log::error!("Failed to receive a message from broadcast! {e}");
break;
}
Ok(_) => {}
}
}
res = sync_stream.next() => {
let Some(res)= res else {
log::error!("No more Matrix event to process, stopping now...");
break;
};
// Forward message
match res {
Ok(res) => {
if let Err(e)= tx.send(BroadcastMessage::MatrixSyncResponse {
user: client.email.clone(),
sync: res
}) {
log::warn!("Failed to forward room event! {e}");
}
}
Err(e) => {
log::error!("Sync error for user {:?}! {e}", client.email);
}
}
}
}
}
for h in handlers {
client.remove_event_handler(h);
}
// Notify manager about termination, so this thread can be removed from the list
log::info!("Sync thread {id:?} terminated!");
if let Err(e) = ractor::cast!(
manager,
MatrixManagerMsg::SyncThreadTerminated(client.email.clone(), id.clone())
) {
log::error!("Failed to notify Matrix manager about thread termination! {e}");
}
if let Err(e) = tx.send(BroadcastMessage::SyncThreadStopped(id)) {
log::warn!("Failed to notify that synchronization thread has been interrupted! {e}")
}
}

View File

@@ -1,5 +1,11 @@
use crate::app_config::AppConfig;
use crate::broadcast_messages::{BroadcastMessage, BroadcastSender};
use crate::constants;
use crate::controllers::server_controller::ServerConstraints;
use crate::matrix_connection::matrix_client::EncryptionRecoveryState;
use crate::utils::rand_utils::rand_string;
use crate::utils::time_utils::time_secs;
use anyhow::Context;
use jwt_simple::reexports::serde_json;
use std::cmp::min;
use std::str::FromStr;
@@ -13,6 +19,8 @@ enum MatrixGWUserError {
DecodeUserMetadata(serde_json::Error),
#[error("Failed to save user metadata: {0}")]
SaveUserMetadata(std::io::Error),
#[error("Failed to create API token directory: {0}")]
CreateApiTokensDirectory(std::io::Error),
#[error("Failed to delete API token: {0}")]
DeleteToken(std::io::Error),
#[error("Failed to load API token: {0}")]
@@ -100,17 +108,63 @@ impl User {
}
}
/// Single API client information
/// Base API token information
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct APIToken {
/// Token unique ID
pub id: APITokenID,
/// Client description
pub description: String,
pub struct BaseAPIToken {
/// Token name
pub name: String,
/// Restricted API network for token
pub network: Option<ipnet::IpNet>,
pub networks: Option<Vec<ipnet::IpNet>>,
/// Token max inactivity
pub max_inactivity: u32,
/// Token expiration
pub expiration: Option<u64>,
/// Read only access
pub read_only: bool,
}
impl BaseAPIToken {
/// Check API token information validity
pub fn check(&self) -> Option<&'static str> {
let constraints = ServerConstraints::default();
if !lazy_regex::regex!("^[a-zA-Z0-9 :-]+$").is_match(&self.name) {
return Some("Token name contains invalid characters!");
}
if !constraints.token_name.check_str(&self.name) {
return Some("Invalid token name length!");
}
if !constraints
.token_max_inactivity
.check_u32(self.max_inactivity)
{
return Some("Invalid token max inactivity!");
}
if let Some(expiration) = self.expiration
&& expiration <= time_secs()
{
return Some("Given expiration time is in the past!");
}
None
}
}
/// Single API token information
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct APIToken {
#[serde(flatten)]
pub base: BaseAPIToken,
/// Token unique ID
pub id: APITokenID,
/// Client secret
pub secret: String,
@@ -120,15 +174,58 @@ pub struct APIToken {
/// Client last usage time
pub last_used: u64,
/// Read only access
pub read_only: bool,
/// Token max inactivity
pub max_inactivity: u64,
}
impl APIToken {
/// Get the list of tokens of a user
pub async fn list_user(email: &UserEmail) -> anyhow::Result<Vec<Self>> {
let tokens_dir = AppConfig::get().user_api_token_directory(email);
if !tokens_dir.exists() {
return Ok(vec![]);
}
let mut list = vec![];
for u in std::fs::read_dir(&tokens_dir)? {
let entry = u?;
list.push(
Self::load(
email,
&APITokenID::from_str(
entry
.file_name()
.to_str()
.context("Cannot decode API Token ID as string!")?,
)?,
)
.await?,
);
}
Ok(list)
}
/// Create a new token
pub async fn create(email: &UserEmail, base: BaseAPIToken) -> anyhow::Result<Self> {
let tokens_dir = AppConfig::get().user_api_token_directory(email);
if !tokens_dir.exists() {
std::fs::create_dir_all(tokens_dir)
.map_err(MatrixGWUserError::CreateApiTokensDirectory)?;
}
let token = APIToken {
base,
id: Default::default(),
secret: rand_string(constants::TOKENS_LEN),
created: time_secs(),
last_used: time_secs(),
};
token.write(email).await?;
Ok(token)
}
/// Get a token information
pub async fn load(email: &UserEmail, id: &APITokenID) -> anyhow::Result<Self> {
let token_file = AppConfig::get().user_api_token_metadata_file(email, id);
@@ -150,19 +247,42 @@ impl APIToken {
}
/// Delete this token
pub async fn delete(self, email: &UserEmail) -> anyhow::Result<()> {
pub async fn delete(self, email: &UserEmail, tx: &BroadcastSender) -> anyhow::Result<()> {
let token_file = AppConfig::get().user_api_token_metadata_file(email, &self.id);
std::fs::remove_file(&token_file).map_err(MatrixGWUserError::DeleteToken)?;
if let Err(e) = tx.send(BroadcastMessage::APITokenDeleted(self)) {
log::error!("Failed to notify API token deletion! {e}");
}
Ok(())
}
pub fn shall_update_time_used(&self) -> bool {
let refresh_interval = min(600, self.max_inactivity / 10);
let refresh_interval = min(600, self.base.max_inactivity / 10);
(self.last_used) < time_secs() - refresh_interval
(self.last_used) < time_secs() - refresh_interval as u64
}
pub fn is_expired(&self) -> bool {
(self.last_used + self.max_inactivity) < time_secs()
// Check for hard coded expiration
if let Some(exp_time) = self.base.expiration
&& exp_time < time_secs()
{
return true;
}
// Control max token inactivity
(self.last_used + self.base.max_inactivity as u64) < time_secs()
}
}
#[derive(serde::Serialize, Debug, Clone)]
pub struct ExtendedUserInfo {
#[serde(flatten)]
pub user: User,
pub matrix_account_connected: bool,
pub matrix_user_id: Option<String>,
pub matrix_device_id: Option<String>,
pub matrix_recovery_state: EncryptionRecoveryState,
}

View File

@@ -1,6 +1,11 @@
use sha2::{Digest, Sha256};
use sha2::{Digest, Sha256, Sha512};
/// Compute SHA256sum of a given string
pub fn sha256str(input: &str) -> String {
hex::encode(Sha256::digest(input.as_bytes()))
}
/// Compute SHA256sum of a given byte array
pub fn sha512(input: &[u8]) -> String {
hex::encode(Sha512::digest(input))
}

1
matrixgw_frontend/.env Normal file
View File

@@ -0,0 +1 @@
VITE_APP_BACKEND=http://localhost:8000/api

View File

@@ -0,0 +1 @@
VITE_APP_BACKEND=/api

View File

@@ -1,73 +1,2 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
# MatrixGW frontend
Built using React + TypeScript + Vite

View File

@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>matrixgw_frontend</title>
<title>MatrixGW</title>
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,23 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.8",
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5",
"@mui/x-data-grid": "^8.18.0",
"@mui/x-date-pickers": "^8.17.0",
"date-and-time": "^4.1.0",
"dayjs": "^1.11.19",
"is-cidr": "^6.0.1",
"qrcode.react": "^4.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"react-json-view-lite": "^2.5.0",
"react-router": "^7.9.5"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,35 +1,69 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
import React from "react";
import {
createBrowserRouter,
createRoutesFromElements,
Route,
RouterProvider,
} from "react-router";
import { AuthApi } from "./api/AuthApi";
import { ServerApi } from "./api/ServerApi";
import { APITokensRoute } from "./routes/APITokensRoute";
import { LoginRoute } from "./routes/auth/LoginRoute";
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
import { HomeRoute } from "./routes/HomeRoute";
import { MatrixAuthCallback } from "./routes/MatrixAuthCallback";
import { MatrixLinkRoute } from "./routes/MatrixLinkRoute";
import { NotFoundRoute } from "./routes/NotFoundRoute";
import { WSDebugRoute } from "./routes/WSDebugRoute";
import { BaseLoginPage } from "./widgets/auth/BaseLoginPage";
import BaseAuthenticatedPage from "./widgets/dashboard/BaseAuthenticatedPage";
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
interface AuthContext {
signedIn: boolean;
setSignedIn: (signedIn: boolean) => void;
}
export default App
const AuthContextK = React.createContext<AuthContext | null>(null);
export function App(): React.ReactElement {
const [signedIn, setSignedIn] = React.useState(AuthApi.SignedIn);
const context: AuthContext = {
signedIn: signedIn,
setSignedIn: (s) => {
setSignedIn(s);
location.reload();
},
};
const router = createBrowserRouter(
createRoutesFromElements(
signedIn || ServerApi.Config.auth_disabled ? (
<Route path="*" element={<BaseAuthenticatedPage />}>
<Route path="" element={<HomeRoute />} />
<Route path="matrix_link" element={<MatrixLinkRoute />} />
<Route path="matrix_auth_cb" element={<MatrixAuthCallback />} />
<Route path="tokens" element={<APITokensRoute />} />
<Route path="wsdebug" element={<WSDebugRoute />} />
<Route path="*" element={<NotFoundRoute />} />
</Route>
) : (
<Route path="*" element={<BaseLoginPage />}>
<Route path="" element={<LoginRoute />} />
<Route path="oidc_cb" element={<OIDCCbRoute />} />
<Route path="*" element={<NotFoundRoute />} />
</Route>
)
)
);
return (
<AuthContextK value={context}>
<RouterProvider router={router} />
</AuthContextK>
);
}
export function useAuth(): AuthContext {
return React.use(AuthContextK)!;
}

View File

@@ -0,0 +1,192 @@
import { AuthApi } from "./AuthApi";
interface RequestParams {
uri: string;
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
allowFail?: boolean;
jsonData?: any;
formData?: FormData;
upProgress?: (progress: number) => void;
downProgress?: (e: { progress: number; total: number }) => void;
}
interface APIResponse {
data: any;
status: number;
}
export class ApiError extends Error {
public code: number;
public data: number;
constructor(message: string, code: number, data: any) {
super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`);
this.code = code;
this.data = data;
}
}
export class APIClient {
/**
* Get backend URL
*/
static backendURL(): string {
const URL = import.meta.env.VITE_APP_BACKEND ?? "";
if (URL.length === 0) throw new Error("Backend URL undefined!");
return URL;
}
/**
* Get the full URL at which the backend can be contacted
*/
static ActualBackendURL(): string {
const backendURL = this.backendURL();
if (backendURL.startsWith("/")) return `${location.origin}${backendURL}`;
else return backendURL;
}
/**
* Check out whether the backend is accessed through
* HTTPS or not
*/
static IsBackendSecure(): boolean {
return this.ActualBackendURL().startsWith("https");
}
/**
* Perform a request on the backend
*/
static async exec(args: RequestParams): Promise<APIResponse> {
let body: string | undefined | FormData = undefined;
const headers: any = {};
// JSON request
if (args.jsonData) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(args.jsonData);
}
// Form data request
else if (args.formData) {
body = args.formData;
}
const url = this.backendURL() + args.uri;
let data;
let status: number;
// Make the request with XMLHttpRequest
if (args.upProgress) {
const res: XMLHttpRequest = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
args.upProgress!(e.loaded / e.total);
});
xhr.addEventListener("load", () => {
resolve(xhr);
});
xhr.addEventListener("error", () => {
reject(new Error("File upload failed"));
});
xhr.addEventListener("abort", () => {
reject(new Error("File upload aborted"));
});
xhr.addEventListener("timeout", () => {
reject(new Error("File upload timeout"));
});
xhr.open(args.method, url, true);
xhr.withCredentials = true;
for (const key in headers) {
if (Object.prototype.hasOwnProperty.call(headers, key))
xhr.setRequestHeader(key, headers[key]);
}
xhr.send(body);
});
status = res.status;
if (res.responseType === "json") data = JSON.parse(res.responseText);
else data = res.response;
}
// Make the request with fetch
else {
const res = await fetch(url, {
method: args.method,
body: body,
headers: headers,
credentials: "include",
});
// Process response
// JSON response
if (res.headers.get("content-type") === "application/json")
data = await res.json();
// Text / XML response
else if (
["application/xml", "text/plain"].includes(
res.headers.get("content-type") ?? ""
)
)
data = await res.text();
// Binary file, tracking download progress
else if (res.body !== null && args.downProgress) {
// Track download progress
const contentEncoding = res.headers.get("content-encoding");
const contentLength = contentEncoding
? null
: res.headers.get("content-length");
const total = parseInt(contentLength ?? "0", 10);
let loaded = 0;
const resInt = new Response(
new ReadableStream({
start(controller) {
const reader = res.body!.getReader();
const read = async () => {
try {
const ret = await reader.read();
if (ret.done) {
controller.close();
return;
}
loaded += ret.value.byteLength;
args.downProgress!({ progress: loaded, total });
controller.enqueue(ret.value);
read();
} catch (e) {
console.error(e);
controller.error(e);
}
};
read();
},
})
);
data = await resInt.blob();
}
// Do not track progress (binary file)
else data = await res.blob();
status = res.status;
}
// Handle expired tokens
if (status === 412) {
AuthApi.UnsetAuthenticated();
window.location.href = "/";
}
if (!args.allowFail && (status < 200 || status > 299))
throw new ApiError("Request failed!", status, data);
return {
data: data,
status: status,
};
}
}

View File

@@ -0,0 +1,91 @@
import { APIClient } from "./ApiClient";
export interface UserInfo {
id: number;
time_create: number;
time_update: number;
name: string;
email: string;
matrix_account_connected: boolean;
matrix_user_id?: string;
matrix_device_id?: string;
matrix_recovery_state?: "Enabled" | "Disabled" | "Unknown" | "Incomplete";
}
const TokenStateKey = "auth-state";
export class AuthApi {
/**
* Check out whether user is signed in or not
*/
static get SignedIn(): boolean {
return localStorage.getItem(TokenStateKey) !== null;
}
/**
* Mark user as authenticated
*/
static SetAuthenticated() {
localStorage.setItem(TokenStateKey, "");
}
/**
* Un-mark user as authenticated
*/
static UnsetAuthenticated() {
localStorage.removeItem(TokenStateKey);
}
/**
* Start OpenID login
*/
static async StartOpenIDLogin(): Promise<{ url: string }> {
return (
await APIClient.exec({
uri: "/auth/start_oidc",
method: "GET",
})
).data;
}
/**
* Finish OpenID login
*/
static async FinishOpenIDLogin(code: string, state: string): Promise<void> {
await APIClient.exec({
uri: "/auth/finish_oidc",
method: "POST",
jsonData: { code: code, state: state },
});
this.SetAuthenticated();
}
/**
* Get user information
*/
static async GetUserInfo(): Promise<UserInfo> {
return (
await APIClient.exec({
uri: "/auth/info",
method: "GET",
})
).data;
}
/**
* Sign out
*/
static async SignOut(): Promise<void> {
try {
await APIClient.exec({
uri: "/auth/sign_out",
method: "GET",
});
} catch (e) {
console.error("Failed to sign out user on API!", e);
}
this.UnsetAuthenticated();
}
}

View File

@@ -0,0 +1,47 @@
import { APIClient } from "./ApiClient";
export class MatrixLinkApi {
/**
* Start Matrix Account login
*/
static async StartAuth(): Promise<{ url: string }> {
return (
await APIClient.exec({
uri: "/matrix_link/start_auth",
method: "POST",
})
).data;
}
/**
* Finish Matrix Account login
*/
static async FinishAuth(code: string, state: string): Promise<void> {
await APIClient.exec({
uri: "/matrix_link/finish_auth",
method: "POST",
jsonData: { code, state },
});
}
/**
* Disconnect from Matrix Account
*/
static async Disconnect(): Promise<void> {
await APIClient.exec({
uri: "/matrix_link/logout",
method: "POST",
});
}
/**
* Set a new user recovery key
*/
static async SetRecoveryKey(key: string): Promise<void> {
await APIClient.exec({
uri: "/matrix_link/set_recovery_key",
method: "POST",
jsonData: { key },
});
}
}

View File

@@ -0,0 +1,34 @@
import { APIClient } from "./ApiClient";
export class MatrixSyncApi {
/**
* Start sync thread
*/
static async Start(): Promise<void> {
await APIClient.exec({
method: "POST",
uri: "/matrix_sync/start",
});
}
/**
* Stop sync thread
*/
static async Stop(): Promise<void> {
await APIClient.exec({
method: "POST",
uri: "/matrix_sync/stop",
});
}
/**
* Get sync thread status
*/
static async Status(): Promise<boolean> {
const res = await APIClient.exec({
method: "GET",
uri: "/matrix_sync/status",
});
return res.data.started;
}
}

View File

@@ -0,0 +1,48 @@
import { APIClient } from "./ApiClient";
export interface ServerConfig {
auth_disabled: boolean;
oidc_provider_name: string;
constraints: ServerConstraints;
}
export interface AccountType {
label: string;
code: string;
icon: string;
}
export interface ServerConstraints {
token_name: LenConstraint;
token_ip_net: LenConstraint;
token_max_inactivity: LenConstraint;
}
export interface LenConstraint {
min: number;
max: number;
}
let config: ServerConfig | null = null;
export class ServerApi {
/**
* Get server configuration
*/
static async LoadConfig(): Promise<void> {
config = (
await APIClient.exec({
uri: "/server/config",
method: "GET",
})
).data;
}
/**
* Get cached configuration
*/
static get Config(): ServerConfig {
if (config === null) throw new Error("Missing configuration!");
return config;
}
}

View File

@@ -0,0 +1,58 @@
import { APIClient } from "./ApiClient";
export interface BaseToken {
name: string;
networks?: string[];
max_inactivity: number;
expiration?: number;
read_only: boolean;
}
export interface Token extends BaseToken {
id: number;
created: number;
last_used: number;
}
export interface TokenWithSecret extends Token {
secret: string;
}
export class TokensApi {
/**
* Get the list of tokens of the current user
*/
static async GetList(): Promise<Token[]> {
return (
await APIClient.exec({
uri: "/tokens",
method: "GET",
})
).data;
}
/**
* Create a new token
*/
static async Create(t: BaseToken): Promise<TokenWithSecret> {
return (
await APIClient.exec({
uri: "/token",
method: "POST",
jsonData: t,
})
).data;
}
/**
* Delete a token
*/
static async Delete(t: Token): Promise<void> {
return (
await APIClient.exec({
uri: `/token/${t.id}`,
method: "DELETE",
})
).data;
}
}

View File

@@ -0,0 +1,15 @@
import { APIClient } from "./ApiClient";
export type WsMessage = {
type: string;
[k: string]: any;
};
export class WsApi {
/**
* Get WebSocket URL
*/
static get WsURL(): string {
return APIClient.backendURL() + "/ws";
}
}

View File

@@ -0,0 +1,70 @@
import { APIClient } from "../ApiClient";
import type { Room } from "./MatrixApiRoom";
export interface MatrixRoomMessage {
type: "m.room.message";
content: {
body: string;
msgtype: "m.text" | "m.image" | string;
"m.relates_to"?: {
event_id: string;
rel_type: "m.replace" | string;
};
file?: {
url: string;
};
};
}
export interface MatrixReaction {
type: "m.reaction";
content: {
"m.relates_to": {
event_id: string;
key: string;
};
};
}
export interface MatrixRoomRedaction {
type: "m.room.redaction";
redacts: string;
}
export type MatrixEventData =
| MatrixRoomMessage
| MatrixReaction
| MatrixRoomRedaction
| { type: "other" };
export interface MatrixEvent {
id: string;
time: number;
sender: string;
data: MatrixEventData;
}
export interface MatrixEventsList {
start: string;
end?: string;
events: MatrixEvent[];
}
export class MatrixApiEvent {
/**
* Get Matrix room events
*/
static async GetRoomEvents(
room: Room,
from?: string
): Promise<MatrixEventsList> {
return (
await APIClient.exec({
method: "GET",
uri:
`/matrix/room/${encodeURIComponent(room.id)}/events` +
(from ? `?from=${from}` : ""),
})
).data;
}
}

View File

@@ -0,0 +1,12 @@
import { APIClient } from "../ApiClient";
export class MatrixApiMedia {
/**
* Get media URL
*/
static MediaURL(url: string, thumbnail: boolean): string {
return `${APIClient.ActualBackendURL()}/matrix/media/${encodeURIComponent(
url
)}?thumbnail=${thumbnail}`;
}
}

View File

@@ -0,0 +1,26 @@
import { APIClient } from "../ApiClient";
export interface UserProfile {
user_id: string;
display_name?: string;
avatar?: string;
}
export type UsersMap = Map<string, UserProfile>;
export class MatrixApiProfile {
/**
* Get multiple profiles information
*/
static async GetMultiple(ids: string[]): Promise<UsersMap> {
const list: UserProfile[] = (
await APIClient.exec({
method: "POST",
uri: "/matrix/profile/get_multiple",
jsonData: ids,
})
).data;
return new Map(list.map((e) => [e.user_id, e]));
}
}

View File

@@ -0,0 +1,55 @@
import { APIClient } from "../ApiClient";
import type { UserInfo } from "../AuthApi";
import type { MatrixEvent } from "./MatrixApiEvent";
import type { UsersMap } from "./MatrixApiProfile";
export interface Room {
id: string;
name?: string;
members: string[];
avatar?: string;
is_space?: boolean;
parents: string[];
number_unread_messages: number;
latest_event?: MatrixEvent;
}
/**
* Find main member of room
*/
export function mainRoomMember(user: UserInfo, r: Room): string | undefined {
if (r.members.length <= 1) return r.members[0];
if (r.members.length < 2)
return r.members[0] == user.matrix_user_id ? r.members[1] : r.members[0];
return undefined;
}
/**
* Find room name
*/
export function roomName(user: UserInfo, r: Room, users: UsersMap): string {
if (r.name) return r.name;
const name = r.members
.filter((m) => m !== user.matrix_user_id)
.map((m) => users.get(m)?.display_name ?? m)
.join(",");
return name === "" ? "Empty room" : name;
}
export class MatrixApiRoom {
/**
* Get the list of joined rooms
*/
static async ListJoined(): Promise<Room[]> {
return (
await APIClient.exec({
method: "GET",
uri: "/matrix/room/joined",
})
).data;
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,159 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from "@mui/material";
import React from "react";
import { ServerApi } from "../api/ServerApi";
import {
TokensApi,
type BaseToken,
type TokenWithSecret,
} from "../api/TokensApi";
import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
import { useLoadingMessage } from "../hooks/contexts_provider/LoadingMessageProvider";
import { time } from "../utils/DateUtils";
import {
checkConstraint,
checkNumberConstraint,
isIPNetworkValid,
} from "../utils/FormUtils";
import { CheckboxInput } from "../widgets/forms/CheckboxInput";
import { DateInput } from "../widgets/forms/DateInput";
import { NetworksInput } from "../widgets/forms/NetworksInput";
import { TextInput } from "../widgets/forms/TextInput";
const SECS_IN_DAY = 3600 * 24;
export function CreateTokenDialog(p: {
open: boolean;
onClose: () => void;
onCreated: (t: TokenWithSecret) => void;
}): React.ReactElement {
const alert = useAlert();
const loadingMessage = useLoadingMessage();
const [newTokenUndef, setNewToken] = React.useState<BaseToken | undefined>();
const newToken: BaseToken = newTokenUndef ?? {
name: "",
max_inactivity: 3600 * 24 * 90,
read_only: false,
};
const valid =
checkConstraint(ServerApi.Config.constraints.token_name, newToken.name) ===
undefined &&
checkNumberConstraint(
ServerApi.Config.constraints.token_max_inactivity,
newToken.max_inactivity
) === undefined &&
(newToken.networks === undefined ||
newToken.networks.every((n) => isIPNetworkValid(n)));
const handleSubmit = async () => {
try {
loadingMessage.show("Creating access token...");
const token = await TokensApi.Create(newToken);
p.onCreated(token);
// Clear form
setNewToken(undefined);
} catch (e) {
console.error(`Failed to create token! ${e}`);
alert(`Failed to create API token! ${e}`);
} finally {
loadingMessage.hide();
}
};
return (
<Dialog open={p.open} onClose={p.onClose}>
<DialogTitle>Create new API token</DialogTitle>
<DialogContent>
<TextInput
editable
required
label="Token name"
value={newToken.name}
onValueChange={(v) => {
setNewToken({
...newToken,
name: v ?? "",
});
}}
size={ServerApi.Config.constraints.token_name}
/>
<NetworksInput
editable
label="Allowed networks (CIDR notation)"
value={newToken.networks}
onChange={(v) => {
setNewToken({
...newToken,
networks: v,
});
}}
/>
<TextInput
editable
required
label="Max inactivity period (days)"
type="number"
value={(newToken.max_inactivity / SECS_IN_DAY).toString()}
onValueChange={(i) => {
setNewToken({
...newToken,
max_inactivity: Number(i) * SECS_IN_DAY,
});
}}
size={{
min:
ServerApi.Config.constraints.token_max_inactivity.min /
SECS_IN_DAY,
max:
ServerApi.Config.constraints.token_max_inactivity.max /
SECS_IN_DAY,
}}
/>
<DateInput
editable
label="Expiration date (optional)"
value={newToken.expiration}
onChange={(i) => {
setNewToken((t) => {
return {
...(t ?? newToken),
expiration: i ?? undefined,
};
});
}}
disablePast
checkValue={(s) => s > time()}
/>
<CheckboxInput
editable
label="Read only"
checked={newToken.read_only}
onValueChange={(v) => {
setNewToken({
...newToken,
read_only: v,
});
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={p.onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!valid} autoFocus>
Create token
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,73 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
TextField,
DialogActions,
Button,
} from "@mui/material";
import { MatrixLinkApi } from "../api/MatrixLinkApi";
import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
import { useLoadingMessage } from "../hooks/contexts_provider/LoadingMessageProvider";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
import React from "react";
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
export function SetRecoveryKeyDialog(p: {
open: boolean;
onClose: () => void;
}): React.ReactElement {
const alert = useAlert();
const snackbar = useSnackbar();
const loadingMessage = useLoadingMessage();
const user = useUserInfo();
const [newKey, setNewKey] = React.useState("");
const handleSubmitKey = async () => {
try {
loadingMessage.show("Updating recovery key...");
await MatrixLinkApi.SetRecoveryKey(newKey);
setNewKey("");
p.onClose();
snackbar("Recovery key successfully updated!");
user.reloadUserInfo();
} catch (e) {
console.error(`Failed to set new recovery key! ${e}`);
alert(`Failed to set new recovery key! ${e}`);
} finally {
loadingMessage.hide();
}
};
return (
<Dialog open={p.open} onClose={p.onClose}>
<DialogTitle>Set new recovery key</DialogTitle>
<DialogContent>
<DialogContentText>
Enter below you recovery key to verify this session and gain access to
old messages.
</DialogContentText>
<TextField
label="Recovery key"
type="text"
variant="standard"
autoComplete="off"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={p.onClose}>Cancel</Button>
<Button onClick={handleSubmitKey} disabled={newKey === ""} autoFocus>
Submit
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,68 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import React, { type PropsWithChildren } from "react";
type AlertContext = (message: string, title?: string) => Promise<void>;
const AlertContextK = React.createContext<AlertContext | null>(null);
export function AlertDialogProvider(p: PropsWithChildren): React.ReactElement {
const [open, setOpen] = React.useState(false);
const [title, setTitle] = React.useState<string | undefined>(undefined);
const [message, setMessage] = React.useState("");
const cb = React.useRef<null | (() => void)>(null);
const handleClose = () => {
setOpen(false);
if (cb.current !== null) cb.current();
cb.current = null;
};
const hook: AlertContext = (message, title) => {
setTitle(title);
setMessage(message);
setOpen(true);
return new Promise((res) => {
cb.current = res;
});
};
return (
<>
<AlertContextK value={hook}>{p.children}</AlertContextK>
<Dialog
open={open}
onClose={handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
{title && <DialogTitle id="alert-dialog-title">{title}</DialogTitle>}
<DialogContent>
<DialogContentText id="alert-dialog-description">
{message}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} autoFocus>
Ok
</Button>
</DialogActions>
</Dialog>
</>
);
}
export function useAlert(): AlertContext {
return React.use(AlertContextK)!;
}

View File

@@ -0,0 +1,98 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import React, { type PropsWithChildren } from "react";
type ConfirmContext = (
message: string | React.ReactElement,
title?: string,
confirmButton?: string
) => Promise<boolean>;
const ConfirmContextK = React.createContext<ConfirmContext | null>(null);
export function ConfirmDialogProvider(
p: PropsWithChildren
): React.ReactElement {
const [open, setOpen] = React.useState(false);
const [title, setTitle] = React.useState<string | undefined>(undefined);
const [message, setMessage] = React.useState<string | React.ReactElement>("");
const [confirmButton, setConfirmButton] = React.useState<string | undefined>(
undefined
);
const cb = React.useRef<null | ((a: boolean) => void)>(null);
const handleClose = (confirm: boolean) => {
setOpen(false);
if (cb.current !== null) cb.current(confirm);
cb.current = null;
};
const hook: ConfirmContext = (message, title, confirmButton) => {
setTitle(title);
setMessage(message);
setConfirmButton(confirmButton);
setOpen(true);
return new Promise((res) => {
cb.current = res;
});
};
const keyUp = (e: React.KeyboardEvent) => {
if (e.code === "Enter") handleClose(true);
};
return (
<>
<ConfirmContextK value={hook}>{p.children}</ConfirmContextK>
<Dialog
open={open}
onClose={() => {
handleClose(false);
}}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
onKeyUp={keyUp}
>
{title && <DialogTitle id="alert-dialog-title">{title}</DialogTitle>}
<DialogContent>
<DialogContentText id="alert-dialog-description">
{message}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
handleClose(false);
}}
autoFocus
>
Cancel
</Button>
<Button
onClick={() => {
handleClose(true);
}}
color="error"
>
{confirmButton ?? "Confirm"}
</Button>
</DialogActions>
</Dialog>
</>
);
}
export function useConfirm(): ConfirmContext {
return React.use(ConfirmContextK)!;
}

View File

@@ -0,0 +1,61 @@
import {
CircularProgress,
Dialog,
DialogContent,
DialogContentText,
} from "@mui/material";
import React, { type PropsWithChildren } from "react";
interface LoadingMessageContext {
show: (message: string) => void;
hide: () => void;
}
const LoadingMessageContextK =
React.createContext<LoadingMessageContext | null>(null);
export function LoadingMessageProvider(
p: PropsWithChildren
): React.ReactElement {
const [open, setOpen] = React.useState(0);
const [message, setMessage] = React.useState("");
const hook: LoadingMessageContext = {
show(message) {
setMessage(message);
setOpen((v) => v + 1);
},
hide() {
setOpen((v) => v - 1);
},
};
return (
<>
<LoadingMessageContextK value={hook}>{p.children}</LoadingMessageContextK>
<Dialog open={open > 0}>
<DialogContent>
<DialogContentText>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<CircularProgress style={{ marginRight: "15px" }} />
{message}
</div>
</DialogContentText>
</DialogContent>
</Dialog>
</>
);
}
export function useLoadingMessage(): LoadingMessageContext {
return React.use(LoadingMessageContextK)!;
}

View File

@@ -0,0 +1,41 @@
import { Snackbar } from "@mui/material";
import React, { type PropsWithChildren } from "react";
type SnackbarContext = (message: string, duration?: number) => void;
const SnackbarContextK = React.createContext<SnackbarContext | null>(null);
export function SnackbarProvider(p: PropsWithChildren): React.ReactElement {
const [open, setOpen] = React.useState(false);
const [message, setMessage] = React.useState("");
const [duration, setDuration] = React.useState(0);
const handleClose = () => {
setOpen(false);
};
const hook: SnackbarContext = (message, duration) => {
setMessage(message);
setDuration(duration ?? 6000);
setOpen(true);
};
return (
<>
<SnackbarContextK value={hook}>{p.children}</SnackbarContextK>
<Snackbar
open={open}
autoHideDuration={duration}
onClose={handleClose}
message={message}
/>
</>
);
}
export function useSnackbar(): SnackbarContext {
return React.use(SnackbarContextK)!;
}

View File

@@ -1,68 +1,18 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
}
html,
body,
#root {
height: 100%;
}
#root {
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
flex-direction: column;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
#root > div {
flex: 1;
}

View File

@@ -1,10 +1,45 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
createRoot(document.getElementById('root')!).render(
import { CssBaseline } from "@mui/material";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ServerApi } from "./api/ServerApi";
import { App } from "./App";
import { AlertDialogProvider } from "./hooks/contexts_provider/AlertDialogProvider";
import { ConfirmDialogProvider } from "./hooks/contexts_provider/ConfirmDialogProvider";
import { LoadingMessageProvider } from "./hooks/contexts_provider/LoadingMessageProvider";
import { SnackbarProvider } from "./hooks/contexts_provider/SnackbarProvider";
import "./index.css";
import { AppTheme } from "./theme/AppTheme";
import { AsyncWidget } from "./widgets/AsyncWidget";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="en">
<AppTheme>
<CssBaseline enableColorScheme />
<AlertDialogProvider>
<ConfirmDialogProvider>
<SnackbarProvider>
<LoadingMessageProvider>
<AsyncWidget
loadKey={1}
load={async () => {
await ServerApi.LoadConfig();
}}
errMsg="Failed to load static server configuration!"
build={() => <App />}
/>
</LoadingMessageProvider>
</SnackbarProvider>
</ConfirmDialogProvider>
</AlertDialogProvider>
</AppTheme>
</LocalizationProvider>
</StrictMode>
);

View File

@@ -0,0 +1,293 @@
import AddIcon from "@mui/icons-material/Add";
import RefreshIcon from "@mui/icons-material/Refresh";
import { Alert, AlertTitle, IconButton, Tooltip } from "@mui/material";
import type { GridColDef } from "@mui/x-data-grid";
import { DataGrid, GridActionsCellItem } from "@mui/x-data-grid";
import { QRCodeCanvas } from "qrcode.react";
import React from "react";
import { APIClient } from "../api/ApiClient";
import { TokensApi, type Token, type TokenWithSecret } from "../api/TokensApi";
import { CreateTokenDialog } from "../dialogs/CreateTokenDialog";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { CopyTextChip } from "../widgets/CopyTextChip";
import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer";
import { TimeWidget } from "../widgets/TimeWidget";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
import { useConfirm } from "../hooks/contexts_provider/ConfirmDialogProvider";
import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
import { time } from "../utils/DateUtils";
export function APITokensRoute(): React.ReactElement {
const count = React.useRef(0);
const [openCreateTokenDialog, setOpenCreateTokenDialog] =
React.useState(false);
const [createdToken, setCreatedToken] =
React.useState<TokenWithSecret | null>(null);
const [list, setList] = React.useState<Token[] | undefined>();
const load = async () => {
setList(await TokensApi.GetList());
};
const handleRefreshTokensList = () => {
count.current += 1;
setList(undefined);
};
const handleOpenCreateTokenDialog = () => setOpenCreateTokenDialog(true);
const handleCancelCreateToken = () => setOpenCreateTokenDialog(false);
const handleCreatedToken = (s: TokenWithSecret) => {
setCreatedToken(s);
setOpenCreateTokenDialog(false);
handleRefreshTokensList();
};
return (
<MatrixGWRouteContainer
label={"API tokens"}
actions={
<span>
<Tooltip title="Create new token">
<IconButton onClick={handleOpenCreateTokenDialog}>
<AddIcon />
</IconButton>
</Tooltip>
&nbsp;&nbsp;
<Tooltip title="Refresh tokens list">
<IconButton onClick={handleRefreshTokensList}>
<RefreshIcon />
</IconButton>
</Tooltip>
</span>
}
>
{/* Create token dialog anchor */}
<CreateTokenDialog
open={openCreateTokenDialog}
onCreated={handleCreatedToken}
onClose={handleCancelCreateToken}
/>
{/* Info about created token */}
{createdToken && <CreatedToken token={createdToken!} />}
{/* Tokens list */}
<AsyncWidget
loadKey={count.current}
ready={list !== undefined}
load={load}
errMsg="Failed to load the list of tokens!"
build={() => (
<TokensListGrid list={list!} onReload={handleRefreshTokensList} />
)}
/>
</MatrixGWRouteContainer>
);
}
function CreatedToken(p: { token: TokenWithSecret }): React.ReactElement {
return (
<Alert severity="success" style={{ margin: "10px" }}>
<div
style={{
display: "flex",
flexDirection: "row",
}}
>
<div style={{ textAlign: "center", marginRight: "10px" }}>
<div style={{ padding: "15px", backgroundColor: "white" }}>
<QRCodeCanvas
value={`matrixgw://api=${encodeURIComponent(
APIClient.ActualBackendURL()
)}&id=${p.token.id}&secret=${p.token.secret}`}
/>
</div>
<br />
<em>Mobile App Qr Code</em>
</div>
<div>
<AlertTitle>Token successfully created</AlertTitle>
The API token <i>{p.token.name}</i> was successfully created. Please
note the following information as they won't be available after.
<br />
<br />
API URL: <CopyTextChip text={APIClient.ActualBackendURL()} />
<br />
Token ID: <CopyTextChip text={p.token.id.toString()} />
<br />
Token secret: <CopyTextChip text={p.token.secret} />
</div>
</div>
</Alert>
);
}
function TokensListGrid(p: {
list: Token[];
onReload: () => void;
}): React.ReactElement {
const snackbar = useSnackbar();
const confirm = useConfirm();
const alert = useAlert();
// Delete a token
const handleDeleteClick = (token: Token) => async () => {
try {
if (
!(await confirm(
`Do you really want to delete the token named '${token.name}' ?`
))
)
return;
await TokensApi.Delete(token);
p.onReload();
snackbar("The token was successfully deleted!");
} catch (e) {
console.error(e);
alert(`Failed to delete API token! ${e}`);
}
};
const columns: GridColDef<(typeof p.list)[number]>[] = [
{ field: "id", headerName: "ID", flex: 1 },
{
field: "name",
headerName: "Name",
flex: 3,
},
{
field: "networks",
headerName: "Networks restriction",
flex: 3,
renderCell(params) {
return (
params.row.networks?.join(", ") ?? (
<span style={{ fontStyle: "italic" }}>Unrestricted</span>
)
);
},
},
{
field: "created",
headerName: "Creation",
flex: 3,
renderCell(params) {
return <TimeWidget time={params.row.created} />;
},
},
{
field: "last_used",
headerName: "Last usage",
flex: 3,
renderCell(params) {
return (
<span
style={{
color:
params.row.last_used + params.row.max_inactivity < time()
? "red"
: undefined,
}}
>
<TimeWidget time={params.row.last_used} />
</span>
);
},
},
{
field: "max_inactivity",
headerName: "Max inactivity",
flex: 3,
renderCell(params) {
return (
<span
style={{
color:
params.row.last_used + params.row.max_inactivity < time()
? "red"
: undefined,
}}
>
<TimeWidget time={params.row.max_inactivity} isDuration />
</span>
);
},
},
{
field: "expiration",
headerName: "Expiration",
flex: 3,
renderCell(params) {
return (
<span
style={{
color:
params.row.expiration && params.row.expiration < time()
? "red"
: undefined,
}}
>
<TimeWidget time={params.row.expiration} showDate />
</span>
);
},
},
{
field: "read_only",
headerName: "Read only",
flex: 2,
type: "boolean",
},
{
field: "actions",
type: "actions",
headerName: "Actions",
flex: 2,
cellClassName: "actions",
getActions: ({ row }) => {
return [
<GridActionsCellItem
key={row.id}
icon={<DeleteIcon />}
label="Delete"
onClick={handleDeleteClick(row)}
color="inherit"
/>,
];
},
},
];
if (p.list.length === 0)
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
You do not have created any token yet!
</div>
);
return (
<DataGrid
style={{ flex: "1" }}
rows={p.list}
columns={columns}
autoPageSize
getRowId={(c) => c.id}
isCellEditable={() => false}
isRowSelectable={() => false}
/>
);
}

View File

@@ -0,0 +1,11 @@
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
import { MainMessageWidget } from "../widgets/messages/MainMessagesWidget";
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
export function HomeRoute(): React.ReactElement {
const user = useUserInfo();
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
return <MainMessageWidget />;
}

View File

@@ -0,0 +1,81 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material";
import React from "react";
import { useNavigate, useSearchParams } from "react-router";
import { MatrixLinkApi } from "../api/MatrixLinkApi";
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
import { RouterLink } from "../widgets/RouterLink";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
export function MatrixAuthCallback(): React.ReactElement {
const navigate = useNavigate();
const snackbar = useSnackbar();
const info = useUserInfo();
const [error, setError] = React.useState<null | string>(null);
const [searchParams] = useSearchParams();
const code = searchParams.get("code");
const state = searchParams.get("state");
const count = React.useRef("");
React.useEffect(() => {
const load = async () => {
try {
if (count.current === code) {
return;
}
count.current = code!;
await MatrixLinkApi.FinishAuth(code!, state!);
snackbar("Successfully linked to Matrix account!");
navigate("/matrix_link");
} catch (e) {
console.error(e);
setError(String(e));
} finally {
info.reloadUserInfo();
}
};
load();
}, [code, state]);
if (error)
return (
<Box
component="div"
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
flex: "1",
flexDirection: "column",
}}
>
<Alert
variant="outlined"
severity="error"
style={{ margin: "0px 15px 15px 15px" }}
>
Failed to finalize Matrix authentication!
<br />
<br />
{error}
</Alert>
<Button>
<RouterLink to="/matrix_link">Go back</RouterLink>
</Button>
</Box>
);
return (
<div style={{ textAlign: "center" }}>
<CircularProgress />
</div>
);
}

View File

@@ -0,0 +1,329 @@
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import KeyIcon from "@mui/icons-material/Key";
import LinkIcon from "@mui/icons-material/Link";
import LinkOffIcon from "@mui/icons-material/LinkOff";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew";
import StopIcon from "@mui/icons-material/Stop";
import {
Button,
Card,
CardActions,
CardContent,
CircularProgress,
Grid,
Typography,
} from "@mui/material";
import React from "react";
import { MatrixLinkApi } from "../api/MatrixLinkApi";
import { MatrixSyncApi } from "../api/MatrixSyncApi";
import { SetRecoveryKeyDialog } from "../dialogs/SetRecoveryKeyDialog";
import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
import { useConfirm } from "../hooks/contexts_provider/ConfirmDialogProvider";
import { useLoadingMessage } from "../hooks/contexts_provider/LoadingMessageProvider";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer";
export function MatrixLinkRoute(): React.ReactElement {
const user = useUserInfo();
return (
<MatrixGWRouteContainer label={"Matrix account link"}>
{user.info.matrix_user_id === null ? (
<ConnectCard />
) : (
<Grid container spacing={2}>
<Grid size={{ sm: 12, md: 6 }}>
<ConnectedCard />
</Grid>
<Grid size={{ sm: 12, md: 6 }}>
<EncryptionKeyStatus />
</Grid>
<Grid size={{ sm: 12, md: 6 }}>
<SyncThreadStatus />
</Grid>
</Grid>
)}
</MatrixGWRouteContainer>
);
}
function ConnectCard(): React.ReactElement {
const alert = useAlert();
const loadingMessage = useLoadingMessage();
const startMatrixConnection = async () => {
try {
loadingMessage.show("Initiating Matrix link...");
const res = await MatrixLinkApi.StartAuth();
window.location.href = res.url;
} catch (e) {
console.error(`Failed to connect to Matrix account! ${e}`);
alert(`Failed to connect to Matrix account! ${e}`);
} finally {
loadingMessage.hide();
}
};
return (
<Card>
<CardContent>
<Typography variant="h5" component="div" gutterBottom>
<i>Disconnected from your Matrix account</i>
</Typography>
<Typography variant="body1" gutterBottom>
You need to connect MatrixGW to your Matrix account to let it access
your messages.
</Typography>
</CardContent>
<CardActions>
<Button
size="small"
variant="outlined"
startIcon={<LinkIcon />}
onClick={startMatrixConnection}
>
Connect now
</Button>
</CardActions>
</Card>
);
}
function ConnectedCard(): React.ReactElement {
const snackbar = useSnackbar();
const confirm = useConfirm();
const alert = useAlert();
const loadingMessage = useLoadingMessage();
const user = useUserInfo();
const handleDisconnect = async () => {
if (!(await confirm("Do you really want to unlink your Matrix account?")))
return;
try {
loadingMessage.show("Unlinking Matrix account...");
await MatrixLinkApi.Disconnect();
snackbar("Successfully unlinked Matrix account!");
} catch (e) {
console.error(`Failed to unlink user account! ${e}`);
alert(`Failed to unlink your account! ${e}`);
} finally {
user.reloadUserInfo();
loadingMessage.hide();
}
};
return (
<Card style={{ marginBottom: "10px" }}>
<CardContent>
<Typography variant="h5" component="div" gutterBottom>
<i>Connected to your Matrix account</i>
</Typography>
<Typography variant="body1" gutterBottom>
<p>
MatrixGW is currently connected to your account with the following
information:
</p>
<ul>
<li>
User id: <i>{user.info.matrix_user_id}</i>
</li>
<li>
Device id: <i>{user.info.matrix_device_id}</i>
</li>
</ul>
<p>
If you encounter issues with your Matrix account you can try to
disconnect and connect back again.
</p>
</Typography>
</CardContent>
<CardActions>
<Button
size="small"
variant="outlined"
startIcon={<LinkOffIcon />}
onClick={handleDisconnect}
>
Disconnect
</Button>
</CardActions>
</Card>
);
}
function EncryptionKeyStatus(): React.ReactElement {
const user = useUserInfo();
const [openSetKeyDialog, setOpenSetKeyDialog] = React.useState(false);
const handleSetKey = () => setOpenSetKeyDialog(true);
const handleCloseSetKey = () => setOpenSetKeyDialog(false);
return (
<>
<Card>
<CardContent>
<Typography variant="h5" component="div" gutterBottom>
Recovery keys
</Typography>
<Typography variant="body1" gutterBottom>
<p>
Recovery key is used to verify MatrixGW connection and access
message history in encrypted rooms.
</p>
<p>
Current encryption status:{" "}
{user.info.matrix_recovery_state === "Enabled" ? (
<CheckIcon
style={{ display: "inline", verticalAlign: "middle" }}
/>
) : (
<CloseIcon
style={{ display: "inline", verticalAlign: "middle" }}
/>
)}{" "}
{user.info.matrix_recovery_state}
</p>
</Typography>
</CardContent>
<CardActions>
<Button
size="small"
variant="outlined"
startIcon={<KeyIcon />}
onClick={handleSetKey}
>
Set new recovery key
</Button>
</CardActions>
</Card>
{/* Set new key dialog */}
<SetRecoveryKeyDialog
open={openSetKeyDialog}
onClose={handleCloseSetKey}
/>
</>
);
}
function SyncThreadStatus(): React.ReactElement {
const alert = useAlert();
const snackbar = useSnackbar();
const [started, setStarted] = React.useState<undefined | boolean>();
const loadStatus = async () => {
try {
setStarted(await MatrixSyncApi.Status());
} catch (e) {
console.error(`Failed to refresh sync thread status! ${e}`);
snackbar(`Failed to refresh sync thread status! ${e}`);
}
};
const handleStartThread = async () => {
try {
setStarted(undefined);
await MatrixSyncApi.Start();
snackbar("Sync thread started");
} catch (e) {
console.error(`Failed to start sync thread! ${e}`);
alert(`Failed to start sync thread! ${e}`);
}
};
const handleStopThread = async () => {
try {
setStarted(undefined);
await MatrixSyncApi.Stop();
snackbar("Sync thread stopped");
} catch (e) {
console.error(`Failed to stop sync thread! ${e}`);
alert(`Failed to stop sync thread! ${e}`);
}
};
React.useEffect(() => {
const interval = setInterval(loadStatus, 1000);
return () => clearInterval(interval);
}, []);
return (
<>
<Card>
<CardContent>
<Typography variant="h5" component="div" gutterBottom>
Sync thread status
</Typography>
<Typography variant="body1" gutterBottom>
<p>
A thread is spawned on the server to watch for events on the
Matrix server. You can restart this thread from here in case of
issue.
</p>
<p>
Current thread status:{" "}
{started === undefined ? (
<>
<CircularProgress
size={"1rem"}
style={{ verticalAlign: "middle" }}
/>
</>
) : started === true ? (
<>
<CheckIcon
style={{ display: "inline", verticalAlign: "middle" }}
/>{" "}
Started
</>
) : (
<>
<PowerSettingsNewIcon
style={{ display: "inline", verticalAlign: "middle" }}
/>
Stopped
</>
)}
</p>
</Typography>
</CardContent>
<CardActions>
{started === false && (
<Button
size="small"
variant="outlined"
startIcon={<PlayArrowIcon />}
onClick={handleStartThread}
>
Start thread
</Button>
)}
{started === true && (
<Button
size="small"
variant="outlined"
startIcon={<StopIcon />}
onClick={handleStopThread}
>
Stop thread
</Button>
)}
</CardActions>
</Card>
</>
);
}

View File

@@ -0,0 +1,23 @@
import { Button } from "@mui/material";
import { RouterLink } from "../widgets/RouterLink";
export function NotFoundRoute(): React.ReactElement {
return (
<div
style={{
textAlign: "center",
flex: "1",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
<h1>404 Not found</h1>
<p>The page you requested was not found!</p>
<RouterLink to="/">
<Button>Go back home</Button>
</RouterLink>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import React from "react";
import { JsonView, darkStyles } from "react-json-view-lite";
import "react-json-view-lite/dist/index.css";
import { WsApi, type WsMessage } from "../api/WsApi";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
import { time } from "../utils/DateUtils";
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer";
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
const State = {
Closed: "Closed",
Connected: "Connected",
Error: "Error",
} as const;
type TimestampedMessages = WsMessage & { time: number };
export function WSDebugRoute(): React.ReactElement {
const user = useUserInfo();
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
const snackbar = useSnackbar();
const [state, setState] = React.useState<string>(State.Closed);
const wsRef = React.useRef<WebSocket | undefined>(undefined);
const [messages, setMessages] = React.useState<TimestampedMessages[]>([]);
React.useEffect(() => {
const ws = new WebSocket(WsApi.WsURL);
wsRef.current = ws;
ws.onopen = () => setState(State.Connected);
ws.onerror = (e) => {
console.error(`WS Debug error!`, e);
snackbar(`WebSocket error! ${e}`);
setState(State.Error);
};
ws.onclose = () => {
setState(State.Closed);
wsRef.current = undefined;
};
ws.onmessage = (msg) => {
const dec = JSON.parse(msg.data);
setMessages((l) => {
return [{ time: time(), ...dec }, ...l];
});
};
return () => ws.close();
}, []);
return (
<MatrixGWRouteContainer label={"WebSocket Debug"}>
<div>
State:{" "}
<span style={{ color: state == State.Connected ? "green" : "red" }}>
{state}
</span>
</div>
{messages.map((msg, id) => (
<div style={{ margin: "10px", backgroundColor: "black" }}>
<JsonView
key={id}
data={msg}
shouldExpandNode={(level) => level < 2}
style={{
...darkStyles,
container: "",
}}
/>
</div>
))}
</MatrixGWRouteContainer>
);
}

View File

@@ -0,0 +1,50 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material";
import Icon from "@mdi/react";
import { mdiOpenid } from "@mdi/js";
import { ServerApi } from "../../api/ServerApi";
import React from "react";
import { AuthApi } from "../../api/AuthApi";
export function LoginRoute(): React.ReactElement {
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const authWithOpenID = async () => {
try {
setLoading(true);
const res = await AuthApi.StartOpenIDLogin();
window.location.href = res.url;
} catch (e) {
console.error(e);
setError("Failed to initialize OpenID login");
}
};
if (loading)
return (
<div style={{ textAlign: "center" }}>
<CircularProgress />
</div>
);
return (
<>
{error && (
<Alert style={{ width: "100%" }} severity="error">
{error}
</Alert>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Button
fullWidth
variant="outlined"
onClick={authWithOpenID}
startIcon={<Icon path={mdiOpenid} size={1} />}
>
Sign in with {ServerApi.Config.oidc_provider_name}
</Button>
</Box>
</>
);
}

View File

@@ -0,0 +1,53 @@
import { CircularProgress } from "@mui/material";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { AuthApi } from "../../api/AuthApi";
import { useAuth } from "../../App";
import { AuthSingleMessage } from "../../widgets/auth/AuthSingleMessage";
/**
* OpenID login callback route
*/
export function OIDCCbRoute(): React.ReactElement {
const auth = useAuth();
const navigate = useNavigate();
const [error, setError] = useState(false);
const [searchParams] = useSearchParams();
const code = searchParams.get("code");
const state = searchParams.get("state");
const count = useRef("");
useEffect(() => {
const load = async () => {
try {
if (count.current === code) {
return;
}
count.current = code!;
await AuthApi.FinishOpenIDLogin(code!, state!);
navigate("/");
auth.setSignedIn(true);
} catch (e) {
console.error(e);
setError(true);
}
};
load();
});
if (error)
return (
<AuthSingleMessage message="Failed to finalize OpenID authentication!" />
);
return (
<div style={{ textAlign: "center" }}>
<CircularProgress />
</div>
);
}

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import type { ThemeOptions } from "@mui/material/styles";
import { inputsCustomizations } from "./customizations/inputs";
import { dataDisplayCustomizations } from "./customizations/dataDisplay";
import { feedbackCustomizations } from "./customizations/feedback";
import { navigationCustomizations } from "./customizations/navigation";
import { surfacesCustomizations } from "./customizations/surfaces";
import { colorSchemes, typography, shadows, shape } from "./themePrimitives";
interface AppThemeProps {
themeComponents?: ThemeOptions["components"];
}
export function AppTheme(
props: React.PropsWithChildren<AppThemeProps>
): React.ReactElement {
const { children, themeComponents } = props;
const theme = React.useMemo(() => {
return createTheme({
// For more details about CSS variables configuration, see https://mui.com/material-ui/customization/css-theme-variables/configuration/
cssVariables: {
colorSchemeSelector: "data-mui-color-scheme",
cssVarPrefix: "template",
},
colorSchemes, // Recently added in v6 for building light & dark mode app, see https://mui.com/material-ui/customization/palette/#color-schemes
typography,
shadows,
shape,
components: {
...inputsCustomizations,
...dataDisplayCustomizations,
...feedbackCustomizations,
...navigationCustomizations,
...surfacesCustomizations,
...themeComponents,
},
});
}, [themeComponents]);
return (
<ThemeProvider theme={theme} disableTransitionOnChange>
{children}
</ThemeProvider>
);
}

View File

@@ -0,0 +1,2 @@
# Application Theme
Taken from https://github.com/mui/material-ui/tree/v7.3.4/docs/data/material/getting-started/templates/shared-theme

View File

@@ -0,0 +1,233 @@
import { buttonBaseClasses } from "@mui/material/ButtonBase";
import { chipClasses } from "@mui/material/Chip";
import { iconButtonClasses } from "@mui/material/IconButton";
import { alpha, type Components, type Theme } from "@mui/material/styles";
import { svgIconClasses } from "@mui/material/SvgIcon";
import { typographyClasses } from "@mui/material/Typography";
import { gray, green, red } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const dataDisplayCustomizations: Components<Theme> = {
MuiList: {
styleOverrides: {
root: {
padding: "8px",
display: "flex",
flexDirection: "column",
gap: 0,
},
},
},
MuiListItem: {
styleOverrides: {
root: ({ theme }) => ({
[`& .${svgIconClasses.root}`]: {
width: "1rem",
height: "1rem",
color: (theme.vars || theme).palette.text.secondary,
},
[`& .${typographyClasses.root}`]: {
fontWeight: 500,
},
[`& .${buttonBaseClasses.root}`]: {
display: "flex",
gap: 8,
padding: "2px 8px",
borderRadius: (theme.vars || theme).shape.borderRadius,
opacity: 0.7,
"&.Mui-selected": {
opacity: 1,
backgroundColor: alpha(theme.palette.action.selected, 0.3),
[`& .${svgIconClasses.root}`]: {
color: (theme.vars || theme).palette.text.primary,
},
"&:focus-visible": {
backgroundColor: alpha(theme.palette.action.selected, 0.3),
},
"&:hover": {
backgroundColor: alpha(theme.palette.action.selected, 0.5),
},
},
"&:focus-visible": {
backgroundColor: "transparent",
},
},
}),
},
},
MuiListItemText: {
styleOverrides: {
primary: ({ theme }) => ({
fontSize: theme.typography.body2.fontSize,
fontWeight: 500,
lineHeight: theme.typography.body2.lineHeight,
}),
secondary: ({ theme }) => ({
fontSize: theme.typography.caption.fontSize,
lineHeight: theme.typography.caption.lineHeight,
}),
},
},
MuiListSubheader: {
styleOverrides: {
root: ({ theme }) => ({
backgroundColor: "transparent",
padding: "4px 8px",
fontSize: theme.typography.caption.fontSize,
fontWeight: 500,
lineHeight: theme.typography.caption.lineHeight,
}),
},
},
MuiListItemIcon: {
styleOverrides: {
root: {
minWidth: 0,
},
},
},
MuiChip: {
defaultProps: {
size: "small",
},
styleOverrides: {
root: ({ theme }) => ({
border: "1px solid",
borderRadius: "999px",
[`& .${chipClasses.label}`]: {
fontWeight: 600,
},
variants: [
{
props: {
color: "default",
},
style: {
borderColor: gray[200],
backgroundColor: gray[100],
[`& .${chipClasses.label}`]: {
color: gray[500],
},
[`& .${chipClasses.icon}`]: {
color: gray[500],
},
...theme.applyStyles("dark", {
borderColor: gray[700],
backgroundColor: gray[800],
[`& .${chipClasses.label}`]: {
color: gray[300],
},
[`& .${chipClasses.icon}`]: {
color: gray[300],
},
}),
},
},
{
props: {
color: "success",
},
style: {
borderColor: green[200],
backgroundColor: green[50],
[`& .${chipClasses.label}`]: {
color: green[500],
},
[`& .${chipClasses.icon}`]: {
color: green[500],
},
...theme.applyStyles("dark", {
borderColor: green[800],
backgroundColor: green[900],
[`& .${chipClasses.label}`]: {
color: green[300],
},
[`& .${chipClasses.icon}`]: {
color: green[300],
},
}),
},
},
{
props: {
color: "error",
},
style: {
borderColor: red[100],
backgroundColor: red[50],
[`& .${chipClasses.label}`]: {
color: red[500],
},
[`& .${chipClasses.icon}`]: {
color: red[500],
},
...theme.applyStyles("dark", {
borderColor: red[800],
backgroundColor: red[900],
[`& .${chipClasses.label}`]: {
color: red[200],
},
[`& .${chipClasses.icon}`]: {
color: red[300],
},
}),
},
},
{
props: { size: "small" },
style: {
maxHeight: 20,
[`& .${chipClasses.label}`]: {
fontSize: theme.typography.caption.fontSize,
},
[`& .${svgIconClasses.root}`]: {
fontSize: theme.typography.caption.fontSize,
},
},
},
{
props: { size: "medium" },
style: {
[`& .${chipClasses.label}`]: {
fontSize: theme.typography.caption.fontSize,
},
},
},
],
}),
},
},
MuiTablePagination: {
styleOverrides: {
actions: {
display: "flex",
gap: 8,
marginRight: 6,
[`& .${iconButtonClasses.root}`]: {
minWidth: 0,
width: 36,
height: 36,
},
},
},
},
MuiIcon: {
defaultProps: {
fontSize: "small",
},
styleOverrides: {
root: {
variants: [
{
props: {
fontSize: "small",
},
style: {
fontSize: "1rem",
},
},
],
},
},
},
};

View File

@@ -0,0 +1,46 @@
import { type Theme, alpha, type Components } from "@mui/material/styles";
import { gray, orange } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const feedbackCustomizations: Components<Theme> = {
MuiAlert: {
styleOverrides: {
root: ({ theme }) => ({
borderRadius: 10,
backgroundColor: orange[100],
color: (theme.vars || theme).palette.text.primary,
border: `1px solid ${alpha(orange[300], 0.5)}`,
"& .MuiAlert-icon": {
color: orange[500],
},
...theme.applyStyles("dark", {
backgroundColor: `${alpha(orange[900], 0.5)}`,
border: `1px solid ${alpha(orange[800], 0.5)}`,
}),
}),
},
},
MuiDialog: {
styleOverrides: {
root: ({ theme }) => ({
"& .MuiDialog-paper": {
borderRadius: "10px",
border: "1px solid",
borderColor: (theme.vars || theme).palette.divider,
},
}),
},
},
MuiLinearProgress: {
styleOverrides: {
root: ({ theme }) => ({
height: 8,
borderRadius: 8,
backgroundColor: gray[200],
...theme.applyStyles("dark", {
backgroundColor: gray[800],
}),
}),
},
},
};

View File

@@ -0,0 +1,452 @@
import { alpha, type Theme, type Components } from "@mui/material/styles";
import { outlinedInputClasses } from "@mui/material/OutlinedInput";
import { svgIconClasses } from "@mui/material/SvgIcon";
import { toggleButtonGroupClasses } from "@mui/material/ToggleButtonGroup";
import { toggleButtonClasses } from "@mui/material/ToggleButton";
import CheckBoxOutlineBlankRoundedIcon from "@mui/icons-material/CheckBoxOutlineBlankRounded";
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded";
import { gray, brand } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const inputsCustomizations: Components<Theme> = {
MuiButtonBase: {
defaultProps: {
disableTouchRipple: true,
disableRipple: true,
},
styleOverrides: {
root: ({ theme }) => ({
boxSizing: "border-box",
transition: "all 100ms ease-in",
"&:focus-visible": {
outline: `3px solid ${alpha(theme.palette.primary.main, 0.5)}`,
outlineOffset: "2px",
},
}),
},
},
MuiButton: {
styleOverrides: {
root: ({ theme }) => ({
boxShadow: "none",
borderRadius: (theme.vars || theme).shape.borderRadius,
textTransform: "none",
variants: [
{
props: {
size: "small",
},
style: {
height: "2.25rem",
padding: "8px 12px",
},
},
{
props: {
size: "medium",
},
style: {
height: "2.5rem", // 40px
},
},
{
props: {
color: "primary",
variant: "contained",
},
style: {
color: "white",
backgroundColor: gray[900],
backgroundImage: `linear-gradient(to bottom, ${gray[700]}, ${gray[800]})`,
boxShadow: `inset 0 1px 0 ${gray[600]}, inset 0 -1px 0 1px hsl(220, 0%, 0%)`,
border: `1px solid ${gray[700]}`,
"&:hover": {
backgroundImage: "none",
backgroundColor: gray[700],
boxShadow: "none",
},
"&:active": {
backgroundColor: gray[800],
},
...theme.applyStyles("dark", {
color: "black",
backgroundColor: gray[50],
backgroundImage: `linear-gradient(to bottom, ${gray[100]}, ${gray[50]})`,
boxShadow: "inset 0 -1px 0 hsl(220, 30%, 80%)",
border: `1px solid ${gray[50]}`,
"&:hover": {
backgroundImage: "none",
backgroundColor: gray[300],
boxShadow: "none",
},
"&:active": {
backgroundColor: gray[400],
},
}),
},
},
{
props: {
color: "secondary",
variant: "contained",
},
style: {
color: "white",
backgroundColor: brand[300],
backgroundImage: `linear-gradient(to bottom, ${alpha(
brand[400],
0.8
)}, ${brand[500]})`,
boxShadow: `inset 0 2px 0 ${alpha(
brand[200],
0.2
)}, inset 0 -2px 0 ${alpha(brand[700], 0.4)}`,
border: `1px solid ${brand[500]}`,
"&:hover": {
backgroundColor: brand[700],
boxShadow: "none",
},
"&:active": {
backgroundColor: brand[700],
backgroundImage: "none",
},
},
},
{
props: {
variant: "outlined",
},
style: {
color: (theme.vars || theme).palette.text.primary,
border: "1px solid",
borderColor: gray[200],
backgroundColor: alpha(gray[50], 0.3),
"&:hover": {
backgroundColor: gray[100],
borderColor: gray[300],
},
"&:active": {
backgroundColor: gray[200],
},
...theme.applyStyles("dark", {
backgroundColor: gray[800],
borderColor: gray[700],
"&:hover": {
backgroundColor: gray[900],
borderColor: gray[600],
},
"&:active": {
backgroundColor: gray[900],
},
}),
},
},
{
props: {
color: "secondary",
variant: "outlined",
},
style: {
color: brand[700],
border: "1px solid",
borderColor: brand[200],
backgroundColor: brand[50],
"&:hover": {
backgroundColor: brand[100],
borderColor: brand[400],
},
"&:active": {
backgroundColor: alpha(brand[200], 0.7),
},
...theme.applyStyles("dark", {
color: brand[50],
border: "1px solid",
borderColor: brand[900],
backgroundColor: alpha(brand[900], 0.3),
"&:hover": {
borderColor: brand[700],
backgroundColor: alpha(brand[900], 0.6),
},
"&:active": {
backgroundColor: alpha(brand[900], 0.5),
},
}),
},
},
{
props: {
variant: "text",
},
style: {
color: gray[600],
"&:hover": {
backgroundColor: gray[100],
},
"&:active": {
backgroundColor: gray[200],
},
...theme.applyStyles("dark", {
color: gray[50],
"&:hover": {
backgroundColor: gray[700],
},
"&:active": {
backgroundColor: alpha(gray[700], 0.7),
},
}),
},
},
{
props: {
color: "secondary",
variant: "text",
},
style: {
color: brand[700],
"&:hover": {
backgroundColor: alpha(brand[100], 0.5),
},
"&:active": {
backgroundColor: alpha(brand[200], 0.7),
},
...theme.applyStyles("dark", {
color: brand[100],
"&:hover": {
backgroundColor: alpha(brand[900], 0.5),
},
"&:active": {
backgroundColor: alpha(brand[900], 0.3),
},
}),
},
},
],
}),
},
},
MuiIconButton: {
styleOverrides: {
root: ({ theme }) => ({
boxShadow: "none",
borderRadius: (theme.vars || theme).shape.borderRadius,
textTransform: "none",
fontWeight: theme.typography.fontWeightMedium,
letterSpacing: 0,
color: (theme.vars || theme).palette.text.primary,
border: "1px solid ",
borderColor: gray[200],
backgroundColor: alpha(gray[50], 0.3),
"&:hover": {
backgroundColor: gray[100],
borderColor: gray[300],
},
"&:active": {
backgroundColor: gray[200],
},
...theme.applyStyles("dark", {
backgroundColor: gray[800],
borderColor: gray[700],
"&:hover": {
backgroundColor: gray[900],
borderColor: gray[600],
},
"&:active": {
backgroundColor: gray[900],
},
}),
variants: [
{
props: {
size: "small",
},
style: {
width: "2.25rem",
height: "2.25rem",
padding: "0.25rem",
[`& .${svgIconClasses.root}`]: { fontSize: "1rem" },
},
},
{
props: {
size: "medium",
},
style: {
width: "2.5rem",
height: "2.5rem",
},
},
],
}),
},
},
MuiToggleButtonGroup: {
styleOverrides: {
root: ({ theme }) => ({
borderRadius: "10px",
boxShadow: `0 4px 16px ${alpha(gray[400], 0.2)}`,
[`& .${toggleButtonGroupClasses.selected}`]: {
color: brand[500],
},
...theme.applyStyles("dark", {
[`& .${toggleButtonGroupClasses.selected}`]: {
color: "#fff",
},
boxShadow: `0 4px 16px ${alpha(brand[700], 0.5)}`,
}),
}),
},
},
MuiToggleButton: {
styleOverrides: {
root: ({ theme }) => ({
padding: "12px 16px",
textTransform: "none",
borderRadius: "10px",
fontWeight: 500,
...theme.applyStyles("dark", {
color: gray[400],
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.5)",
[`&.${toggleButtonClasses.selected}`]: {
color: brand[300],
},
}),
}),
},
},
MuiCheckbox: {
defaultProps: {
disableRipple: true,
icon: (
<CheckBoxOutlineBlankRoundedIcon
sx={{ color: "hsla(210, 0%, 0%, 0.0)" }}
/>
),
checkedIcon: <CheckRoundedIcon sx={{ height: 14, width: 14 }} />,
indeterminateIcon: <RemoveRoundedIcon sx={{ height: 14, width: 14 }} />,
},
styleOverrides: {
root: ({ theme }) => ({
margin: 10,
height: 16,
width: 16,
borderRadius: 5,
border: "1px solid ",
borderColor: alpha(gray[300], 0.8),
boxShadow: "0 0 0 1.5px hsla(210, 0%, 0%, 0.04) inset",
backgroundColor: alpha(gray[100], 0.4),
transition: "border-color, background-color, 120ms ease-in",
"&:hover": {
borderColor: brand[300],
},
"&.Mui-focusVisible": {
outline: `3px solid ${alpha(brand[500], 0.5)}`,
outlineOffset: "2px",
borderColor: brand[400],
},
"&.Mui-checked": {
color: "white",
backgroundColor: brand[500],
borderColor: brand[500],
boxShadow: `none`,
"&:hover": {
backgroundColor: brand[600],
},
},
...theme.applyStyles("dark", {
borderColor: alpha(gray[700], 0.8),
boxShadow: "0 0 0 1.5px hsl(210, 0%, 0%) inset",
backgroundColor: alpha(gray[900], 0.8),
"&:hover": {
borderColor: brand[300],
},
"&.Mui-focusVisible": {
borderColor: brand[400],
outline: `3px solid ${alpha(brand[500], 0.5)}`,
outlineOffset: "2px",
},
}),
}),
},
},
MuiInputBase: {
styleOverrides: {
root: {
border: "none",
},
input: {
"&::placeholder": {
opacity: 0.7,
color: gray[500],
},
},
},
},
MuiOutlinedInput: {
styleOverrides: {
input: {
padding: 0,
},
root: ({ theme }) => ({
padding: "8px 12px",
color: (theme.vars || theme).palette.text.primary,
borderRadius: (theme.vars || theme).shape.borderRadius,
border: `1px solid ${(theme.vars || theme).palette.divider}`,
backgroundColor: (theme.vars || theme).palette.background.default,
transition: "border 120ms ease-in",
"&:hover": {
borderColor: gray[400],
},
[`&.${outlinedInputClasses.focused}`]: {
outline: `3px solid ${alpha(brand[500], 0.5)}`,
borderColor: brand[400],
},
...theme.applyStyles("dark", {
"&:hover": {
borderColor: gray[500],
},
}),
variants: [
{
props: {
size: "small",
},
style: {
height: "2.25rem",
},
},
{
props: {
size: "medium",
},
style: {
height: "2.5rem",
},
},
],
}),
notchedOutline: {
border: "none",
},
},
},
MuiInputAdornment: {
styleOverrides: {
root: ({ theme }) => ({
color: (theme.vars || theme).palette.grey[500],
...theme.applyStyles("dark", {
color: (theme.vars || theme).palette.grey[400],
}),
}),
},
},
MuiFormLabel: {
styleOverrides: {
root: ({ theme }) => ({
typography: theme.typography.caption,
marginBottom: 8,
}),
},
},
};

View File

@@ -0,0 +1,284 @@
import * as React from "react";
import { type Theme, alpha, type Components } from "@mui/material/styles";
import { type SvgIconProps } from "@mui/material/SvgIcon";
import { buttonBaseClasses } from "@mui/material/ButtonBase";
import { dividerClasses } from "@mui/material/Divider";
import { menuItemClasses } from "@mui/material/MenuItem";
import { selectClasses } from "@mui/material/Select";
import { tabClasses } from "@mui/material/Tab";
import UnfoldMoreRoundedIcon from "@mui/icons-material/UnfoldMoreRounded";
import { gray, brand } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const navigationCustomizations: Components<Theme> = {
MuiMenuItem: {
styleOverrides: {
root: ({ theme }) => ({
borderRadius: (theme.vars || theme).shape.borderRadius,
padding: "6px 8px",
[`&.${menuItemClasses.focusVisible}`]: {
backgroundColor: "transparent",
},
[`&.${menuItemClasses.selected}`]: {
[`&.${menuItemClasses.focusVisible}`]: {
backgroundColor: alpha(theme.palette.action.selected, 0.3),
},
},
}),
},
},
MuiMenu: {
styleOverrides: {
list: {
gap: "0px",
[`&.${dividerClasses.root}`]: {
margin: "0 -8px",
},
},
paper: ({ theme }) => ({
marginTop: "4px",
borderRadius: (theme.vars || theme).shape.borderRadius,
border: `1px solid ${(theme.vars || theme).palette.divider}`,
backgroundImage: "none",
background: "hsl(0, 0%, 100%)",
boxShadow:
"hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px",
[`& .${buttonBaseClasses.root}`]: {
"&.Mui-selected": {
backgroundColor: alpha(theme.palette.action.selected, 0.3),
},
},
...theme.applyStyles("dark", {
background: gray[900],
boxShadow:
"hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px",
}),
}),
},
},
MuiSelect: {
defaultProps: {
IconComponent: React.forwardRef<SVGSVGElement, SvgIconProps>(
(props, ref) => (
<UnfoldMoreRoundedIcon fontSize="small" {...props} ref={ref} />
)
),
},
styleOverrides: {
root: ({ theme }) => ({
borderRadius: (theme.vars || theme).shape.borderRadius,
border: "1px solid",
borderColor: gray[200],
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: `inset 0 1px 0 1px hsla(220, 0%, 100%, 0.6), inset 0 -1px 0 1px hsla(220, 35%, 90%, 0.5)`,
"&:hover": {
borderColor: gray[300],
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: "none",
},
[`&.${selectClasses.focused}`]: {
outlineOffset: 0,
borderColor: gray[400],
},
"&:before, &:after": {
display: "none",
},
...theme.applyStyles("dark", {
borderRadius: (theme.vars || theme).shape.borderRadius,
borderColor: gray[700],
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: `inset 0 1px 0 1px ${alpha(
gray[700],
0.15
)}, inset 0 -1px 0 1px hsla(220, 0%, 0%, 0.7)`,
"&:hover": {
borderColor: alpha(gray[700], 0.7),
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: "none",
},
[`&.${selectClasses.focused}`]: {
outlineOffset: 0,
borderColor: gray[900],
},
"&:before, &:after": {
display: "none",
},
}),
}),
select: ({ theme }) => ({
display: "flex",
alignItems: "center",
...theme.applyStyles("dark", {
display: "flex",
alignItems: "center",
"&:focus-visible": {
backgroundColor: gray[900],
},
}),
}),
},
},
MuiLink: {
defaultProps: {
underline: "none",
},
styleOverrides: {
root: ({ theme }) => ({
color: (theme.vars || theme).palette.text.primary,
fontWeight: 500,
position: "relative",
textDecoration: "none",
width: "fit-content",
"&::before": {
content: '""',
position: "absolute",
width: "100%",
height: "1px",
bottom: 0,
left: 0,
backgroundColor: (theme.vars || theme).palette.text.secondary,
opacity: 0.3,
transition: "width 0.3s ease, opacity 0.3s ease",
},
"&:hover::before": {
width: 0,
},
"&:focus-visible": {
outline: `3px solid ${alpha(brand[500], 0.5)}`,
outlineOffset: "4px",
borderRadius: "2px",
},
}),
},
},
MuiDrawer: {
styleOverrides: {
paper: ({ theme }) => ({
backgroundColor: (theme.vars || theme).palette.background.default,
}),
},
},
MuiPaginationItem: {
styleOverrides: {
root: ({ theme }) => ({
"&.Mui-selected": {
color: "white",
backgroundColor: (theme.vars || theme).palette.grey[900],
},
...theme.applyStyles("dark", {
"&.Mui-selected": {
color: "black",
backgroundColor: (theme.vars || theme).palette.grey[50],
},
}),
}),
},
},
MuiTabs: {
styleOverrides: {
root: { minHeight: "fit-content" },
indicator: ({ theme }) => ({
backgroundColor: (theme.vars || theme).palette.grey[800],
...theme.applyStyles("dark", {
backgroundColor: (theme.vars || theme).palette.grey[200],
}),
}),
},
},
MuiTab: {
styleOverrides: {
root: ({ theme }) => ({
padding: "6px 8px",
marginBottom: "8px",
textTransform: "none",
minWidth: "fit-content",
minHeight: "fit-content",
color: (theme.vars || theme).palette.text.secondary,
borderRadius: (theme.vars || theme).shape.borderRadius,
border: "1px solid",
borderColor: "transparent",
":hover": {
color: (theme.vars || theme).palette.text.primary,
backgroundColor: gray[100],
borderColor: gray[200],
},
[`&.${tabClasses.selected}`]: {
color: gray[900],
},
...theme.applyStyles("dark", {
":hover": {
color: (theme.vars || theme).palette.text.primary,
backgroundColor: gray[800],
borderColor: gray[700],
},
[`&.${tabClasses.selected}`]: {
color: "#fff",
},
}),
}),
},
},
MuiStepConnector: {
styleOverrides: {
line: ({ theme }) => ({
borderTop: "1px solid",
borderColor: (theme.vars || theme).palette.divider,
flex: 1,
borderRadius: "99px",
}),
},
},
MuiStepIcon: {
styleOverrides: {
root: ({ theme }) => ({
color: "transparent",
border: `1px solid ${gray[400]}`,
width: 12,
height: 12,
borderRadius: "50%",
"& text": {
display: "none",
},
"&.Mui-active": {
border: "none",
color: (theme.vars || theme).palette.primary.main,
},
"&.Mui-completed": {
border: "none",
color: (theme.vars || theme).palette.success.main,
},
...theme.applyStyles("dark", {
border: `1px solid ${gray[700]}`,
"&.Mui-active": {
border: "none",
color: (theme.vars || theme).palette.primary.light,
},
"&.Mui-completed": {
border: "none",
color: (theme.vars || theme).palette.success.light,
},
}),
variants: [
{
props: { completed: true },
style: {
width: 12,
height: 12,
},
},
],
}),
},
},
MuiStepLabel: {
styleOverrides: {
label: ({ theme }) => ({
"&.Mui-completed": {
opacity: 0.6,
...theme.applyStyles("dark", { opacity: 0.5 }),
},
}),
},
},
};

View File

@@ -0,0 +1,113 @@
import { alpha, type Theme, type Components } from "@mui/material/styles";
import { gray } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const surfacesCustomizations: Components<Theme> = {
MuiAccordion: {
defaultProps: {
elevation: 0,
disableGutters: true,
},
styleOverrides: {
root: ({ theme }) => ({
padding: 4,
overflow: "clip",
backgroundColor: (theme.vars || theme).palette.background.default,
border: "1px solid",
borderColor: (theme.vars || theme).palette.divider,
":before": {
backgroundColor: "transparent",
},
"&:not(:last-of-type)": {
borderBottom: "none",
},
"&:first-of-type": {
borderTopLeftRadius: (theme.vars || theme).shape.borderRadius,
borderTopRightRadius: (theme.vars || theme).shape.borderRadius,
},
"&:last-of-type": {
borderBottomLeftRadius: (theme.vars || theme).shape.borderRadius,
borderBottomRightRadius: (theme.vars || theme).shape.borderRadius,
},
}),
},
},
MuiAccordionSummary: {
styleOverrides: {
root: ({ theme }) => ({
border: "none",
borderRadius: 8,
"&:hover": { backgroundColor: gray[50] },
"&:focus-visible": { backgroundColor: "transparent" },
...theme.applyStyles("dark", {
"&:hover": { backgroundColor: gray[800] },
}),
}),
},
},
MuiAccordionDetails: {
styleOverrides: {
root: { mb: 20, border: "none" },
},
},
MuiPaper: {
defaultProps: {
elevation: 0,
},
},
MuiCard: {
styleOverrides: {
root: ({ theme }) => {
return {
padding: 16,
gap: 16,
transition: "all 100ms ease",
backgroundColor: gray[50],
borderRadius: (theme.vars || theme).shape.borderRadius,
border: `1px solid ${(theme.vars || theme).palette.divider}`,
boxShadow: "none",
...theme.applyStyles("dark", {
backgroundColor: gray[800],
}),
variants: [
{
props: {
variant: "outlined",
},
style: {
border: `1px solid ${(theme.vars || theme).palette.divider}`,
boxShadow: "none",
background: "hsl(0, 0%, 100%)",
...theme.applyStyles("dark", {
background: alpha(gray[900], 0.4),
}),
},
},
],
};
},
},
},
MuiCardContent: {
styleOverrides: {
root: {
padding: 0,
"&:last-child": { paddingBottom: 0 },
},
},
},
MuiCardHeader: {
styleOverrides: {
root: {
padding: 0,
},
},
},
MuiCardActions: {
styleOverrides: {
root: {
padding: 0,
},
},
},
};

View File

@@ -0,0 +1,414 @@
import {
createTheme,
alpha,
type PaletteMode,
type Shadows,
} from "@mui/material/styles";
declare module "@mui/material/Paper" {
interface PaperPropsVariantOverrides {
highlighted: true;
}
}
declare module "@mui/material/styles" {
interface ColorRange {
50: string;
100: string;
200: string;
300: string;
400: string;
500: string;
600: string;
700: string;
800: string;
900: string;
}
interface PaletteColor extends ColorRange {}
interface Palette {
baseShadow: string;
}
}
const defaultTheme = createTheme();
const customShadows: Shadows = [...defaultTheme.shadows];
export const brand = {
50: "hsl(210, 100%, 95%)",
100: "hsl(210, 100%, 92%)",
200: "hsl(210, 100%, 80%)",
300: "hsl(210, 100%, 65%)",
400: "hsl(210, 98%, 48%)",
500: "hsl(210, 98%, 42%)",
600: "hsl(210, 98%, 55%)",
700: "hsl(210, 100%, 35%)",
800: "hsl(210, 100%, 16%)",
900: "hsl(210, 100%, 21%)",
};
export const gray = {
50: "hsl(220, 35%, 97%)",
100: "hsl(220, 30%, 94%)",
200: "hsl(220, 20%, 88%)",
300: "hsl(220, 20%, 80%)",
400: "hsl(220, 20%, 65%)",
500: "hsl(220, 20%, 42%)",
600: "hsl(220, 20%, 35%)",
700: "hsl(220, 20%, 25%)",
800: "hsl(220, 30%, 6%)",
900: "hsl(220, 35%, 3%)",
};
export const green = {
50: "hsl(120, 80%, 98%)",
100: "hsl(120, 75%, 94%)",
200: "hsl(120, 75%, 87%)",
300: "hsl(120, 61%, 77%)",
400: "hsl(120, 44%, 53%)",
500: "hsl(120, 59%, 30%)",
600: "hsl(120, 70%, 25%)",
700: "hsl(120, 75%, 16%)",
800: "hsl(120, 84%, 10%)",
900: "hsl(120, 87%, 6%)",
};
export const orange = {
50: "hsl(45, 100%, 97%)",
100: "hsl(45, 92%, 90%)",
200: "hsl(45, 94%, 80%)",
300: "hsl(45, 90%, 65%)",
400: "hsl(45, 90%, 40%)",
500: "hsl(45, 90%, 35%)",
600: "hsl(45, 91%, 25%)",
700: "hsl(45, 94%, 20%)",
800: "hsl(45, 95%, 16%)",
900: "hsl(45, 93%, 12%)",
};
export const red = {
50: "hsl(0, 100%, 97%)",
100: "hsl(0, 92%, 90%)",
200: "hsl(0, 94%, 80%)",
300: "hsl(0, 90%, 65%)",
400: "hsl(0, 90%, 40%)",
500: "hsl(0, 90%, 30%)",
600: "hsl(0, 91%, 25%)",
700: "hsl(0, 94%, 18%)",
800: "hsl(0, 95%, 12%)",
900: "hsl(0, 93%, 6%)",
};
export const getDesignTokens = (mode: PaletteMode) => {
customShadows[1] =
mode === "dark"
? "hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px"
: "hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px";
return {
palette: {
mode,
primary: {
light: brand[200],
main: brand[400],
dark: brand[700],
contrastText: brand[50],
...(mode === "dark" && {
contrastText: brand[50],
light: brand[300],
main: brand[400],
dark: brand[700],
}),
},
info: {
light: brand[100],
main: brand[300],
dark: brand[600],
contrastText: gray[50],
...(mode === "dark" && {
contrastText: brand[300],
light: brand[500],
main: brand[700],
dark: brand[900],
}),
},
warning: {
light: orange[300],
main: orange[400],
dark: orange[800],
...(mode === "dark" && {
light: orange[400],
main: orange[500],
dark: orange[700],
}),
},
error: {
light: red[300],
main: red[400],
dark: red[800],
...(mode === "dark" && {
light: red[400],
main: red[500],
dark: red[700],
}),
},
success: {
light: green[300],
main: green[400],
dark: green[800],
...(mode === "dark" && {
light: green[400],
main: green[500],
dark: green[700],
}),
},
grey: {
...gray,
},
divider: mode === "dark" ? alpha(gray[700], 0.6) : alpha(gray[300], 0.4),
background: {
default: "hsl(0, 0%, 99%)",
paper: "hsl(220, 35%, 97%)",
...(mode === "dark" && {
default: gray[900],
paper: "hsl(220, 30%, 7%)",
}),
},
text: {
primary: gray[800],
secondary: gray[600],
warning: orange[400],
...(mode === "dark" && {
primary: "hsl(0, 0%, 100%)",
secondary: gray[400],
}),
},
action: {
hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`,
...(mode === "dark" && {
hover: alpha(gray[600], 0.2),
selected: alpha(gray[600], 0.3),
}),
},
},
typography: {
fontFamily: "Inter, sans-serif",
h1: {
fontSize: defaultTheme.typography.pxToRem(48),
fontWeight: 600,
lineHeight: 1.2,
letterSpacing: -0.5,
},
h2: {
fontSize: defaultTheme.typography.pxToRem(36),
fontWeight: 600,
lineHeight: 1.2,
},
h3: {
fontSize: defaultTheme.typography.pxToRem(30),
lineHeight: 1.2,
},
h4: {
fontSize: defaultTheme.typography.pxToRem(24),
fontWeight: 600,
lineHeight: 1.5,
},
h5: {
fontSize: defaultTheme.typography.pxToRem(20),
fontWeight: 600,
},
h6: {
fontSize: defaultTheme.typography.pxToRem(18),
fontWeight: 600,
},
subtitle1: {
fontSize: defaultTheme.typography.pxToRem(18),
},
subtitle2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 500,
},
body1: {
fontSize: defaultTheme.typography.pxToRem(14),
},
body2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 400,
},
caption: {
fontSize: defaultTheme.typography.pxToRem(12),
fontWeight: 400,
},
},
shape: {
borderRadius: 8,
},
shadows: customShadows,
};
};
export const colorSchemes = {
light: {
palette: {
primary: {
light: brand[200],
main: brand[400],
dark: brand[700],
contrastText: brand[50],
},
info: {
light: brand[100],
main: brand[300],
dark: brand[600],
contrastText: gray[50],
},
warning: {
light: orange[300],
main: orange[400],
dark: orange[800],
},
error: {
light: red[300],
main: red[400],
dark: red[800],
},
success: {
light: green[300],
main: green[400],
dark: green[800],
},
grey: {
...gray,
},
divider: alpha(gray[300], 0.4),
background: {
default: "hsl(0, 0%, 99%)",
paper: "hsl(220, 35%, 97%)",
},
text: {
primary: gray[800],
secondary: gray[600],
warning: orange[400],
},
action: {
hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`,
},
baseShadow:
"hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px",
},
},
dark: {
palette: {
primary: {
contrastText: brand[50],
light: brand[300],
main: brand[400],
dark: brand[700],
},
info: {
contrastText: brand[300],
light: brand[500],
main: brand[700],
dark: brand[900],
},
warning: {
light: orange[400],
main: orange[500],
dark: orange[700],
},
error: {
light: red[400],
main: red[500],
dark: red[700],
},
success: {
light: green[400],
main: green[500],
dark: green[700],
},
grey: {
...gray,
},
divider: alpha(gray[700], 0.6),
background: {
default: gray[900],
paper: "hsl(220, 30%, 7%)",
},
text: {
primary: "hsl(0, 0%, 100%)",
secondary: gray[400],
},
action: {
hover: alpha(gray[600], 0.2),
selected: alpha(gray[600], 0.3),
},
baseShadow:
"hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px",
},
},
};
export const typography = {
fontFamily: "Inter, sans-serif",
h1: {
fontSize: defaultTheme.typography.pxToRem(48),
fontWeight: 600,
lineHeight: 1.2,
letterSpacing: -0.5,
},
h2: {
fontSize: defaultTheme.typography.pxToRem(36),
fontWeight: 600,
lineHeight: 1.2,
},
h3: {
fontSize: defaultTheme.typography.pxToRem(30),
lineHeight: 1.2,
},
h4: {
fontSize: defaultTheme.typography.pxToRem(24),
fontWeight: 600,
lineHeight: 1.5,
},
h5: {
fontSize: defaultTheme.typography.pxToRem(20),
fontWeight: 600,
},
h6: {
fontSize: defaultTheme.typography.pxToRem(18),
fontWeight: 600,
},
subtitle1: {
fontSize: defaultTheme.typography.pxToRem(18),
},
subtitle2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 500,
},
body1: {
fontSize: defaultTheme.typography.pxToRem(14),
},
body2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 400,
},
caption: {
fontSize: defaultTheme.typography.pxToRem(12),
fontWeight: 400,
},
};
export const shape = {
borderRadius: 8,
};
// @ts-ignore
const defaultShadows: Shadows = [
"none",
"var(--template-palette-baseShadow)",
...defaultTheme.shadows.slice(2),
];
export const shadows = defaultShadows;

View File

@@ -0,0 +1,8 @@
/**
* Get UNIX time
*
* @returns Number of seconds since Epoch
*/
export function time(): number {
return Math.floor(new Date().getTime() / 1000);
}

View File

@@ -0,0 +1,52 @@
import isCidr from "is-cidr";
import type { LenConstraint } from "../api/ServerApi";
/**
* Check if a constraint was respected or not
*
* @returns An error message appropriate for the constraint
* violation, if any, or undefined otherwise
*/
export function checkConstraint(
constraint: LenConstraint,
value: string | undefined
): string | undefined {
value = value ?? "";
if (value.length < constraint.min)
return `Please specify at least ${constraint.min} characters!`;
if (value.length > constraint.max)
return `Please specify at least ${constraint.min} characters!`;
return undefined;
}
/**
* Check if a number constraint was respected or not
*
* @returns An error message appropriate for the constraint
* violation, if any, or undefined otherwise
*/
export function checkNumberConstraint(
constraint: LenConstraint,
value: number
): string | undefined {
value = value ?? "";
if (value < constraint.min)
return `Value is below accepted minimum (${constraint.min})!`;
if (value > constraint.max)
return `Value is above accepted maximum (${constraint.min})!`;
return undefined;
}
/**
* Check whether a given IP network address is valid or not
*
* @param ip The IP network to check
* @returns true if the address is valid, false otherwise
*/
export function isIPNetworkValid(ip: string): boolean {
return isCidr(ip) !== 0;
}

View File

@@ -0,0 +1,101 @@
import type {
MatrixEvent,
MatrixEventsList,
} from "../api/matrix/MatrixApiEvent";
import type { Room } from "../api/matrix/MatrixApiRoom";
export interface MessageReaction {
event_id: string;
account: string;
key: string;
}
export interface Message {
event_id: string;
sent: number;
modified: boolean;
reactions: MessageReaction[];
content: string;
image?: string;
}
export class RoomEventsManager {
readonly room: Room;
private events: MatrixEvent[];
messages: Message[];
endToken?: string;
constructor(room: Room, initialMessages: MatrixEventsList) {
this.room = room;
this.events = [];
this.messages = [];
this.processNewEvents(initialMessages);
}
/**
* Process events given by the API
*/
processNewEvents(evts: MatrixEventsList) {
this.endToken = evts.end;
this.events = [...this.events, ...evts.events];
this.rebuildMessagesList();
}
private rebuildMessagesList() {
// Sorts events list to process oldest events first
this.events.sort((a, b) => a.time - b.time);
// First, process redactions to skip redacted events
let redacted = new Set(
this.events
.map((e) =>
e.data.type === "m.room.redaction" ? e.data.redacts : undefined
)
.filter((e) => e !== undefined)
);
for (const evt of this.events) {
if (redacted.has(evt.id)) continue;
const data = evt.data;
// Message
if (data.type === "m.room.message") {
// Check if this message replaces another one
if (data.content["m.relates_to"]) {
const message = this.messages.find(
(m) => m.event_id === data.content["m.relates_to"]?.event_id
);
if (!message) continue;
message.modified = true;
message.content = data.content.body;
continue;
}
this.messages.push({
event_id: evt.id,
modified: false,
reactions: [],
sent: evt.time,
image: data.content.file?.url,
content: data.content.body,
});
}
// Reaction
if (data.type === "m.reaction") {
const message = this.messages.find(
(m) => m.event_id === data.content["m.relates_to"].event_id
);
if (!message) continue;
message.reactions.push({
account: evt.sender,
event_id: evt.id,
key: data.content["m.relates_to"].key,
});
}
}
}
}

View File

@@ -0,0 +1,86 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material";
import { useEffect, useRef, useState } from "react";
const State = {
Loading: 0,
Ready: 1,
Error: 2,
} as const;
type State = keyof typeof State;
export function AsyncWidget(p: {
loadKey: any;
load: () => Promise<void>;
errMsg: string;
build: () => React.ReactElement;
ready?: boolean;
errAdditionalElement?: () => React.ReactElement;
}): React.ReactElement {
const [state, setState] = useState<number>(State.Loading);
const counter = useRef<any>(null);
const load = async () => {
try {
setState(State.Loading);
await p.load();
setState(State.Ready);
} catch (e) {
console.error(e);
setState(State.Error);
}
};
useEffect(() => {
if (counter.current === p.loadKey) return;
counter.current = p.loadKey;
load();
});
if (state === State.Error)
return (
<Box
component="div"
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
flex: "1",
flexDirection: "column",
}}
>
<Alert
variant="outlined"
severity="error"
style={{ margin: "0px 15px 15px 15px" }}
>
{p.errMsg}
</Alert>
<Button onClick={load}>Try again</Button>
{p.errAdditionalElement?.()}
</Box>
);
if (state === State.Loading || p.ready === false)
return (
<Box
component="div"
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
flex: "1",
}}
>
<CircularProgress />
</Box>
);
return p.build();
}

View File

@@ -0,0 +1,29 @@
import { Chip, Tooltip } from "@mui/material";
import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
export function CopyTextChip(p: { text: string }): React.ReactElement {
const snackbar = useSnackbar();
const alert = useAlert();
const copyTextToClipboard = () => {
try {
navigator.clipboard.writeText(p.text);
snackbar(`'${p.text}' was copied to clipboard.`);
} catch (e) {
console.error(`Failed to copy text to the clipboard! ${e}`);
alert(p.text);
}
};
return (
<Tooltip title="Copy to clipboard">
<Chip
label={p.text}
variant="outlined"
style={{ margin: "5px" }}
onClick={copyTextToClipboard}
/>
</Tooltip>
);
}

View File

@@ -0,0 +1,37 @@
import { Typography } from "@mui/material";
import React, { type PropsWithChildren } from "react";
export function MatrixGWRouteContainer(
p: {
label: string | React.ReactElement;
actions?: React.ReactElement;
} & PropsWithChildren
): React.ReactElement {
return (
<div
style={{
margin: "50px",
flexGrow: 1,
flexShrink: 0,
flexBasis: 0,
minWidth: 0,
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
}}
>
<Typography variant="h4">{p.label}</Typography>
{p.actions ?? <></>}
</div>
{p.children}
</div>
);
}

View File

@@ -0,0 +1,14 @@
export function NotLinkedAccountMessage(): React.ReactElement {
return (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
Your Matrix account is not linked yet!
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { type PropsWithChildren } from "react";
import { Link } from "react-router";
export function RouterLink(
p: PropsWithChildren<{ to: string; target?: React.HTMLAttributeAnchorTarget }>
): React.ReactElement {
return (
<Link
to={p.to}
target={p.target}
style={{ color: "inherit", textDecoration: "inherit" }}
>
{p.children}
</Link>
);
}

View File

@@ -0,0 +1,86 @@
import { Tooltip } from "@mui/material";
import { format } from "date-and-time";
import { time } from "../utils/DateUtils";
export function formatDateTime(time: number): string {
const t = new Date();
t.setTime(1000 * time);
return format(t, "DD/MM/YYYY HH:mm:ss");
}
export function formatDate(time: number): string {
const t = new Date();
t.setTime(1000 * time);
return format(t, "DD/MM/YYYY");
}
export function timeDiff(a: number, b: number): string {
let diff = b - a;
if (diff === 0) return "now";
if (diff === 1) return "1 second";
if (diff < 60) {
return `${diff} seconds`;
}
diff = Math.floor(diff / 60);
if (diff === 1) return "1 minute";
if (diff < 60) {
return `${diff} minutes`;
}
diff = Math.floor(diff / 60);
if (diff === 1) return "1 hour";
if (diff < 24) {
return `${diff} hours`;
}
const diffDays = Math.floor(diff / 24);
if (diffDays === 1) return "1 day";
if (diffDays < 31) {
return `${diffDays} days`;
}
diff = Math.floor(diffDays / 31);
if (diff < 12) {
return `${diff} month`;
}
const diffYears = Math.floor(diffDays / 365);
if (diffYears === 1) return "1 year";
return `${diffYears} years`;
}
export function timeDiffFromNow(t: number): string {
return timeDiff(t, time());
}
export function TimeWidget(p: {
time?: number;
isDuration?: boolean;
showDate?: boolean;
}): React.ReactElement {
if (!p.time) return <></>;
return (
<Tooltip
title={formatDateTime(
p.isDuration ? new Date().getTime() / 1000 - p.time : p.time
)}
arrow
>
<span>
{p.showDate
? formatDate(p.time)
: p.isDuration
? timeDiff(0, p.time)
: timeDiffFromNow(p.time)}
</span>
</Tooltip>
);
}

View File

@@ -0,0 +1,13 @@
import { Button } from "@mui/material";
import { Link } from "react-router";
export function AuthSingleMessage(p: { message: string }): React.ReactElement {
return (
<>
<p style={{ textAlign: "center" }}>{p.message}</p>
<Link to={"/"}>
<Button>Go back home</Button>
</Link>
</>
);
}

View File

@@ -0,0 +1,71 @@
import { mdiMessageTextFast } from "@mdi/js";
import Icon from "@mdi/react";
import { Typography } from "@mui/material";
import MuiCard from "@mui/material/Card";
import Stack from "@mui/material/Stack";
import { styled } from "@mui/material/styles";
import { Outlet } from "react-router";
const Card = styled(MuiCard)(({ theme }) => ({
display: "flex",
flexDirection: "column",
alignSelf: "center",
width: "100%",
padding: theme.spacing(4),
gap: theme.spacing(2),
margin: "auto",
[theme.breakpoints.up("sm")]: {
maxWidth: "450px",
},
boxShadow:
"hsla(220, 30%, 5%, 0.05) 0px 5px 15px 0px, hsla(220, 25%, 10%, 0.05) 0px 15px 35px -5px",
...theme.applyStyles("dark", {
boxShadow:
"hsla(220, 30%, 5%, 0.5) 0px 5px 15px 0px, hsla(220, 25%, 10%, 0.08) 0px 15px 35px -5px",
}),
}));
const SignInContainer = styled(Stack)(({ theme }) => ({
height: "calc((1 - var(--template-frame-height, 0)) * 100dvh)",
minHeight: "100%",
padding: theme.spacing(2),
[theme.breakpoints.up("sm")]: {
padding: theme.spacing(4),
},
"&::before": {
content: '""',
display: "block",
position: "absolute",
zIndex: -1,
inset: 0,
backgroundImage:
"radial-gradient(ellipse at 50% 50%, hsl(210, 100%, 97%), hsl(0, 0%, 100%))",
backgroundRepeat: "no-repeat",
...theme.applyStyles("dark", {
backgroundImage:
"radial-gradient(at 50% 50%, hsla(210, 100%, 16%, 0.5), hsl(220, 30%, 5%))",
}),
},
}));
export function BaseLoginPage(): React.ReactElement {
return (
<SignInContainer direction="column" justifyContent="space-between">
<Card variant="outlined">
<Typography
component="h1"
variant="h4"
sx={{ width: "100%", fontSize: "clamp(2rem, 10vw, 2.15rem)" }}
>
<Icon
path={mdiMessageTextFast}
size={"1em"}
style={{ display: "inline-table" }}
/>{" "}
MatrixGW
</Typography>
<Outlet />
</Card>
</SignInContainer>
);
}

View File

@@ -0,0 +1,153 @@
import { Button } from "@mui/material";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import * as React from "react";
import { Outlet, useNavigate } from "react-router";
import { AuthApi, type UserInfo } from "../../api/AuthApi";
import { useAuth } from "../../App";
import { useAlert } from "../../hooks/contexts_provider/AlertDialogProvider";
import { useLoadingMessage } from "../../hooks/contexts_provider/LoadingMessageProvider";
import { AsyncWidget } from "../AsyncWidget";
import DashboardHeader from "./DashboardHeader";
import DashboardSidebar from "./DashboardSidebar";
interface UserInfoContext {
info: UserInfo;
reloadUserInfo: () => void;
signOut: () => void;
}
const UserInfoContextK = React.createContext<UserInfoContext | null>(null);
export default function BaseAuthenticatedPage(): React.ReactElement {
const theme = useTheme();
const alert = useAlert();
const loadingMessage = useLoadingMessage();
const [userInfo, setuserInfo] = React.useState<null | UserInfo>(null);
const loadUserInfo = async () => {
setuserInfo(await AuthApi.GetUserInfo());
};
const reloadUserInfo = async () => {
try {
loadingMessage.show("Refreshing user information...");
await loadUserInfo();
} catch (e) {
console.error(`Failed to load user information! ${e}`);
alert(`Failed to load user information! ${e}`);
} finally {
loadingMessage.hide();
}
};
const auth = useAuth();
const navigate = useNavigate();
const signOut = () => {
AuthApi.SignOut();
navigate("/");
auth.setSignedIn(false);
};
const [isDesktopNavigationExpanded, setIsDesktopNavigationExpanded] =
React.useState(false);
const [isMobileNavigationExpanded, setIsMobileNavigationExpanded] =
React.useState(false);
const isOverMdViewport = useMediaQuery(theme.breakpoints.up("md"));
const isNavigationExpanded = isOverMdViewport
? isDesktopNavigationExpanded
: isMobileNavigationExpanded;
const setIsNavigationExpanded = React.useCallback(
(newExpanded: boolean) => {
if (isOverMdViewport) {
setIsDesktopNavigationExpanded(newExpanded);
} else {
setIsMobileNavigationExpanded(newExpanded);
}
},
[
isOverMdViewport,
setIsDesktopNavigationExpanded,
setIsMobileNavigationExpanded,
]
);
const handleToggleHeaderMenu = React.useCallback(
(isExpanded: boolean) => {
setIsNavigationExpanded(isExpanded);
},
[setIsNavigationExpanded]
);
const layoutRef = React.useRef<HTMLDivElement>(null);
return (
<AsyncWidget
loadKey="1"
load={loadUserInfo}
errMsg="Failed to load user information!"
errAdditionalElement={() => (
<>
<Button onClick={signOut}>Sign out</Button>
</>
)}
build={() => (
<UserInfoContextK
value={{
info: userInfo!,
reloadUserInfo,
signOut,
}}
>
<DashboardHeader
menuOpen={isNavigationExpanded}
onToggleMenu={handleToggleHeaderMenu}
/>
<Box
ref={layoutRef}
sx={{
position: "relative",
display: "flex",
overflow: "hidden",
}}
>
<DashboardSidebar
expanded={isNavigationExpanded}
setExpanded={setIsNavigationExpanded}
container={layoutRef?.current ?? undefined}
/>
<Box
sx={{
display: "flex",
flexDirection: "column",
flex: 1,
minWidth: 0,
}}
>
<Box
component="main"
sx={{
display: "flex",
flexDirection: "column",
flex: 1,
overflow: "auto",
}}
>
<Outlet />
</Box>
</Box>
</Box>
</UserInfoContextK>
)}
/>
);
}
export function useUserInfo(): UserInfoContext {
return React.use(UserInfoContextK)!;
}

View File

@@ -0,0 +1,160 @@
import { mdiMessageTextFast } from "@mdi/js";
import Icon from "@mdi/react";
import LogoutIcon from "@mui/icons-material/Logout";
import MenuIcon from "@mui/icons-material/Menu";
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
import { Avatar } from "@mui/material";
import MuiAppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import { styled } from "@mui/material/styles";
import Toolbar from "@mui/material/Toolbar";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import * as React from "react";
import { RouterLink } from "../RouterLink";
import { useUserInfo } from "./BaseAuthenticatedPage";
import ThemeSwitcher from "./ThemeSwitcher";
const AppBar = styled(MuiAppBar)(({ theme }) => ({
borderWidth: 0,
borderBottomWidth: 1,
borderStyle: "solid",
borderColor: (theme.vars ?? theme).palette.divider,
boxShadow: "none",
zIndex: theme.zIndex.drawer + 1,
}));
const LogoContainer = styled("div")({
position: "relative",
height: 40,
display: "flex",
alignItems: "center",
"& img": {
maxHeight: 40,
},
});
export interface DashboardHeaderProps {
menuOpen: boolean;
onToggleMenu: (open: boolean) => void;
}
export default function DashboardHeader({
menuOpen,
onToggleMenu,
}: DashboardHeaderProps) {
const user = useUserInfo();
const handleMenuOpen = React.useCallback(() => {
onToggleMenu(!menuOpen);
}, [menuOpen, onToggleMenu]);
const getMenuIcon = React.useCallback(
(isExpanded: boolean) => {
const expandMenuActionText = "Expand";
const collapseMenuActionText = "Collapse";
return (
<Tooltip
title={`${
isExpanded ? collapseMenuActionText : expandMenuActionText
} menu`}
enterDelay={200}
>
<div>
<IconButton
size="small"
aria-label={`${
isExpanded ? collapseMenuActionText : expandMenuActionText
} navigation menu`}
onClick={handleMenuOpen}
>
{isExpanded ? <MenuOpenIcon /> : <MenuIcon />}
</IconButton>
</div>
</Tooltip>
);
},
[handleMenuOpen]
);
return (
<AppBar
color="inherit"
position="static"
sx={{ displayPrint: "none", overflow: "hidden" }}
>
<Toolbar sx={{ backgroundColor: "inherit", mx: { xs: -0.75, sm: -1 } }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{
flexWrap: "wrap",
width: "100%",
}}
>
<Stack direction="row" alignItems="center">
<Box sx={{ mr: 3 }}>{getMenuIcon(menuOpen)}</Box>
<RouterLink to="/">
<Stack direction="row" alignItems="center">
<LogoContainer>
<Icon path={mdiMessageTextFast} size="2em" />
</LogoContainer>
<Typography
variant="h6"
sx={{
fontWeight: "700",
ml: 1,
whiteSpace: "nowrap",
lineHeight: 1,
display: { xs: "none", sm: "block" },
}}
>
MatrixGW
</Typography>
</Stack>
</RouterLink>
</Stack>
{/* User avatar */}
<Stack
direction="row"
sx={{
p: 2,
gap: 1,
alignItems: "center",
borderTop: "1px solid",
borderColor: "divider",
}}
>
<Avatar
sizes="small"
alt={user.info.name}
sx={{ width: 36, height: 36 }}
/>
<Box sx={{ mr: "auto", display: { xs: "none", md: "block" } }}>
<Typography
variant="body2"
sx={{ fontWeight: 500, lineHeight: "16px" }}
>
{user.info.name}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{user.info.email}
</Typography>
</Box>
<ThemeSwitcher />
<Tooltip title="Sign out">
<IconButton size="small" onClick={user.signOut}>
<LogoutIcon />
</IconButton>
</Tooltip>
</Stack>
</Stack>
</Toolbar>
</AppBar>
);
}

View File

@@ -0,0 +1,218 @@
import { mdiBug, mdiForum, mdiKeyVariant, mdiLinkLock } from "@mdi/js";
import Icon from "@mdi/react";
import Box from "@mui/material/Box";
import Drawer from "@mui/material/Drawer";
import List from "@mui/material/List";
import { useTheme } from "@mui/material/styles";
import type {} from "@mui/material/themeCssVarsAugmentation";
import useMediaQuery from "@mui/material/useMediaQuery";
import * as React from "react";
import { useUserInfo } from "./BaseAuthenticatedPage";
import DashboardSidebarContext from "./DashboardSidebarContext";
import DashboardSidebarDividerItem from "./DashboardSidebarDividerItem";
import DashboardSidebarPageItem from "./DashboardSidebarPageItem";
import { DRAWER_WIDTH, MINI_DRAWER_WIDTH } from "./constants";
import {
getDrawerSxTransitionMixin,
getDrawerWidthTransitionMixin,
} from "./mixins";
export interface DashboardSidebarProps {
expanded?: boolean;
setExpanded: (expanded: boolean) => void;
disableCollapsibleSidebar?: boolean;
container?: Element;
}
export default function DashboardSidebar({
expanded = true,
setExpanded,
container,
}: DashboardSidebarProps) {
const theme = useTheme();
const user = useUserInfo();
const isOverSmViewport = useMediaQuery(theme.breakpoints.up("sm"));
const isOverMdViewport = useMediaQuery(theme.breakpoints.up("md"));
const [isFullyExpanded, setIsFullyExpanded] = React.useState(expanded);
React.useEffect(() => {
if (expanded) {
const drawerWidthTransitionTimeout = setTimeout(() => {
setIsFullyExpanded(true);
}, theme.transitions.duration.enteringScreen);
return () => clearTimeout(drawerWidthTransitionTimeout);
}
setIsFullyExpanded(false);
return () => {};
}, [expanded, theme.transitions.duration.enteringScreen]);
const handleSetSidebarExpanded = React.useCallback(
(newExpanded: boolean) => () => {
setExpanded(newExpanded);
},
[setExpanded]
);
const handlePageItemClick = React.useCallback(() => {
if (!isOverSmViewport) {
setExpanded(false);
}
}, [expanded, setExpanded, isOverSmViewport]);
const hasDrawerTransitions = isOverSmViewport && isOverMdViewport;
const getDrawerContent = React.useCallback(
(viewport: "phone" | "tablet" | "desktop") => (
<React.Fragment>
<Box
component="nav"
aria-label={`${viewport.charAt(0).toUpperCase()}${viewport.slice(1)}`}
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
overflow: "auto",
scrollbarGutter: !expanded ? "stable" : "auto",
overflowX: "hidden",
pt: expanded ? 0 : 2,
paddingTop: 0,
...(hasDrawerTransitions
? getDrawerSxTransitionMixin(isFullyExpanded, "padding")
: {}),
}}
>
<List
dense
sx={{
padding: !expanded ? 0 : 0.5,
mb: 4,
width: !expanded ? MINI_DRAWER_WIDTH : "auto",
}}
>
<DashboardSidebarPageItem
disabled={!user.info.matrix_account_connected}
title="Messages"
icon={<Icon path={mdiForum} size={"1.5em"} />}
href="/"
mini={viewport === "desktop"}
/>
<DashboardSidebarDividerItem />
<DashboardSidebarPageItem
title="Matrix link"
icon={<Icon path={mdiLinkLock} size={"1.5em"} />}
href="/matrix_link"
mini={viewport === "desktop"}
/>
<DashboardSidebarPageItem
title="API tokens"
icon={<Icon path={mdiKeyVariant} size={"1.5em"} />}
href="/tokens"
mini={viewport === "desktop"}
/>
<DashboardSidebarPageItem
disabled={!user.info.matrix_account_connected}
title="WS Debug"
icon={<Icon path={mdiBug} size={"1.5em"} />}
href="/wsdebug"
mini={viewport === "desktop"}
/>
</List>
</Box>
</React.Fragment>
),
[
expanded,
hasDrawerTransitions,
isFullyExpanded,
user.info.matrix_account_connected,
]
);
const getDrawerSharedSx = React.useCallback(
(isTemporary: boolean, desktop?: boolean) => {
const drawerWidth = desktop
? expanded
? MINI_DRAWER_WIDTH
: 0
: !expanded
? MINI_DRAWER_WIDTH
: DRAWER_WIDTH;
return {
displayPrint: "none",
width: drawerWidth,
flexShrink: 0,
...getDrawerWidthTransitionMixin(expanded),
...(isTemporary ? { position: "absolute" } : {}),
[`& .MuiDrawer-paper`]: {
position: "absolute",
width: drawerWidth,
boxSizing: "border-box",
backgroundImage: "none",
...getDrawerWidthTransitionMixin(expanded),
},
};
},
[expanded, !expanded]
);
const sidebarContextValue = React.useMemo(() => {
return {
onPageItemClick: handlePageItemClick,
fullyExpanded: isFullyExpanded,
hasDrawerTransitions,
};
}, [handlePageItemClick, !expanded, isFullyExpanded, hasDrawerTransitions]);
return (
<DashboardSidebarContext.Provider value={sidebarContextValue}>
<Drawer
container={container}
variant="temporary"
open={expanded}
onClose={handleSetSidebarExpanded(false)}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
sx={{
display: {
xs: "block",
sm: "none",
md: "none",
},
...getDrawerSharedSx(true),
}}
>
{getDrawerContent("phone")}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: {
xs: "none",
sm: "block",
md: "none",
},
...getDrawerSharedSx(false),
}}
>
{getDrawerContent("tablet")}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: "none", md: "block" },
...getDrawerSharedSx(false, true),
}}
>
{getDrawerContent("desktop")}
</Drawer>
</DashboardSidebarContext.Provider>
);
}

View File

@@ -0,0 +1,9 @@
import * as React from "react";
const DashboardSidebarContext = React.createContext<{
onPageItemClick: () => void;
fullyExpanded: boolean;
hasDrawerTransitions: boolean;
} | null>(null);
export default DashboardSidebarContext;

View File

@@ -0,0 +1,28 @@
import * as React from "react";
import Divider from "@mui/material/Divider";
import type {} from "@mui/material/themeCssVarsAugmentation";
import DashboardSidebarContext from "./DashboardSidebarContext";
import { getDrawerSxTransitionMixin } from "./mixins";
export default function DashboardSidebarDividerItem() {
const sidebarContext = React.useContext(DashboardSidebarContext);
if (!sidebarContext) {
throw new Error("Sidebar context was used without a provider.");
}
const { fullyExpanded = true, hasDrawerTransitions } = sidebarContext;
return (
<li>
<Divider
sx={{
borderBottomWidth: 1,
my: 1,
mx: -0.5,
...(hasDrawerTransitions
? getDrawerSxTransitionMixin(fullyExpanded, "margin")
: {}),
}}
/>
</li>
);
}

View File

@@ -0,0 +1,140 @@
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Typography from "@mui/material/Typography";
import type {} from "@mui/material/themeCssVarsAugmentation";
import * as React from "react";
import { Link, matchPath, useLocation } from "react-router";
import DashboardSidebarContext from "./DashboardSidebarContext";
import { MINI_DRAWER_WIDTH } from "./constants";
export interface DashboardSidebarPageItemProps {
title: string;
icon?: React.ReactNode;
href: string;
action?: React.ReactNode;
disabled?: boolean;
mini?: boolean;
}
export default function DashboardSidebarPageItem({
title,
icon,
href,
action,
disabled = false,
mini = false,
}: DashboardSidebarPageItemProps) {
const { pathname } = useLocation();
const sidebarContext = React.useContext(DashboardSidebarContext);
if (!sidebarContext) {
throw new Error("Sidebar context was used without a provider.");
}
const { onPageItemClick, fullyExpanded = true } = sidebarContext;
const hasExternalHref = href
? href.startsWith("http://") || href.startsWith("https://")
: false;
const LinkComponent = hasExternalHref ? "a" : Link;
const selected = !!matchPath(href, pathname);
return (
<React.Fragment>
<ListItem disablePadding style={{ padding: "5px" }}>
<ListItemButton
selected={selected}
disabled={disabled}
sx={{
height: mini ? 50 : "auto",
}}
{...{
LinkComponent,
...(hasExternalHref
? {
target: "_blank",
rel: "noopener noreferrer",
}
: {}),
to: href,
onClick: onPageItemClick,
}}
>
{icon || mini ? (
<Box
sx={
mini
? {
position: "absolute",
left: "50%",
top: "calc(50% - 6px)",
transform: "translate(-50%, -50%)",
}
: {}
}
>
<ListItemIcon
sx={{
display: "flex",
alignItems: "center",
justifyContent: mini ? "center" : "auto",
}}
>
{icon ?? null}
{!icon && mini ? (
<Avatar
sx={{
fontSize: 10,
height: 16,
width: 16,
}}
>
{title
.split(" ")
.slice(0, 2)
.map((titleWord) => titleWord.charAt(0).toUpperCase())}
</Avatar>
) : null}
</ListItemIcon>
{mini ? (
<Typography
variant="caption"
sx={{
position: "absolute",
bottom: -18,
left: "50%",
transform: "translateX(-50%)",
fontSize: 10,
fontWeight: 500,
textAlign: "center",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: MINI_DRAWER_WIDTH - 28,
}}
>
{title}
</Typography>
) : null}
</Box>
) : null}
{!mini ? (
<ListItemText
primary={title}
sx={{
whiteSpace: "nowrap",
zIndex: 1,
}}
/>
) : null}
{action && !mini && fullyExpanded ? action : null}
</ListItemButton>
</ListItem>
</React.Fragment>
);
}

View File

@@ -0,0 +1,28 @@
import * as React from "react";
import Divider from "@mui/material/Divider";
import type {} from "@mui/material/themeCssVarsAugmentation";
import DashboardSidebarContext from "./DashboardSidebarContext";
import { getDrawerSxTransitionMixin } from "./mixins";
export default function DashboardSidebarDividerItem() {
const sidebarContext = React.useContext(DashboardSidebarContext);
if (!sidebarContext) {
throw new Error("Sidebar context was used without a provider.");
}
const { fullyExpanded = true, hasDrawerTransitions } = sidebarContext;
return (
<li>
<Divider
sx={{
borderBottomWidth: 1,
my: 1,
mx: -0.5,
...(hasDrawerTransitions
? getDrawerSxTransitionMixin(fullyExpanded, "margin")
: {}),
}}
/>
</li>
);
}

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { useTheme, useColorScheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import DarkModeIcon from "@mui/icons-material/DarkMode";
import LightModeIcon from "@mui/icons-material/LightMode";
import type {} from "@mui/material/themeCssVarsAugmentation";
export default function ThemeSwitcher() {
const theme = useTheme();
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const preferredMode = prefersDarkMode ? "dark" : "light";
const { mode, setMode } = useColorScheme();
const paletteMode = !mode || mode === "system" ? preferredMode : mode;
const toggleMode = React.useCallback(() => {
setMode(paletteMode === "dark" ? "light" : "dark");
}, [setMode, paletteMode]);
return (
<Tooltip
title={`${paletteMode === "dark" ? "Light" : "Dark"} mode`}
enterDelay={1000}
>
<div>
<IconButton
size="small"
aria-label={`Switch to ${
paletteMode === "dark" ? "light" : "dark"
} mode`}
onClick={toggleMode}
>
<LightModeIcon
sx={{
display: "inline",
[theme.getColorSchemeSelector("dark")]: {
display: "none",
},
}}
/>
<DarkModeIcon
sx={{
display: "none",
[theme.getColorSchemeSelector("dark")]: {
display: "inline",
},
}}
/>
</IconButton>
</div>
</Tooltip>
);
}

View File

@@ -0,0 +1,2 @@
export const DRAWER_WIDTH = 240; // px
export const MINI_DRAWER_WIDTH = 90; // px

View File

@@ -0,0 +1,23 @@
import { type Theme } from "@mui/material/styles";
export function getDrawerSxTransitionMixin(
isExpanded: boolean,
property: string
) {
return {
transition: (theme: Theme) =>
theme.transitions.create(property, {
easing: theme.transitions.easing.sharp,
duration: isExpanded
? theme.transitions.duration.enteringScreen
: theme.transitions.duration.leavingScreen,
}),
};
}
export function getDrawerWidthTransitionMixin(isExpanded: boolean) {
return {
...getDrawerSxTransitionMixin(isExpanded, "width"),
overflowX: "hidden",
};
}

View File

@@ -0,0 +1,23 @@
import { Checkbox, FormControlLabel } from "@mui/material";
export function CheckboxInput(p: {
editable: boolean;
label: string;
checked: boolean | undefined;
onValueChange: (v: boolean) => void;
}): React.ReactElement {
return (
<FormControlLabel
control={
<Checkbox
disabled={!p.editable}
checked={p.checked}
onChange={(e) => {
p.onValueChange(e.target.checked);
}}
/>
}
label={p.label}
/>
);
}

View File

@@ -0,0 +1,49 @@
import { DateField } from "@mui/x-date-pickers";
import dayjs from "dayjs";
import { TextInput } from "./TextInput";
export function DateInput(p: {
editable?: boolean;
required?: boolean;
label: string;
value: number | undefined | null;
checkValue?: (s: number) => boolean;
disableFuture?: boolean;
disablePast?: boolean;
onChange: (newVal: number | undefined | null) => void;
}): React.ReactElement {
const date = p.value ? dayjs.unix(p.value) : undefined;
const error = p.value && p.checkValue && !p.checkValue(p.value);
if (!p.editable)
return (
<TextInput
{...p}
checkValue={undefined}
value={date !== undefined ? date.format("DD/MM/YYYY") : undefined}
/>
);
return (
<DateField
clearable
value={date}
onChange={(v) => p.onChange(v?.unix())}
slotProps={{
textField: {
fullWidth: true,
label: p.label,
variant: "standard",
},
inputAdornment: {
variant: "standard",
},
}}
disableFuture={p.disableFuture}
disablePast={p.disablePast}
error={error === true}
format="DD/MM/YYYY"
/>
);
}

View File

@@ -0,0 +1,26 @@
import { isIPNetworkValid } from "../../utils/FormUtils";
import { TextInput } from "./TextInput";
function rebuildNetworksList(val?: string): string[] | undefined {
if (!val || val.trim() === "") return undefined;
return val.split(",").map((v) => v.trim());
}
export function NetworksInput(p: {
editable?: boolean;
label: string;
value?: string[];
onChange: (n: string[] | undefined) => void;
}): React.ReactElement {
const textValue = (p.value ?? []).join(", ").trim();
return (
<TextInput
{...p}
type="string"
value={textValue}
onValueChange={(i) => p.onChange(rebuildNetworksList(i))}
checkValue={(v) => (rebuildNetworksList(v) ?? []).every(isIPNetworkValid)}
/>
);
}

View File

@@ -0,0 +1,65 @@
import { TextField, type TextFieldVariants } from "@mui/material";
import type { LenConstraint } from "../../api/ServerApi";
/**
* Text input
*/
export function TextInput(p: {
label?: string;
editable?: boolean;
required?: boolean;
value?: string;
onValueChange?: (newVal: string | undefined) => void;
size?: LenConstraint;
checkValue?: (s: string) => boolean;
multiline?: boolean;
minRows?: number;
maxRows?: number;
placeholder?: string;
type?: React.HTMLInputTypeAttribute;
style?: React.CSSProperties;
helperText?: string;
variant?: TextFieldVariants;
}): React.ReactElement {
if (!p.editable && (p.value ?? "") === "") return <></>;
let valueError = undefined;
if (p.value && p.value.length > 0) {
if (p.size?.min && p.type !== "number" && p.value.length < p.size.min)
valueError = `Please specify at least ${p.size.min} characters !`;
if (p.checkValue && !p.checkValue(p.value)) valueError = "Invalid value!";
if (
p.type === "number" &&
p.size &&
(Number(p.value) > p.size.max || Number(p.value) < p.size.min)
)
valueError = "Invalid size range!";
}
return (
<TextField
label={p.label}
required={p.required}
value={p.value ?? ""}
onChange={(e) =>
p.onValueChange?.(
e.target.value.length === 0 ? undefined : e.target.value
)
}
slotProps={{
input: {
readOnly: !p.editable,
type: p.type,
},
htmlInput: { maxLength: p.size?.max, placeholder: p.placeholder },
}}
variant={p.variant ?? "standard"}
style={p.style ?? { width: "100%", marginBottom: "15px" }}
multiline={p.multiline}
minRows={p.minRows}
maxRows={p.maxRows}
error={valueError !== undefined}
helperText={valueError ?? p.helperText}
/>
);
}

View File

@@ -0,0 +1,85 @@
import { Divider } from "@mui/material";
import React from "react";
import {
MatrixApiProfile,
type UsersMap,
} from "../../api/matrix/MatrixApiProfile";
import { MatrixApiRoom, type Room } from "../../api/matrix/MatrixApiRoom";
import { MatrixSyncApi } from "../../api/MatrixSyncApi";
import { AsyncWidget } from "../AsyncWidget";
import { RoomSelector } from "./RoomSelector";
import { RoomWidget } from "./RoomWidget";
import { SpaceSelector } from "./SpaceSelector";
export function MainMessageWidget(): React.ReactElement {
const [rooms, setRooms] = React.useState<Room[] | undefined>();
const [users, setUsers] = React.useState<UsersMap | undefined>();
const load = async () => {
await MatrixSyncApi.Start();
const rooms = await MatrixApiRoom.ListJoined();
setRooms(rooms);
// Get the list of users in rooms
const users = rooms.reduce((prev, r) => {
r.members.forEach((m) => prev.add(m));
return prev;
}, new Set<string>());
setUsers(await MatrixApiProfile.GetMultiple([...users]));
};
return (
<AsyncWidget
loadKey={1}
load={load}
ready={!!rooms && !!users}
errMsg="Failed to initialize messaging component!"
build={() => <_MainMessageWidget rooms={rooms!} users={users!} />}
/>
);
}
function _MainMessageWidget(p: {
rooms: Room[];
users: UsersMap;
}): React.ReactElement {
const [space, setSpace] = React.useState<string | undefined>();
const [room, setRoom] = React.useState<Room | undefined>();
const spaceRooms = React.useMemo(() => {
return p.rooms
.filter((r) => !r.is_space && (!space || r.parents.includes(space)))
.sort(
(a, b) => (b.latest_event?.time ?? 0) - (a.latest_event?.time ?? 0)
);
}, [space, p.rooms]);
return (
<div style={{ display: "flex", height: "100%" }}>
<SpaceSelector {...p} selectedSpace={space} onChange={setSpace} />
<Divider orientation="vertical" />
<RoomSelector
{...p}
rooms={spaceRooms}
currRoom={room}
onChange={setRoom}
/>
<Divider orientation="vertical" />
{room === undefined && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
flex: 1,
}}
>
No room selected.
</div>
)}
{room && <RoomWidget {...p} room={room} />}
</div>
);
}

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