Compare commits
5 Commits
migrate-to
...
ded4673e1b
| Author | SHA1 | Date | |
|---|---|---|---|
| ded4673e1b | |||
| aeb35029c3 | |||
| 1dc56d5ec1 | |||
| 51b1ab380c | |||
| b5abddaacb |
12
.drone.yml
Normal file
12
.drone.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: default
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: cargo_check
|
||||||
|
image: rust
|
||||||
|
commands:
|
||||||
|
- rustup component add clippy
|
||||||
|
- cargo clippy -- -D warnings
|
||||||
|
- cargo test
|
||||||
1
matrixgw_backend/.gitignore → .gitignore
vendored
1
matrixgw_backend/.gitignore → .gitignore
vendored
@@ -1,4 +1,3 @@
|
|||||||
storage
|
storage
|
||||||
app_storage
|
|
||||||
.idea
|
.idea
|
||||||
target
|
target
|
||||||
3679
matrixgw_backend/Cargo.lock → Cargo.lock
generated
3679
matrixgw_backend/Cargo.lock → Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,35 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "matrixgw_backend"
|
name = "matrix_gateway"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
env_logger = "0.11.8"
|
|
||||||
log = "0.4.28"
|
log = "0.4.28"
|
||||||
|
env_logger = "0.11.8"
|
||||||
clap = { version = "4.5.51", features = ["derive", "env"] }
|
clap = { version = "4.5.51", features = ["derive", "env"] }
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
anyhow = "1.0.100"
|
anyhow = "1.0.100"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
tokio = { version = "1.48.0", features = ["full"] }
|
serde_json = "1.0.143"
|
||||||
|
rust-s3 = { version = "0.37.0", features = ["tokio"] }
|
||||||
actix-web = "4.11.0"
|
actix-web = "4.11.0"
|
||||||
actix-session = { version = "0.11.0", features = ["redis-session"] }
|
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"
|
light-openid = "1.0.4"
|
||||||
bytes = "1.10.1"
|
|
||||||
sha2 = "0.10.9"
|
|
||||||
base16ct = { version = "0.3.0", features = ["alloc"] }
|
|
||||||
futures-util = "0.3.31"
|
|
||||||
jwt-simple = { version = "0.12.13", default-features = false, features = ["pure-rust"] }
|
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
|
rand = "0.9.2"
|
||||||
|
rust-embed = "8.9.0"
|
||||||
|
mime_guess = "2.0.5"
|
||||||
|
askama = "0.14.0"
|
||||||
|
urlencoding = "2.1.3"
|
||||||
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
||||||
ipnet = { version = "2.11.0", features = ["serde"] }
|
ipnet = { version = "2.11.0", features = ["serde"] }
|
||||||
rand = "0.9.2"
|
chrono = "0.4.42"
|
||||||
hex = "0.4.3"
|
futures-util = { version = "0.3.31", features = ["sink"] }
|
||||||
mailchecker = "6.0.19"
|
jwt-simple = { version = "0.12.13", default-features = false, features = ["pure-rust"] }
|
||||||
matrix-sdk = { version = "0.14.0" }
|
actix-remote-ip = "0.1.0"
|
||||||
url = "2.5.7"
|
bytes = "1.10.1"
|
||||||
ractor = "0.15.9"
|
sha2 = "0.11.0-rc.3"
|
||||||
serde_json = "1.0.145"
|
base16ct = { version = "0.3.0", features = ["alloc"] }
|
||||||
lazy-regex = "3.4.2"
|
ruma = { version = "0.13.0", features = ["client-api-c", "client-ext-client-api", "client-hyper-native-tls", "rand"] }
|
||||||
actix-ws = "0.3.0"
|
actix-ws = "0.3.0"
|
||||||
infer = "0.19.0"
|
tokio = { version = "1.48.0", features = ["rt", "time", "macros", "rt-multi-thread"] }
|
||||||
14
Makefile
Normal file
14
Makefile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
DOCKER_TEMP_DIR=temp
|
||||||
|
|
||||||
|
all: gateway
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
cargo clippy -- -D warnings && cargo build --release
|
||||||
|
|
||||||
|
gateway_docker: gateway
|
||||||
|
rm -rf $(DOCKER_TEMP_DIR)
|
||||||
|
mkdir $(DOCKER_TEMP_DIR)
|
||||||
|
cp target/release/matrix_gateway $(DOCKER_TEMP_DIR)
|
||||||
|
docker build -t pierre42100/matrix_gateway -f ./Dockerfile "$(DOCKER_TEMP_DIR)"
|
||||||
|
rm -rf $(DOCKER_TEMP_DIR)
|
||||||
|
|
||||||
24
README.md
24
README.md
@@ -6,9 +6,10 @@ Project that expose a simple API to make use of Matrix API. It acts as a Matrix
|
|||||||
**Known limitations**:
|
**Known limitations**:
|
||||||
|
|
||||||
- Supports only a limited subset of Matrix API
|
- Supports only a limited subset of Matrix API
|
||||||
|
- Does not support E2E encryption
|
||||||
- Does not support spaces
|
- Does not support spaces
|
||||||
|
|
||||||
Project written in Rust and TypeScript. Releases are published on Docker Hub.
|
Project written in Rust. Releases are published on Docker Hub.
|
||||||
|
|
||||||
## Docker image options
|
## Docker image options
|
||||||
```bash
|
```bash
|
||||||
@@ -16,13 +17,8 @@ docker run --rm -it docker.io/pierre42100/matrix_gateway --help
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Setup dev environment
|
## Setup dev environment
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
```
|
```
|
||||||
sudo apt install -y libsqlite3-dev
|
mkdir -p storage/maspostgres storage/synapse storage/minio
|
||||||
|
|
||||||
cd matrixgw_backend
|
|
||||||
mkdir -p storage/maspostgres storage/synapse
|
|
||||||
docker compose up
|
docker compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -37,22 +33,12 @@ URLs:
|
|||||||
* Synapse: http://localhost:8448/
|
* Synapse: http://localhost:8448/
|
||||||
* Matrix Authentication Service: http://localhost:8778/
|
* Matrix Authentication Service: http://localhost:8778/
|
||||||
* OpenID configuration: http://127.0.0.1:9001/dex/.well-known/openid-configuration
|
* OpenID configuration: http://127.0.0.1:9001/dex/.well-known/openid-configuration
|
||||||
|
* Minio console: http://localhost:9002/
|
||||||
|
|
||||||
Auto-created Matrix accounts:
|
Auto-created Matrix accounts:
|
||||||
|
|
||||||
* `admin1` : `admin1`
|
* `admin1` : `admin1`
|
||||||
* `user1` : `user1`
|
* `user1` : `user1`
|
||||||
|
|
||||||
|
Minio administration credentials: `minioadmin` : `minioadmin`
|
||||||
|
|
||||||
### Backend
|
|
||||||
```bash
|
|
||||||
cd matrixgw_backend
|
|
||||||
cargo fmt && cargo clippy && cargo run --
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
```bash
|
|
||||||
cd matrixgw_frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|||||||
12199
assets/bootstrap.css
vendored
Normal file
12199
assets/bootstrap.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
30
assets/script.js
Normal file
30
assets/script.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Delete a client referenced by its ID
|
||||||
|
*
|
||||||
|
* @param clientID The ID of the client to delete
|
||||||
|
*/
|
||||||
|
async function deleteClient(clientID) {
|
||||||
|
if(!confirm("Do you really want to remove client " + clientID + "? The operation cannot be reverted!"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/", {
|
||||||
|
method: "POST",
|
||||||
|
headers:{
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
"delete_client_id": clientID
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if(res.status !== 200)
|
||||||
|
throw new Error(`Invalid status code: ${res.status}`);
|
||||||
|
|
||||||
|
alert("The client was successfully deleted!");
|
||||||
|
location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to delete client: ${e}`);
|
||||||
|
alert("Failed to delete client!");
|
||||||
|
}
|
||||||
|
}
|
||||||
12
assets/style.css
Normal file
12
assets/style.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
.body-content {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 50px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-content .card-header {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user_id_container {
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
68
assets/ws_debug.js
Normal file
68
assets/ws_debug.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
let ws;
|
||||||
|
|
||||||
|
const JS_MESSAGE = "JS code";
|
||||||
|
const IN_MESSAGE = "Incoming";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log message
|
||||||
|
*/
|
||||||
|
function log(src, txt) {
|
||||||
|
const target = document.getElementById("ws_log");
|
||||||
|
const msg = document.createElement("div");
|
||||||
|
msg.className = "message";
|
||||||
|
msg.innerHTML = `<div class='type'>${src}</div><div>${txt}</div>`
|
||||||
|
target.insertBefore(msg, target.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the state of the WebSocket
|
||||||
|
*/
|
||||||
|
function setState(state) {
|
||||||
|
document.getElementById("state").innerText = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize WebSocket connection
|
||||||
|
*/
|
||||||
|
function connect() {
|
||||||
|
disconnect();
|
||||||
|
log(JS_MESSAGE, "Initialize connection...");
|
||||||
|
ws = new WebSocket("/api/ws");
|
||||||
|
setState("Connecting...");
|
||||||
|
ws.onopen = function () {
|
||||||
|
log(JS_MESSAGE, "Connected to WebSocket !");
|
||||||
|
setState("Connected");
|
||||||
|
}
|
||||||
|
ws.onmessage = function (event) {
|
||||||
|
log(IN_MESSAGE, event.data);
|
||||||
|
}
|
||||||
|
ws.onclose = function () {
|
||||||
|
log(JS_MESSAGE, "Disconnected from WebSocket !");
|
||||||
|
setState("Disconnected");
|
||||||
|
}
|
||||||
|
ws.onerror = function (event) {
|
||||||
|
console.error("WS Error!", event);
|
||||||
|
log(JS_MESSAGE, `Error with websocket! ${event}`);
|
||||||
|
setState("Error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close WebSocket connection
|
||||||
|
*/
|
||||||
|
function disconnect() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
log(JS_MESSAGE, "Close connection...");
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
setState("Disconnected");
|
||||||
|
ws = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear WS logs
|
||||||
|
*/
|
||||||
|
function clearLogs() {
|
||||||
|
document.getElementById("ws_log").innerHTML = "";
|
||||||
|
}
|
||||||
@@ -80,23 +80,36 @@ services:
|
|||||||
element:
|
element:
|
||||||
image: docker.io/vectorim/element-web
|
image: docker.io/vectorim/element-web
|
||||||
ports:
|
ports:
|
||||||
- "8080:80/tcp"
|
- 8080:80/tcp
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/element/config.json:/app/config.json:ro
|
- ./docker/element/config.json:/app/config.json:ro
|
||||||
|
|
||||||
oidc:
|
oidc:
|
||||||
image: dexidp/dex
|
image: dexidp/dex
|
||||||
ports:
|
ports:
|
||||||
- "9001:9001"
|
- 9001:9001
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/dex:/conf:ro
|
- ./docker/dex:/conf:ro
|
||||||
command: [ "dex", "serve", "/conf/dex.config.yaml" ]
|
command: ["dex", "serve", "/conf/dex.config.yaml"]
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: quay.io/minio/minio
|
||||||
|
command: minio server --console-address ":9002" /data
|
||||||
|
ports:
|
||||||
|
- 9000:9000/tcp
|
||||||
|
- 9002:9002/tcp
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
volumes:
|
||||||
|
# You may store the database tables in a local folder..
|
||||||
|
- ./storage/minio:/data
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
command: redis-server --requirepass ${REDIS_PASS:-secretredis}
|
command: redis-server --requirepass ${REDIS_PASS:-secretredis}
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- 6379:6379
|
||||||
volumes:
|
volumes:
|
||||||
- ./storage/redis-data:/data
|
- ./storage/redis-data:/data
|
||||||
- ./storage/redis-conf:/usr/local/etc/redis/redis.conf
|
- ./storage/redis-conf:/usr/local/etc/redis/redis.conf
|
||||||
@@ -22,5 +22,5 @@ staticClients:
|
|||||||
- id: foo
|
- id: foo
|
||||||
secret: bar
|
secret: bar
|
||||||
redirectURIs:
|
redirectURIs:
|
||||||
- http://localhost:5173/oidc_cb
|
- http://localhost:8000/oidc_cb
|
||||||
name: Project
|
name: Project
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use jwt_simple::algorithms::HS256Key;
|
use jwt_simple::algorithms::HS256Key;
|
||||||
use jwt_simple::prelude::{Clock, Duration, JWTClaims, MACLike};
|
use jwt_simple::prelude::{Clock, Duration, JWTClaims, MACLike};
|
||||||
use matrixgw_backend::constants;
|
use matrix_gateway::extractors::client_auth::TokenClaims;
|
||||||
use matrixgw_backend::extractors::auth_extractor::{MatrixJWTKID, TokenClaims};
|
use matrix_gateway::utils::base_utils::rand_str;
|
||||||
use matrixgw_backend::users::{APITokenID, UserEmail};
|
|
||||||
use matrixgw_backend::utils::rand_utils::rand_string;
|
|
||||||
use std::ops::Add;
|
use std::ops::Add;
|
||||||
use std::os::unix::prelude::CommandExt;
|
use std::os::unix::prelude::CommandExt;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
/// cURL wrapper to query MatrixGW
|
/// cURL wrapper to query MatrixGW
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -22,9 +19,9 @@ struct Args {
|
|||||||
#[arg(short('i'), long, env)]
|
#[arg(short('i'), long, env)]
|
||||||
token_id: String,
|
token_id: String,
|
||||||
|
|
||||||
/// User email
|
/// User ID
|
||||||
#[arg(short('u'), long, env)]
|
#[arg(short('u'), long, env)]
|
||||||
user_mail: String,
|
user_id: String,
|
||||||
|
|
||||||
/// Token secret
|
/// Token secret
|
||||||
#[arg(short('t'), long, env)]
|
#[arg(short('t'), long, env)]
|
||||||
@@ -62,7 +59,7 @@ fn main() {
|
|||||||
subject: None,
|
subject: None,
|
||||||
audiences: None,
|
audiences: None,
|
||||||
jwt_id: None,
|
jwt_id: None,
|
||||||
nonce: Some(rand_string(10)),
|
nonce: Some(rand_str(10)),
|
||||||
custom: TokenClaims {
|
custom: TokenClaims {
|
||||||
method: args.method.to_string(),
|
method: args.method.to_string(),
|
||||||
uri: args.uri,
|
uri: args.uri,
|
||||||
@@ -71,20 +68,17 @@ fn main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let jwt = key
|
let jwt = key
|
||||||
.with_key_id(
|
.with_key_id(&format!(
|
||||||
&MatrixJWTKID {
|
"{}#{}",
|
||||||
user_email: UserEmail(args.user_mail),
|
urlencoding::encode(&args.user_id),
|
||||||
id: APITokenID::from_str(args.token_id.as_str())
|
urlencoding::encode(&args.token_id)
|
||||||
.expect("Failed to decode token ID!"),
|
))
|
||||||
}
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
.authenticate(claims)
|
.authenticate(claims)
|
||||||
.expect("Failed to sign JWT!");
|
.expect("Failed to sign JWT!");
|
||||||
|
|
||||||
let _ = Command::new("curl")
|
let _ = Command::new("curl")
|
||||||
.args(["-X", &args.method])
|
.args(["-X", &args.method])
|
||||||
.args(["-H", &format!("{}: {jwt}", constants::API_AUTH_HEADER)])
|
.args(["-H", &format!("x-client-auth: {jwt}")])
|
||||||
.args(args.run)
|
.args(args.run)
|
||||||
.arg(full_url)
|
.arg(full_url)
|
||||||
.exec();
|
.exec();
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Matrix Gateway backend
|
|
||||||
Backend component, written in Rust using Actix.
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
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)]
|
|
||||||
#[clap(author, version, about, long_about = None)]
|
|
||||||
pub struct AppConfig {
|
|
||||||
/// Listen address
|
|
||||||
#[clap(short, long, env, default_value = "0.0.0.0:8000")]
|
|
||||||
pub listen_address: String,
|
|
||||||
|
|
||||||
/// Website origin
|
|
||||||
#[clap(short, long, env, default_value = "http://localhost:5173")]
|
|
||||||
pub website_origin: String,
|
|
||||||
|
|
||||||
/// Proxy IP, might end with a star "*"
|
|
||||||
#[clap(short, long, env)]
|
|
||||||
pub proxy_ip: Option<String>,
|
|
||||||
|
|
||||||
/// Unsecure : for development, bypass authentication, using the account with the given
|
|
||||||
/// email address by default
|
|
||||||
#[clap(long, env)]
|
|
||||||
unsecure_auto_login_email: Option<String>,
|
|
||||||
|
|
||||||
/// Secret key, used to secure some resources. Must be randomly generated
|
|
||||||
#[clap(short = 'S', long, env, default_value = "")]
|
|
||||||
secret: String,
|
|
||||||
|
|
||||||
/// Matrix homeserver origin
|
|
||||||
#[clap(short, long, env, default_value = "http://127.0.0.1:8448")]
|
|
||||||
pub matrix_homeserver: String,
|
|
||||||
|
|
||||||
/// Redis connection hostname
|
|
||||||
#[clap(long, env, default_value = "localhost")]
|
|
||||||
redis_hostname: String,
|
|
||||||
|
|
||||||
/// Redis connection port
|
|
||||||
#[clap(long, env, default_value_t = 6379)]
|
|
||||||
redis_port: u16,
|
|
||||||
|
|
||||||
/// Redis database number
|
|
||||||
#[clap(long, env, default_value_t = 0)]
|
|
||||||
redis_db_number: i64,
|
|
||||||
|
|
||||||
/// Redis username
|
|
||||||
#[clap(long, env)]
|
|
||||||
redis_username: Option<String>,
|
|
||||||
|
|
||||||
/// Redis password
|
|
||||||
#[clap(long, env, default_value = "secretredis")]
|
|
||||||
redis_password: String,
|
|
||||||
|
|
||||||
/// URL where the OpenID configuration can be found
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
env,
|
|
||||||
default_value = "http://localhost:9001/dex/.well-known/openid-configuration"
|
|
||||||
)]
|
|
||||||
pub oidc_configuration_url: String,
|
|
||||||
|
|
||||||
/// OpenID provider name
|
|
||||||
#[arg(long, env, default_value = "3rd party provider")]
|
|
||||||
pub oidc_provider_name: String,
|
|
||||||
|
|
||||||
/// OpenID client ID
|
|
||||||
#[arg(long, env, default_value = "foo")]
|
|
||||||
pub oidc_client_id: String,
|
|
||||||
|
|
||||||
/// OpenID client secret
|
|
||||||
#[arg(long, env, default_value = "bar")]
|
|
||||||
pub oidc_client_secret: String,
|
|
||||||
|
|
||||||
/// OpenID login redirect URL
|
|
||||||
#[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,
|
|
||||||
}
|
|
||||||
|
|
||||||
lazy_static::lazy_static! {
|
|
||||||
static ref ARGS: AppConfig = {
|
|
||||||
AppConfig::parse()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppConfig {
|
|
||||||
/// Get parsed command line arguments
|
|
||||||
pub fn get() -> &'static AppConfig {
|
|
||||||
&ARGS
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get auto login email (if not empty)
|
|
||||||
pub fn unsecure_auto_login_email(&self) -> Option<UserEmail> {
|
|
||||||
match self.unsecure_auto_login_email.as_deref() {
|
|
||||||
None | Some("") => None,
|
|
||||||
Some(s) => Some(UserEmail(s.to_owned())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get app secret
|
|
||||||
pub fn secret(&self) -> &str {
|
|
||||||
let mut secret = self.secret.as_str();
|
|
||||||
|
|
||||||
if cfg!(debug_assertions) && secret.is_empty() {
|
|
||||||
secret = "DEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEY";
|
|
||||||
}
|
|
||||||
|
|
||||||
if secret.is_empty() {
|
|
||||||
panic!("SECRET is undefined or too short (min 64 chars)!")
|
|
||||||
}
|
|
||||||
|
|
||||||
secret
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if auth is disabled
|
|
||||||
pub fn is_auth_disabled(&self) -> bool {
|
|
||||||
self.unsecure_auto_login_email().is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get Redis connection configuration
|
|
||||||
pub fn redis_connection_string(&self) -> String {
|
|
||||||
format!(
|
|
||||||
"redis://{}:{}@{}:{}/{}",
|
|
||||||
self.redis_username.as_deref().unwrap_or(""),
|
|
||||||
self.redis_password,
|
|
||||||
self.redis_hostname,
|
|
||||||
self.redis_port,
|
|
||||||
self.redis_db_number
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get OpenID providers configuration
|
|
||||||
pub fn openid_provider(&self) -> OIDCProvider<'_> {
|
|
||||||
OIDCProvider {
|
|
||||||
client_id: self.oidc_client_id.as_str(),
|
|
||||||
client_secret: self.oidc_client_secret.as_str(),
|
|
||||||
configuration_url: self.oidc_configuration_url.as_str(),
|
|
||||||
name: self.oidc_provider_name.as_str(),
|
|
||||||
redirect_url: self
|
|
||||||
.oidc_redirect_url
|
|
||||||
.replace("APP_ORIGIN", &self.website_origin),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// User storage directory
|
|
||||||
pub fn user_directory(&self, mail: &UserEmail) -> PathBuf {
|
|
||||||
self.storage_path().join("users").join(sha256str(&mail.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// User metadata file
|
|
||||||
pub fn user_metadata_file_path(&self, mail: &UserEmail) -> PathBuf {
|
|
||||||
self.user_directory(mail).join("metadata.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// User API tokens directory
|
|
||||||
pub fn user_api_token_directory(&self, mail: &UserEmail) -> PathBuf {
|
|
||||||
self.user_directory(mail).join("api-tokens")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// User API token metadata file
|
|
||||||
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)]
|
|
||||||
pub struct OIDCProvider<'a> {
|
|
||||||
pub name: &'a str,
|
|
||||||
pub client_id: &'a str,
|
|
||||||
pub client_secret: &'a str,
|
|
||||||
pub configuration_url: &'a str,
|
|
||||||
pub redirect_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use crate::app_config::AppConfig;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verify_cli() {
|
|
||||||
use clap::CommandFactory;
|
|
||||||
AppConfig::command().debug_assert()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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 },
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
pub const OIDC_STATE_KEY: &str = "oidc-state";
|
|
||||||
/// OpenID auth remote IP address
|
|
||||||
pub const OIDC_REMOTE_IP: &str = "oidc-remote-ip";
|
|
||||||
/// 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);
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
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;
|
|
||||||
use actix_web::{HttpResponse, web};
|
|
||||||
use light_openid::primitives::OpenIDConfig;
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
struct StartOIDCResponse {
|
|
||||||
url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start OIDC authentication
|
|
||||||
pub async fn start_oidc(session: MatrixGWSession, remote_ip: RemoteIP) -> HttpResult {
|
|
||||||
let prov = AppConfig::get().openid_provider();
|
|
||||||
|
|
||||||
let conf = match OpenIDConfig::load_from_url(prov.configuration_url).await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to fetch OpenID provider configuration! {e}");
|
|
||||||
return Ok(HttpResponse::InternalServerError()
|
|
||||||
.json("Failed to fetch OpenID provider configuration!"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let state = match session.gen_oidc_state(remote_ip.0) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to generate auth state! {e}");
|
|
||||||
return Ok(HttpResponse::InternalServerError().json("Failed to generate auth state!"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(StartOIDCResponse {
|
|
||||||
url: conf.gen_authorization_url(
|
|
||||||
prov.client_id,
|
|
||||||
&state,
|
|
||||||
&AppConfig::get().openid_provider().redirect_url,
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct FinishOpenIDLoginQuery {
|
|
||||||
code: String,
|
|
||||||
state: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Finish OIDC authentication
|
|
||||||
pub async fn finish_oidc(
|
|
||||||
session: MatrixGWSession,
|
|
||||||
remote_ip: RemoteIP,
|
|
||||||
req: web::Json<FinishOpenIDLoginQuery>,
|
|
||||||
) -> HttpResult {
|
|
||||||
if let Err(e) = session.validate_state(&req.state, remote_ip.0) {
|
|
||||||
log::error!("Failed to validate OIDC CB state! {e}");
|
|
||||||
return Ok(HttpResponse::BadRequest().json("Invalid state!"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let prov = AppConfig::get().openid_provider();
|
|
||||||
|
|
||||||
let conf = OpenIDConfig::load_from_url(prov.configuration_url)
|
|
||||||
.await
|
|
||||||
.map_err(HttpFailure::OpenID)?;
|
|
||||||
|
|
||||||
let (token, _) = conf
|
|
||||||
.request_token(
|
|
||||||
prov.client_id,
|
|
||||||
prov.client_secret,
|
|
||||||
&req.code,
|
|
||||||
&AppConfig::get().openid_provider().redirect_url,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(HttpFailure::OpenID)?;
|
|
||||||
let (user_info, _) = conf
|
|
||||||
.request_user_info(&token)
|
|
||||||
.await
|
|
||||||
.map_err(HttpFailure::OpenID)?;
|
|
||||||
|
|
||||||
if user_info.email_verified != Some(true) {
|
|
||||||
log::error!("Email is not verified!");
|
|
||||||
return Ok(HttpResponse::Unauthorized().json("Email unverified by IDP!"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mail = match user_info.email {
|
|
||||||
Some(m) => m,
|
|
||||||
None => {
|
|
||||||
return Ok(HttpResponse::Unauthorized().json("Email not provided by the IDP!"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let user_name = user_info.name.unwrap_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"{} {}",
|
|
||||||
user_info.given_name.as_deref().unwrap_or(""),
|
|
||||||
user_info.family_name.as_deref().unwrap_or("")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
let user = User::create_or_update_user(&UserEmail(mail), &user_name).await?;
|
|
||||||
|
|
||||||
session.set_user(&user)?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().finish())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current user information
|
|
||||||
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,
|
|
||||||
tx: web::Data<BroadcastSender>,
|
|
||||||
) -> HttpResult {
|
|
||||||
match auth.method {
|
|
||||||
AuthenticatedMethod::Cookie => {
|
|
||||||
session.unset_current_user()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
AuthenticatedMethod::Token(token) => {
|
|
||||||
token.delete(&auth.user.email, &tx).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
AuthenticatedMethod::Dev => {
|
|
||||||
// Nothing to be done, user is always authenticated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(HttpResponse::NoContent().finish())
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
use crate::controllers::HttpResult;
|
|
||||||
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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl APIRoomInfo {
|
|
||||||
async fn from_room(r: &Room) -> anyhow::Result<Self> {
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 {
|
|
||||||
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.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.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
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod matrix_media_controller;
|
|
||||||
pub mod matrix_profile_controller;
|
|
||||||
pub mod matrix_room_controller;
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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 }))
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
use crate::app_config::AppConfig;
|
|
||||||
use actix_web::HttpResponse;
|
|
||||||
|
|
||||||
/// Serve robots.txt (disallow ranking)
|
|
||||||
pub async fn robots_txt() -> HttpResponse {
|
|
||||||
HttpResponse::Ok()
|
|
||||||
.content_type("text/plain")
|
|
||||||
.body("User-agent: *\nDisallow: /\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
pub struct LenConstraints {
|
|
||||||
min: usize,
|
|
||||||
max: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LenConstraints {
|
|
||||||
pub fn new(min: usize, max: usize) -> Self {
|
|
||||||
Self { min, max }
|
|
||||||
}
|
|
||||||
pub fn not_empty(max: usize) -> Self {
|
|
||||||
Self { min: 1, max }
|
|
||||||
}
|
|
||||||
pub fn max_only(max: usize) -> Self {
|
|
||||||
Self { min: 0, max }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_str(&self, s: &str) -> bool {
|
|
||||||
s.len() >= self.min && s.len() <= self.max
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_u32(&self, v: u32) -> bool {
|
|
||||||
v >= self.min as u32 && v <= self.max as u32
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
pub struct ServerConstraints {
|
|
||||||
pub token_name: LenConstraints,
|
|
||||||
pub token_ip_net: LenConstraints,
|
|
||||||
pub token_max_inactivity: LenConstraints,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ServerConstraints {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
token_name: LenConstraints::new(5, 255),
|
|
||||||
token_ip_net: LenConstraints::max_only(44),
|
|
||||||
token_max_inactivity: LenConstraints::new(3600, 3600 * 24 * 365),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
struct ServerConfig {
|
|
||||||
auth_disabled: bool,
|
|
||||||
oidc_provider_name: &'static str,
|
|
||||||
constraints: ServerConstraints,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ServerConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
auth_disabled: AppConfig::get().is_auth_disabled(),
|
|
||||||
oidc_provider_name: AppConfig::get().openid_provider().name,
|
|
||||||
constraints: Default::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get server static configuration
|
|
||||||
pub async fn config() -> HttpResponse {
|
|
||||||
HttpResponse::Ok().json(ServerConfig::default())
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
use crate::app_config::AppConfig;
|
|
||||||
use crate::constants;
|
|
||||||
use crate::extractors::session_extractor::MatrixGWSession;
|
|
||||||
use crate::users::{APIToken, APITokenID, User, UserEmail};
|
|
||||||
use crate::utils::time_utils::time_secs;
|
|
||||||
use actix_remote_ip::RemoteIP;
|
|
||||||
use actix_web::dev::Payload;
|
|
||||||
use actix_web::error::ErrorPreconditionFailed;
|
|
||||||
use actix_web::{FromRequest, HttpRequest};
|
|
||||||
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;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum AuthenticatedMethod {
|
|
||||||
/// User is authenticated using a cookie
|
|
||||||
Cookie,
|
|
||||||
/// User is authenticated through command line, for debugging purposes only
|
|
||||||
Dev,
|
|
||||||
/// User is authenticated using an API token
|
|
||||||
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,
|
|
||||||
pub id: APITokenID,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for MatrixJWTKID {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "{}#{}", self.user_email.0, self.id.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for MatrixJWTKID {
|
|
||||||
type Err = anyhow::Error;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
let (mail, token_id) = s
|
|
||||||
.split_once("#")
|
|
||||||
.context("Failed to decode KID in two parts!")?;
|
|
||||||
|
|
||||||
let mail = UserEmail(mail.to_string());
|
|
||||||
|
|
||||||
if !mail.is_valid() {
|
|
||||||
anyhow::bail!("Given email is invalid!")
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
user_email: mail,
|
|
||||||
id: token_id.parse().context("Failed to parse API token ID")?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub struct TokenClaims {
|
|
||||||
#[serde(rename = "met")]
|
|
||||||
pub method: String,
|
|
||||||
pub uri: String,
|
|
||||||
#[serde(rename = "pay", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub payload_sha256: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AuthExtractor {
|
|
||||||
async fn extract_auth(
|
|
||||||
req: &HttpRequest,
|
|
||||||
remote_ip: IpAddr,
|
|
||||||
payload_bytes: Option<Bytes>,
|
|
||||||
) -> Result<Self, actix_web::Error> {
|
|
||||||
// Check for authentication using API token
|
|
||||||
if let Some(token) = req.headers().get(constants::API_AUTH_HEADER) {
|
|
||||||
let Ok(jwt_token) = token.to_str() else {
|
|
||||||
return Err(actix_web::error::ErrorBadRequest(
|
|
||||||
"Failed to decode token as string!",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let metadata = match jwt_simple::token::Token::decode_metadata(jwt_token) {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to decode JWT header metadata! {e}");
|
|
||||||
return Err(actix_web::error::ErrorBadRequest(
|
|
||||||
"Failed to decode JWT header metadata!",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract token ID
|
|
||||||
let Some(kid) = metadata.key_id() else {
|
|
||||||
return Err(actix_web::error::ErrorBadRequest(
|
|
||||||
"Missing key id in request!",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let jwt_kid = match MatrixJWTKID::from_str(kid) {
|
|
||||||
Ok(i) => i,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to parse token id! {e}");
|
|
||||||
return Err(actix_web::error::ErrorBadRequest(
|
|
||||||
"Failed to parse token id!",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get token information
|
|
||||||
let Ok(mut token) = APIToken::load(&jwt_kid.user_email, &jwt_kid.id).await else {
|
|
||||||
log::error!("Token not found!");
|
|
||||||
return Err(actix_web::error::ErrorForbidden("Token not found!"));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Decode JWT
|
|
||||||
let key = HS256Key::from_bytes(token.secret.as_ref());
|
|
||||||
let verif = VerificationOptions {
|
|
||||||
max_validity: Some(Duration::from_secs(constants::API_TOKEN_JWT_MAX_DURATION)),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let claims = match key.verify_token::<TokenClaims>(jwt_token, Some(verif)) {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("JWT validation failed! {e}");
|
|
||||||
return Err(actix_web::error::ErrorForbidden("JWT validation failed!"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check for nonce
|
|
||||||
if claims.nonce.is_none() {
|
|
||||||
return Err(actix_web::error::ErrorBadRequest(
|
|
||||||
"A nonce is required in auth JWT!",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check IP restriction
|
|
||||||
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:?}",
|
|
||||||
token.id
|
|
||||||
);
|
|
||||||
return Err(actix_web::error::ErrorForbidden(
|
|
||||||
"This token cannot be used from this IP address!",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for write access
|
|
||||||
if token.base.read_only && !req.method().is_safe() {
|
|
||||||
return Err(actix_web::error::ErrorBadRequest(
|
|
||||||
"Read only token cannot perform write operations!",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user information
|
|
||||||
let Ok(user) = User::get_by_mail(&jwt_kid.user_email).await else {
|
|
||||||
return Err(actix_web::error::ErrorBadRequest(
|
|
||||||
"Failed to get user information from token!",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update last use (if needed)
|
|
||||||
if token.shall_update_time_used() {
|
|
||||||
token.last_used = time_secs();
|
|
||||||
if let Err(e) = token.write(&jwt_kid.user_email).await {
|
|
||||||
log::error!("Failed to refresh last usage of token! {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tokens expiration
|
|
||||||
if token.is_expired() {
|
|
||||||
log::error!("Attempted to use expired token! {token:?}");
|
|
||||||
return Err(actix_web::error::ErrorBadRequest("Token has expired!"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check payload
|
|
||||||
let payload = match (payload_bytes, claims.custom.payload_sha256) {
|
|
||||||
(None, _) => None,
|
|
||||||
(Some(_), None) => {
|
|
||||||
return Err(actix_web::error::ErrorBadRequest(
|
|
||||||
"A payload digest must be included in the JWT when the request has a payload!",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
(Some(payload), Some(provided_digest)) => {
|
|
||||||
let computed_digest = base16ct::lower::encode_string(&Sha256::digest(&payload));
|
|
||||||
if computed_digest != provided_digest {
|
|
||||||
log::error!(
|
|
||||||
"Expected digest {provided_digest} for payload but computed {computed_digest}!"
|
|
||||||
);
|
|
||||||
return Err(actix_web::error::ErrorBadRequest(
|
|
||||||
"Computed digest is different from the one provided in the JWT!",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(payload.to_vec())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return Ok(Self {
|
|
||||||
method: AuthenticatedMethod::Token(token),
|
|
||||||
user,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if login is hard-coded as program argument
|
|
||||||
if let Some(email) = &AppConfig::get().unsecure_auto_login_email() {
|
|
||||||
let user = User::get_by_mail(email).await.map_err(|e| {
|
|
||||||
log::error!("Failed to retrieve dev user: {e}");
|
|
||||||
ErrorPreconditionFailed("Unable to retrieve dev user!")
|
|
||||||
})?;
|
|
||||||
return Ok(Self {
|
|
||||||
method: AuthenticatedMethod::Dev,
|
|
||||||
user,
|
|
||||||
payload: payload_bytes.map(|bytes| bytes.to_vec()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for cookie authentication
|
|
||||||
let session = MatrixGWSession::extract(req).await?;
|
|
||||||
if let Some(mail) = session.current_user().map_err(|e| {
|
|
||||||
log::error!("Failed to retrieve user id: {e}");
|
|
||||||
ErrorPreconditionFailed("Failed to read session information!")
|
|
||||||
})? {
|
|
||||||
let user = User::get_by_mail(&mail).await.map_err(|e| {
|
|
||||||
log::error!("Failed to retrieve user from cookie session: {e}");
|
|
||||||
ErrorPreconditionFailed("Failed to retrieve user information!")
|
|
||||||
})?;
|
|
||||||
return Ok(Self {
|
|
||||||
method: AuthenticatedMethod::Cookie,
|
|
||||||
user,
|
|
||||||
payload: payload_bytes.map(|bytes| bytes.to_vec()),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
Err(ErrorPreconditionFailed("Authentication required!"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromRequest for AuthExtractor {
|
|
||||||
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 remote_ip = match RemoteIP::from_request(&req, &mut Payload::None).into_inner() {
|
|
||||||
Ok(ip) => ip,
|
|
||||||
Err(e) => return Box::pin(async { Err(e) }),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut payload = payload.take();
|
|
||||||
|
|
||||||
Box::pin(async move {
|
|
||||||
let payload_bytes = match Bytes::from_request(&req, &mut payload).await {
|
|
||||||
Ok(b) => {
|
|
||||||
if b.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to extract request payload! {e}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Self::extract_auth(&req, remote_ip.0, payload_bytes).await
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::extractors::auth_extractor::MatrixJWTKID;
|
|
||||||
use crate::users::{APITokenID, UserEmail};
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encode_decode_jwt_kid() {
|
|
||||||
let src = MatrixJWTKID {
|
|
||||||
user_email: UserEmail("test@mail.com".to_string()),
|
|
||||||
id: APITokenID::default(),
|
|
||||||
};
|
|
||||||
let encoded = src.to_string();
|
|
||||||
let decoded = encoded.parse::<MatrixJWTKID>().unwrap();
|
|
||||||
assert_eq!(src, decoded);
|
|
||||||
|
|
||||||
MatrixJWTKID::from_str("bad").unwrap_err();
|
|
||||||
MatrixJWTKID::from_str("ba#d").unwrap_err();
|
|
||||||
MatrixJWTKID::from_str("test@valid.com#d").unwrap_err();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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 })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod auth_extractor;
|
|
||||||
pub mod matrix_client_extractor;
|
|
||||||
pub mod session_extractor;
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
use crate::constants;
|
|
||||||
use crate::users::{User, UserEmail};
|
|
||||||
use crate::utils::rand_utils::rand_string;
|
|
||||||
use actix_session::Session;
|
|
||||||
use actix_web::dev::Payload;
|
|
||||||
use actix_web::{Error, FromRequest, HttpRequest};
|
|
||||||
use futures_util::future::{Ready, ready};
|
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
/// Matrix Gateway session errors
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
|
||||||
enum MatrixGWSessionError {
|
|
||||||
#[error("Missing state!")]
|
|
||||||
OIDCMissingState,
|
|
||||||
#[error("Missing IP address!")]
|
|
||||||
OIDCMissingIP,
|
|
||||||
#[error("Invalid state!")]
|
|
||||||
OIDCInvalidState,
|
|
||||||
#[error("Invalid IP address!")]
|
|
||||||
OIDCInvalidIP,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Matrix Gateway session
|
|
||||||
///
|
|
||||||
/// Basic wrapper around actix-session extractor
|
|
||||||
pub struct MatrixGWSession(Session);
|
|
||||||
|
|
||||||
impl MatrixGWSession {
|
|
||||||
/// Generate OpenID state for this session
|
|
||||||
pub fn gen_oidc_state(&self, ip: IpAddr) -> anyhow::Result<String> {
|
|
||||||
let random_string = rand_string(50);
|
|
||||||
self.0
|
|
||||||
.insert(constants::sessions::OIDC_STATE_KEY, random_string.clone())?;
|
|
||||||
self.0.insert(constants::sessions::OIDC_REMOTE_IP, ip)?;
|
|
||||||
Ok(random_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate OpenID state
|
|
||||||
pub fn validate_state(&self, state: &str, ip: IpAddr) -> anyhow::Result<()> {
|
|
||||||
let session_state: String = self
|
|
||||||
.0
|
|
||||||
.get(constants::sessions::OIDC_STATE_KEY)?
|
|
||||||
.ok_or(MatrixGWSessionError::OIDCMissingState)?;
|
|
||||||
|
|
||||||
let session_ip: IpAddr = self
|
|
||||||
.0
|
|
||||||
.get(constants::sessions::OIDC_REMOTE_IP)?
|
|
||||||
.ok_or(MatrixGWSessionError::OIDCMissingIP)?;
|
|
||||||
|
|
||||||
if session_state != state {
|
|
||||||
return Err(anyhow::anyhow!(MatrixGWSessionError::OIDCInvalidState));
|
|
||||||
}
|
|
||||||
|
|
||||||
if session_ip != ip {
|
|
||||||
return Err(anyhow::anyhow!(MatrixGWSessionError::OIDCInvalidIP));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set current user
|
|
||||||
pub fn set_user(&self, user: &User) -> anyhow::Result<()> {
|
|
||||||
self.0.insert(constants::sessions::USER_ID, &user.email)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current user
|
|
||||||
pub fn current_user(&self) -> anyhow::Result<Option<UserEmail>> {
|
|
||||||
Ok(self.0.get(constants::sessions::USER_ID)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove defined user
|
|
||||||
pub fn unset_current_user(&self) -> anyhow::Result<()> {
|
|
||||||
self.0.remove(constants::sessions::USER_ID);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromRequest for MatrixGWSession {
|
|
||||||
type Error = Error;
|
|
||||||
type Future = Ready<Result<Self, Error>>;
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
|
|
||||||
ready(
|
|
||||||
Session::from_request(req, &mut Payload::None)
|
|
||||||
.into_inner()
|
|
||||||
.map(MatrixGWSession),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
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::broadcast_messages::BroadcastMessage;
|
|
||||||
use matrixgw_backend::constants;
|
|
||||||
use matrixgw_backend::controllers::matrix::{
|
|
||||||
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<()> {
|
|
||||||
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
|
|
||||||
|
|
||||||
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")
|
|
||||||
.await
|
|
||||||
.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(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(
|
|
||||||
"/api/server/config",
|
|
||||||
web::get().to(server_controller::config),
|
|
||||||
)
|
|
||||||
// Auth controller
|
|
||||||
.route(
|
|
||||||
"/api/auth/start_oidc",
|
|
||||||
web::get().to(auth_controller::start_oidc),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/auth/finish_oidc",
|
|
||||||
web::post().to(auth_controller::finish_oidc),
|
|
||||||
)
|
|
||||||
.route("/api/auth/info", web::get().to(auth_controller::auth_info))
|
|
||||||
.route(
|
|
||||||
"/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/{id}",
|
|
||||||
web::get().to(matrix_room_controller::single_room_info),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/matrix/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 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?;
|
|
||||||
|
|
||||||
// Terminate manager actor
|
|
||||||
manager_actor.stop(None);
|
|
||||||
manager_actor_handle.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod matrix_client;
|
|
||||||
pub mod matrix_manager;
|
|
||||||
pub mod sync_thread;
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
//! # 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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
/// Matrix Gateway user errors
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
|
||||||
enum MatrixGWUserError {
|
|
||||||
#[error("Failed to load user metadata: {0}")]
|
|
||||||
LoadUserMetadata(std::io::Error),
|
|
||||||
#[error("Failed to decode user metadata: {0}")]
|
|
||||||
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}")]
|
|
||||||
LoadApiToken(std::io::Error),
|
|
||||||
#[error("Failed to decode API token: {0}")]
|
|
||||||
DecodeApiToken(serde_json::Error),
|
|
||||||
#[error("API Token does not exists!")]
|
|
||||||
ApiTokenDoesNotExists,
|
|
||||||
#[error("Failed to save API token: {0}")]
|
|
||||||
SaveAPIToken(std::io::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct UserEmail(pub String);
|
|
||||||
|
|
||||||
impl UserEmail {
|
|
||||||
pub fn is_valid(&self) -> bool {
|
|
||||||
mailchecker::is_valid(&self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)]
|
|
||||||
pub struct APITokenID(pub uuid::Uuid);
|
|
||||||
|
|
||||||
impl Default for APITokenID {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self(uuid::Uuid::new_v4())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for APITokenID {
|
|
||||||
type Err = uuid::Error;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
Ok(Self(uuid::Uuid::from_str(s)?))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
|
||||||
pub struct User {
|
|
||||||
pub email: UserEmail,
|
|
||||||
pub name: String,
|
|
||||||
pub time_create: u64,
|
|
||||||
pub last_login: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl User {
|
|
||||||
/// Get a user by its mail
|
|
||||||
pub async fn get_by_mail(mail: &UserEmail) -> anyhow::Result<Self> {
|
|
||||||
let path = AppConfig::get().user_metadata_file_path(mail);
|
|
||||||
let data = std::fs::read_to_string(path).map_err(MatrixGWUserError::LoadUserMetadata)?;
|
|
||||||
Ok(serde_json::from_str(&data).map_err(MatrixGWUserError::DecodeUserMetadata)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update user metadata on disk
|
|
||||||
pub async fn write(&self) -> anyhow::Result<()> {
|
|
||||||
let path = AppConfig::get().user_metadata_file_path(&self.email);
|
|
||||||
std::fs::write(&path, serde_json::to_string(&self)?)
|
|
||||||
.map_err(MatrixGWUserError::SaveUserMetadata)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create or update user information
|
|
||||||
pub async fn create_or_update_user(mail: &UserEmail, name: &str) -> anyhow::Result<User> {
|
|
||||||
let storage_dir = AppConfig::get().user_directory(mail);
|
|
||||||
let mut user = if !storage_dir.exists() {
|
|
||||||
std::fs::create_dir_all(storage_dir)?;
|
|
||||||
|
|
||||||
User {
|
|
||||||
email: mail.clone(),
|
|
||||||
name: name.to_string(),
|
|
||||||
time_create: time_secs(),
|
|
||||||
last_login: time_secs(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Self::get_by_mail(mail).await?
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update some user information
|
|
||||||
user.name = name.to_string();
|
|
||||||
user.last_login = time_secs();
|
|
||||||
user.write().await?;
|
|
||||||
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Base API token information
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
|
||||||
pub struct BaseAPIToken {
|
|
||||||
/// Token name
|
|
||||||
pub name: String,
|
|
||||||
|
|
||||||
/// Restricted API network for token
|
|
||||||
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,
|
|
||||||
|
|
||||||
/// Client creation time
|
|
||||||
pub created: u64,
|
|
||||||
|
|
||||||
/// Client last usage time
|
|
||||||
pub last_used: 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);
|
|
||||||
match token_file.exists() {
|
|
||||||
true => Ok(serde_json::from_str::<Self>(
|
|
||||||
&std::fs::read_to_string(&token_file).map_err(MatrixGWUserError::LoadApiToken)?,
|
|
||||||
)
|
|
||||||
.map_err(MatrixGWUserError::DecodeApiToken)?),
|
|
||||||
false => Err(MatrixGWUserError::ApiTokenDoesNotExists.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write this token information
|
|
||||||
pub async fn write(&self, mail: &UserEmail) -> anyhow::Result<()> {
|
|
||||||
let path = AppConfig::get().user_api_token_metadata_file(mail, &self.id);
|
|
||||||
std::fs::write(&path, serde_json::to_string(&self)?)
|
|
||||||
.map_err(MatrixGWUserError::SaveAPIToken)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete this token
|
|
||||||
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.base.max_inactivity / 10);
|
|
||||||
|
|
||||||
(self.last_used) < time_secs() - refresh_interval as u64
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_expired(&self) -> bool {
|
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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,3 +0,0 @@
|
|||||||
pub mod crypt_utils;
|
|
||||||
pub mod rand_utils;
|
|
||||||
pub mod time_utils;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
use rand::distr::{Alphanumeric, SampleString};
|
|
||||||
|
|
||||||
/// Generate a random string of a given length
|
|
||||||
pub fn rand_string(len: usize) -> String {
|
|
||||||
Alphanumeric.sample_string(&mut rand::rng(), len)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
/// Get the current time since epoch
|
|
||||||
pub fn time_secs() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs()
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
VITE_APP_BACKEND=http://localhost:8000/api
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
VITE_APP_BACKEND=/api
|
|
||||||
24
matrixgw_frontend/.gitignore
vendored
24
matrixgw_frontend/.gitignore
vendored
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# MatrixGW frontend
|
|
||||||
Built using React + TypeScript + Vite
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
reactHooks.configs['recommended-latest'],
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>MatrixGW</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
4317
matrixgw_frontend/package-lock.json
generated
4317
matrixgw_frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "matrixgw_frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"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-json-view-lite": "^2.5.0",
|
|
||||||
"react-router": "^7.9.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.36.0",
|
|
||||||
"@types/node": "^24.6.0",
|
|
||||||
"@types/react": "^19.1.16",
|
|
||||||
"@types/react-dom": "^19.1.9",
|
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
|
||||||
"eslint": "^9.36.0",
|
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.22",
|
|
||||||
"globals": "^16.4.0",
|
|
||||||
"typescript": "~5.9.3",
|
|
||||||
"typescript-eslint": "^8.45.0",
|
|
||||||
"vite": "npm:rolldown-vite@7.1.14"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"vite": "npm:rolldown-vite@7.1.14"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,69 +0,0 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
interface AuthContext {
|
|
||||||
signedIn: boolean;
|
|
||||||
setSignedIn: (signedIn: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)!;
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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)!;
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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)!;
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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)!;
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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)!;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import "@fontsource/roboto/300.css";
|
|
||||||
import "@fontsource/roboto/400.css";
|
|
||||||
import "@fontsource/roboto/500.css";
|
|
||||||
import "@fontsource/roboto/700.css";
|
|
||||||
|
|
||||||
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>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
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>
|
|
||||||
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { MatrixSyncApi } from "../api/MatrixSyncApi";
|
|
||||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
|
||||||
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
|
|
||||||
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
|
|
||||||
|
|
||||||
export function HomeRoute(): React.ReactElement {
|
|
||||||
const user = useUserInfo();
|
|
||||||
|
|
||||||
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<p>
|
|
||||||
Todo home route{" "}
|
|
||||||
<AsyncWidget
|
|
||||||
loadKey={1}
|
|
||||||
errMsg="Failed to start sync thread!"
|
|
||||||
load={MatrixSyncApi.Start}
|
|
||||||
build={() => <>sync started</>}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Application Theme
|
|
||||||
Taken from https://github.com/mui/material-ui/tree/v7.3.4/docs/data/material/getting-started/templates/shared-theme
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
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,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
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 }),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* Get UNIX time
|
|
||||||
*
|
|
||||||
* @returns Number of seconds since Epoch
|
|
||||||
*/
|
|
||||||
export function time(): number {
|
|
||||||
return Math.floor(new Date().getTime() / 1000);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export function NotLinkedAccountMessage(): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Your Matrix account is not linked yet!
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { Button } from "@mui/material";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import Toolbar from "@mui/material/Toolbar";
|
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
ref={layoutRef}
|
|
||||||
sx={{
|
|
||||||
position: "relative",
|
|
||||||
display: "flex",
|
|
||||||
overflow: "hidden",
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DashboardHeader
|
|
||||||
menuOpen={isNavigationExpanded}
|
|
||||||
onToggleMenu={handleToggleHeaderMenu}
|
|
||||||
/>
|
|
||||||
<DashboardSidebar
|
|
||||||
expanded={isNavigationExpanded}
|
|
||||||
setExpanded={setIsNavigationExpanded}
|
|
||||||
container={layoutRef?.current ?? undefined}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Toolbar sx={{ displayPrint: "none" }} />
|
|
||||||
<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)!;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user