ci: added basic workflows #2

Merged
sharang merged 14 commits from feat/CAI-5 into main 2026-02-18 09:46:29 +00:00
21 changed files with 427 additions and 465 deletions
Showing only changes of commit 2eb9f699aa - Show all commits

View File

@@ -1,10 +0,0 @@
[package]
name = "api"
version = "0.1.0"
edition = "2021"
[dependencies]
dioxus = { workspace = true, features = ["fullstack"] }
[features]
server = ["dioxus/server"]

View File

@@ -1,13 +0,0 @@
# API
This crate contains all shared fullstack server functions. This is a great place to place any server-only logic you would like to expose in multiple platforms like a method that accesses your database or a method that sends an email.
This crate will be built twice:
1. Once for the server build with the `dioxus/server` feature enabled
2. Once for the client build with the client feature disabled
During the server build, the server functions will be collected and hosted on a public API for the client to call. During the client build, the server functions will be compiled into the client build.
## Dependencies
Most server dependencies (like sqlx and tokio) will not compile on client platforms like WASM. To avoid building server dependencies on the client, you should add platform specific dependencies under the `server` feature in the [Cargo.toml](../Cargo.toml) file. More details about managing server only dependencies can be found in the [Dioxus guide](https://dioxuslabs.com/learn/0.7/guides/fullstack/managing_dependencies#adding-server-only-dependencies).

View File

@@ -1,8 +0,0 @@
//! This crate contains all shared fullstack server functions.
use dioxus::prelude::*;
/// Echo the user input on the server.
#[post("/api/echo")]
pub async fn echo(input: String) -> Result<String, ServerFnError> {
Ok(input)
}

View File

@@ -1,13 +0,0 @@
[package]
name = "web"
version = "0.1.0"
edition = "2021"
[dependencies]
dioxus = { workspace = true, features = ["router", "fullstack"] }
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
[features]
default = []
web = ["dioxus/web"]
server = ["dioxus/server"]

View File

@@ -1,30 +0,0 @@
# Development
The web crate defines the entrypoint for the web app along with any assets, components and dependencies that are specific to web builds. The web crate starts out something like this:
```
web/
├─ assets/ # Assets used by the web app - Any platform specific assets should go in this folder
├─ src/
│ ├─ main.rs # The entrypoint for the web app.It also defines the routes for the web platform
│ ├─ views/ # The views each route will render in the web version of the app
│ │ ├─ mod.rs # Defines the module for the views route and re-exports the components for each route
│ │ ├─ blog.rs # The component that will render at the /blog/:id route
│ │ ├─ home.rs # The component that will render at the / route
├─ Cargo.toml # The web crate's Cargo.toml - This should include all web specific dependencies
```
## Dependencies
Since you have fullstack enabled, the web crate will be built two times:
1. Once for the server build with the `server` feature enabled
2. Once for the client build with the `web` feature enabled
You should make all web specific dependencies optional and only enabled in the `web` feature. This will ensure that the server builds don't pull in web specific dependencies which cuts down on build times significantly.
### Serving Your Web App
You can start your web app with the following command:
```bash
dx serve
```

View File

@@ -1,8 +0,0 @@
#blog {
margin-top: 50px;
}
#blog a {
color: #ffffff;
margin-top: 50px;
}

View File

@@ -1,87 +0,0 @@
/* This file contains the global styles for the styled dioxus components. You only
* need to import this file once in your project root.
*/
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
body {
color: var(--secondary-color-4);
font-family: Inter, sans-serif;
font-optical-sizing: auto;
font-style: normal;
font-weight: 400;
}
html[data-theme="dark"] {
--dark: initial;
--light: ;
}
html[data-theme="light"] {
--dark: ;
--light: initial;
}
@media (prefers-color-scheme: dark) {
:root {
--dark: initial;
--light: ;
}
}
@media (prefers-color-scheme: light) {
:root {
--dark: ;
--light: initial;
}
}
:root {
/* Primary colors */
--primary-color: var(--dark, #000) var(--light, #fff);
--primary-color-1: var(--dark, #0e0e0e) var(--light, #fbfbfb);
--primary-color-2: var(--dark, #0a0a0a) var(--light, #fff);
--primary-color-3: var(--dark, #141313) var(--light, #f8f8f8);
--primary-color-4: var(--dark, #1a1a1a) var(--light, #f8f8f8);
--primary-color-5: var(--dark, #262626) var(--light, #f5f5f5);
--primary-color-6: var(--dark, #232323) var(--light, #e5e5e5);
--primary-color-7: var(--dark, #3e3e3e) var(--light, #b0b0b0);
/* Secondary colors */
--secondary-color: var(--dark, #fff) var(--light, #000);
--secondary-color-1: var(--dark, #fafafa) var(--light, #000);
--secondary-color-2: var(--dark, #e6e6e6) var(--light, #0d0d0d);
--secondary-color-3: var(--dark, #dcdcdc) var(--light, #2b2b2b);
--secondary-color-4: var(--dark, #d4d4d4) var(--light, #111);
--secondary-color-5: var(--dark, #a1a1a1) var(--light, #848484);
--secondary-color-6: var(--dark, #5d5d5d) var(--light, #d0d0d0);
/* Highlight colors */
--focused-border-color: var(--dark, #2b7fff) var(--light, #2b7fff);
--primary-success-color: var(--dark, #02271c) var(--light, #ecfdf5);
--secondary-success-color: var(--dark, #b6fae3) var(--light, #10b981);
--primary-warning-color: var(--dark, #342203) var(--light, #fffbeb);
--secondary-warning-color: var(--dark, #feeac7) var(--light, #f59e0b);
--primary-error-color: var(--dark, #a22e2e) var(--light, #dc2626);
--secondary-error-color: var(--dark, #9b1c1c) var(--light, #ef4444);
--contrast-error-color: var(--dark, var(--secondary-color-3)) var(--light, var(--primary-color));
--primary-info-color: var(--dark, var(--primary-color-5)) var(--light, var(--primary-color));
--secondary-info-color: var(--dark, var(--primary-color-7)) var(--light, var(--secondary-color-3));
}
/* Modern browsers with `scrollbar-*` support */
@supports (scrollbar-width: auto) {
:not(:hover) {
scrollbar-color: rgb(0 0 0 / 0%) rgb(0 0 0 / 0%);
}
:hover {
scrollbar-color: var(--secondary-color-2) rgb(0 0 0 / 0%);
}
}
/* Legacy browsers with `::-webkit-scrollbar-*` support */
@supports selector(::-webkit-scrollbar) {
:root::-webkit-scrollbar-track {
background: transparent;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -1,6 +0,0 @@
body {
background-color: #0f1116;
color: #ffffff;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 20px;
}

View File

@@ -1,2 +0,0 @@
// AUTOGENERATED Components module
pub mod toast;

View File

@@ -1,15 +0,0 @@
use dioxus::prelude::*;
use dioxus_primitives::toast::{self, ToastProviderProps};
#[component]
pub fn ToastProvider(props: ToastProviderProps) -> Element {
rsx! {
document::Link { rel: "stylesheet", href: asset!("./style.css") }
toast::ToastProvider {
default_duration: props.default_duration,
max_toasts: props.max_toasts,
render_toast: props.render_toast,
{props.children}
}
}
}

View File

@@ -1,2 +0,0 @@
mod component;
pub use component::*;

View File

@@ -1,185 +0,0 @@
.toast-container {
position: fixed;
z-index: 9999;
right: 20px;
bottom: 20px;
max-width: 350px;
}
.toast-list {
display: flex;
flex-direction: column-reverse;
padding: 0;
margin: 0;
gap: 0.75rem;
}
.toast-item {
display: flex;
}
.toast {
z-index: calc(var(--toast-count) - var(--toast-index));
display: flex;
overflow: hidden;
width: 18rem;
height: 4rem;
box-sizing: border-box;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border: 1px solid var(--light, var(--primary-color-6))
var(--dark, var(--primary-color-7));
border-radius: 0.5rem;
margin-top: -4rem;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
filter: var(--light, none)
var(
--dark,
brightness(calc(0.5 + 0.5 * (1 - ((var(--toast-index) + 1) / 4))))
);
opacity: calc(1 - var(--toast-hidden));
transform: scale(
calc(100% - var(--toast-index) * 5%),
calc(100% - var(--toast-index) * 2%)
);
transition: transform 0.2s ease, margin-top 0.2s ease, opacity 0.2s ease;
--toast-hidden: calc(min(max(0, var(--toast-index) - 2), 1));
}
.toast-container:not(:hover, :focus-within)
.toast[data-toast-even]:not([data-top]) {
animation: slide-up-even 0.2s ease-out;
}
.toast-container:not(:hover, :focus-within)
.toast[data-toast-odd]:not([data-top]) {
animation: slide-up-odd 0.2s ease-out;
}
@keyframes slide-up-even {
from {
transform: translateY(0.5rem)
scale(
calc(100% - var(--toast-index) * 5%),
calc(100% - var(--toast-index) * 2%)
);
}
to {
transform: translateY(0)
scale(
calc(100% - var(--toast-index) * 5%),
calc(100% - var(--toast-index) * 2%)
);
}
}
@keyframes slide-up-odd {
from {
transform: translateY(0.5rem)
scale(
calc(100% - var(--toast-index) * 5%),
calc(100% - var(--toast-index) * 2%)
);
}
to {
transform: translateY(0)
scale(
calc(100% - var(--toast-index) * 5%),
calc(100% - var(--toast-index) * 2%)
);
}
}
.toast[data-top] {
animation: slide-in 0.2s ease-out;
}
.toast-container:hover .toast[data-top],
.toast-container:focus-within .toast[data-top] {
animation: slide-in 0 ease-out;
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(100%)
scale(
calc(110% - var(--toast-index) * 5%),
calc(110% - var(--toast-index) * 2%)
);
}
to {
opacity: 1;
transform: translateY(0)
scale(
calc(100% - var(--toast-index) * 5%),
calc(100% - var(--toast-index) * 2%)
);
}
}
.toast-container:hover .toast,
.toast-container:focus-within .toast {
margin-top: var(--toast-padding);
filter: brightness(1);
opacity: 1;
transform: scale(calc(100%));
}
.toast[data-type="success"] {
background-color: var(--primary-success-color);
color: var(--secondary-success-color);
}
.toast[data-type="error"] {
background-color: var(--primary-error-color);
color: var(--contrast-error-color);
}
.toast[data-type="warning"] {
background-color: var(--primary-warning-color);
color: var(--secondary-warning-color);
}
.toast[data-type="info"] {
background-color: var(--primary-info-color);
color: var(--secondary-info-color);
}
.toast-content {
flex: 1;
margin-right: 8px;
transition: filter 0.2s ease;
}
.toast-title {
margin-bottom: 4px;
color: var(--secondary-color-4);
font-weight: 600;
}
.toast-description {
color: var(--secondary-color-3);
font-size: 0.875rem;
}
.toast-close {
align-self: flex-start;
padding: 0;
border: none;
margin: 0;
background: none;
color: var(--secondary-color-3);
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.toast-close:hover {
color: var(--secondary-color-1);
}

View File

@@ -1,45 +0,0 @@
use dioxus::prelude::*;
use views::{Blog, Home};
mod views;
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
enum Route {
#[layout(WebNavbar)]
#[route("/")]
Home {},
#[route("/blog/:id")]
Blog { id: i32 },
}
const FAVICON: Asset = asset!("/assets/favicon.ico");
const MAIN_CSS: Asset = asset!("/assets/main.css");
fn main() {
dioxus::launch(App);
}
#[component]
fn App() -> Element {
// Build cool things ✌️
rsx! {
// Global app resources
document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "stylesheet", href: MAIN_CSS }
Router::<Route> {}
}
}
/// A web-specific Router around the shared `Navbar` component
/// which allows us to use the web-specific `Route` enum.
#[component]
fn WebNavbar() -> Element {
rsx! {
Outlet::<Route> {}
}
}

View File

@@ -1,30 +0,0 @@
use crate::Route;
use dioxus::prelude::*;
const BLOG_CSS: Asset = asset!("/assets/blog.css");
#[component]
pub fn Blog(id: i32) -> Element {
rsx! {
document::Link { rel: "stylesheet", href: BLOG_CSS}
div {
id: "blog",
// Content
h1 { "This is blog #{id}!" }
p { "In blog #{id}, we show how the Dioxus router works and how URL parameters can be passed as props to our route components." }
// Navigation links
Link {
to: Route::Blog { id: id - 1 },
"Previous"
}
span { " <---> " }
Link {
to: Route::Blog { id: id + 1 },
"Next"
}
}
}
}

View File

@@ -1,6 +0,0 @@
use dioxus::prelude::*;
#[component]
pub fn Home() -> Element {
rsx! {}
}

View File

@@ -1,5 +0,0 @@
mod home;
pub use home::Home;
mod blog;
pub use blog::Blog;

49
src/infrastructure/db.rs Normal file
View File

@@ -0,0 +1,49 @@
use super::error::Result;
use super::user::{KeyCloakSub, UserEntity};
use mongodb::{bson::doc, Client, Collection};
pub struct Database {
client: Client,
}
impl Database {
pub async fn new(client: Client) -> Self {
Self { client }
}
}
/// Impl of project related DB actions
impl Database {}
/// Impl of user-related actions
impl Database {
async fn users_collection(&self) -> Collection<UserEntity> {
self.client
.database("dashboard")
.collection::<UserEntity>("users")
}
pub async fn get_user_by_kc_sub(&self, kc_sub: KeyCloakSub) -> Result<Option<UserEntity>> {
let c = self.users_collection().await;
let result = c
.find_one(doc! {
"kc_sub" : kc_sub.0
})
.await?;
Ok(result)
}
pub async fn get_user_by_id(&self, user_id: &str) -> Result<Option<UserEntity>> {
let c = self.users_collection().await;
let user_id: mongodb::bson::oid::ObjectId = user_id.parse()?;
let filter = doc! { "_id" : user_id };
let result = c.find_one(filter).await?;
Ok(result)
}
pub async fn insert_user(&self, user: &UserEntity) -> Result<()> {
let c = self.users_collection().await;
let _ = c.insert_one(user).await?;
Ok(())
}
}

302
src/infrastructure/login.rs Normal file
View File

@@ -0,0 +1,302 @@
use super::error::Result;
use super::user::{KeyCloakSub, UserEntity};
use crate::Route;
use axum::{
extract::Query,
response::{IntoResponse, Redirect, Response},
Extension,
};
use reqwest::StatusCode;
use tracing::{info, warn};
use url::form_urlencoded;
#[derive(serde::Deserialize)]
pub struct CallbackCode {
code: Option<String>,
error: Option<String>,
}
const LOGIN_REDIRECT_URL_SESSION_KEY: &str = "login.redirect.url";
const TEST_USER_SUB: KeyCloakSub = KeyCloakSub(String::new());
#[derive(serde::Deserialize)]
pub struct LoginRedirectQuery {
redirect_url: Option<String>,
}
/// Handler that redirects the user to the login page of Keycloack.
#[axum::debug_handler]
pub async fn redirect_to_keycloack_login(
state: Extension<super::server_state::ServerState>,
user_session: super::auth::UserSession,
session: tower_sessions::Session,
query: Query<LoginRedirectQuery>,
) -> Result<Response> {
// check if already logged in before redirecting again
if user_session.data().is_ok() {
return Ok(Redirect::to(&Route::OverviewPage {}.to_string()).into_response());
}
if let Some(url) = &query.redirect_url {
if !url.is_empty() {
session.insert(LOGIN_REDIRECT_URL_SESSION_KEY, &url).await?;
}
}
// if this is a test user then skip login
if state.keycloak_variables.enable_test_user {
return login_test_user(state, session).await;
}
let kc_base_url = &state.keycloak_variables.base_url;
let kc_realm = &state.keycloak_variables.realm;
let kc_client_id = &state.keycloak_variables.client_id;
let redirect_uri = format!("http://localhost:8000/auth/callback");
let encoded_redirect_uri: String =
form_urlencoded::byte_serialize(redirect_uri.as_bytes()).collect();
// Needed for running locally.
// This will not panic on production and it will return the original so we can keep it
let routed_kc_base_url = kc_base_url.replace("keycloak", "localhost");
Ok(Redirect::to(
format!("{routed_kc_base_url}/realms/{kc_realm}/protocol/openid-connect/auth?client_id={kc_client_id}&response_type=code&scope=openid%20profile%20email&redirect_uri={encoded_redirect_uri}").as_str())
.into_response())
}
/// Helper function that automatically logs the user in as a test user.
async fn login_test_user(
state: Extension<super::server_state::ServerState>,
session: tower_sessions::Session,
) -> Result<Response> {
let user = state.db.get_user_by_kc_sub(TEST_USER_SUB).await?;
// if we do not have a test user already, create one
let user = if let Some(user) = user {
info!("Existing test user logged in");
user
} else {
info!("Test User not found, inserting ...");
let user = UserEntity {
_id: mongodb::bson::oid::ObjectId::new(),
created_at: mongodb::bson::DateTime::now(),
kc_sub: TEST_USER_SUB,
email: "exampleuser@domain.com".to_string(),
};
state.db.insert_user(&user).await?;
user
};
info!("Test User successfuly logged in: {:?}", user);
let data = super::auth::LoggedInData {
id: user._id.to_string(),
token_id: String::new(),
username: "tester".to_string(),
avatar_url: None,
};
super::auth::login(&session, &data).await?;
// redirect to the URL stored in the session if available
let redirect_url = session
.remove::<String>(LOGIN_REDIRECT_URL_SESSION_KEY)
.await?
.unwrap_or_else(|| Route::OverviewPage {}.to_string());
Ok(Redirect::to(&redirect_url).into_response())
}
/// Handler function executed once KC redirects back to us. Creates database entries if
/// needed and initializes the user session to mark the user as "logged in".
#[axum::debug_handler]
pub async fn handle_login_callback(
state: Extension<super::server_state::ServerState>,
session: tower_sessions::Session,
Query(params): Query<CallbackCode>,
) -> Result<Response> {
// now make sure the user actually authorized the app and that there was no error
let Some(code) = params.code else {
warn!("Code was not provided, error: {:?}", params.error);
return Ok(Redirect::to(&Route::OverviewPage {}.to_string()).into_response());
};
// if on dev environment we get the internal kc url
let kc_base_url = std::env::var("KEYCLOAK_ADMIN_URL")
.unwrap_or_else(|_| state.keycloak_variables.base_url.clone());
let kc_realm = &state.keycloak_variables.realm;
let kc_client_id = &state.keycloak_variables.client_id;
let kc_client_secret = &state.keycloak_variables.client_secret;
let redirect_uri = format!("http://localhost:8000/auth/callback");
// exchange the code for an access token
let token = exchange_code(
&code,
&kc_base_url,
kc_realm,
kc_client_id,
kc_client_secret,
redirect_uri.as_str(),
)
.await?;
// use the access token to get the user information
let user_info = get_user_info(&token, &kc_base_url, kc_realm).await?;
// Check if the user is a member of the organization (only on dev and demo environments)
let base_url = state.keycloak_variables.base_url.clone();
let is_for_devs = base_url.contains("dev") || base_url.contains("demo");
if is_for_devs {
let Some(github_login) = user_info.github_login.as_ref() else {
return Err(crate::infrastructure::error::Error::Forbidden(
"GitHub login not available.".to_string(),
));
};
if !is_org_member(github_login).await? {
return Err(crate::infrastructure::error::Error::Forbidden(
"You are not a member of the organization.".to_string(),
));
}
}
// now check if we have a user already
let kc_sub = KeyCloakSub(user_info.sub);
let user = state.db.get_user_by_kc_sub(kc_sub.clone()).await?;
// if we do not have a user already, create one
let user = if let Some(user) = user {
info!("Existing user logged in");
user
} else {
info!("User not found, creating ...");
let user = UserEntity {
_id: mongodb::bson::oid::ObjectId::new(),
created_at: mongodb::bson::DateTime::now(),
kc_sub,
email: user_info.email.clone(),
};
state.db.insert_user(&user).await?;
user
};
info!("User successfuly logged in");
// we now have access token and information about the user that just logged in, as well as an
// existing or newly created user database entity.
// Store information in session storage that we want (eg name and avatar url + databae id) to make the user "logged in"!
// Redirect the user somewhere
let data = super::auth::LoggedInData {
id: user._id.to_string(),
token_id: token.id_token,
username: user_info.preferred_username,
avatar_url: user_info.picture,
};
super::auth::login(&session, &data).await?;
// redirect to the URL stored in the session if available
let redirect_url = session
.remove::<String>(LOGIN_REDIRECT_URL_SESSION_KEY)
.await?
.unwrap_or_else(|| Route::OverviewPage {}.to_string());
Ok(Redirect::to(&redirect_url).into_response())
}
#[derive(serde::Deserialize)]
#[allow(dead_code)] // not all fields are currently used
struct AccessToken {
access_token: String,
expires_in: u64,
refresh_token: String,
refresh_expires_in: u64,
id_token: String,
}
/// Exchange KC code for an access token
async fn exchange_code(
code: &str,
kc_base_url: &str,
kc_realm: &str,
kc_client_id: &str,
kc_client_secret: &str,
redirect_uri: &str,
) -> Result<AccessToken> {
let res = reqwest::Client::new()
.post(format!(
"{kc_base_url}/realms/{kc_realm}/protocol/openid-connect/token",
))
.form(&[
("grant_type", "authorization_code"),
("client_id", kc_client_id),
("client_secret", kc_client_secret),
("code", code),
("redirect_uri", redirect_uri),
])
.send()
.await?;
let res: AccessToken = res.json().await?;
Ok(res)
}
/// Query the openid-connect endpoint to get the user info by using the access token.
async fn get_user_info(token: &AccessToken, kc_base_url: &str, kc_realm: &str) -> Result<UserInfo> {
let client = reqwest::Client::new();
let url = format!("{kc_base_url}/realms/{kc_realm}/protocol/openid-connect/userinfo");
let mut request = client.get(&url).bearer_auth(token.access_token.clone());
// If KEYCLOAK_ADMIN_URL is NOT set (i.e. we're on the local Keycloak),
// add the HOST header for local testing.
if std::env::var("KEYCLOAK_ADMIN_URL").is_err() {
request = request.header("HOST", "localhost:8888");
}
let res = request.send().await?;
let res: UserInfo = res.json().await?;
Ok(res)
}
/// Contains selected fields from the user information call to KC
/// https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
#[derive(serde::Deserialize)]
#[allow(dead_code)] // not all fields are currently used
struct UserInfo {
sub: String, // subject element of the ID Token
name: String,
given_name: String,
family_name: String,
preferred_username: String,
email: String,
picture: Option<String>,
github_login: Option<String>,
}
/// Check if a user is a member of the organization
const GITHUB_ORG: &str = "etospheres-labs";
async fn is_org_member(username: &str) -> Result<bool> {
let url = format!("https://api.github.com/orgs/{GITHUB_ORG}/members/{username}");
let client = reqwest::Client::new();
let response = client
.get(&url)
.header("Accept", "application/vnd.github+json") // GitHub requires a User-Agent header.
.header("User-Agent", "etopay-app")
.send()
.await?;
match response.status() {
StatusCode::NO_CONTENT => Ok(true),
status => {
tracing::warn!(
"{}: User '{}' is not a member of the organization",
status.as_str(),
username
);
Ok(false)
}
}
}

View File

@@ -0,0 +1,55 @@
//! Implements a [`ServerState`] that is available in the dioxus server functions
//! as well as in axum handlers.
//! Taken from https://github.com/dxps/dioxus_playground/tree/44a4ddb223e6afe50ef195e61aa2b7182762c7da/dioxus-05-fullstack-routing-axum-pgdb
use super::auth::KeycloakVariables;
use super::error::{Error, Result};
use axum::http;
use std::ops::Deref;
use std::sync::Arc;
/// This is stored as an "extension" object in the axum webserver
/// We can get it in the dioxus server functions using
/// ```rust
/// let state: crate::infrastructure::server_state::ServerState = extract().await?;
/// ```
#[derive(Clone)]
pub struct ServerState(Arc<ServerStateInner>);
impl Deref for ServerState {
type Target = ServerStateInner;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub struct ServerStateInner {
pub db: crate::infrastructure::db::Database,
pub keycloak_variables: &'static KeycloakVariables,
}
impl From<ServerStateInner> for ServerState {
fn from(value: ServerStateInner) -> Self {
Self(Arc::new(value))
}
}
impl<S> axum::extract::FromRequestParts<S> for ServerState
where
S: std::marker::Sync + std::marker::Send,
{
type Rejection = Error;
async fn from_request_parts(parts: &mut http::request::Parts, _: &S) -> Result<Self> {
parts
.extensions
.get::<ServerState>()
.cloned()
.ok_or(Error::ServerStateError(
"ServerState extension should exist".to_string(),
))
}
}

View File

@@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
/// Wraps a `String` to store the sub from KC
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyCloakSub(pub String);
/// database entity to store our users
#[derive(Debug, Serialize, Deserialize)]
pub struct UserEntity {
/// Our unique id of the user, for now this is just the mongodb assigned id
pub _id: mongodb::bson::oid::ObjectId,
/// Time the user was created
pub created_at: mongodb::bson::DateTime,
/// KC subject element of the ID Token
pub kc_sub: KeyCloakSub,
/// User email as provided during signup with the identity provider
pub email: String,
}