feat: basic project restructure
This commit is contained in:
@@ -1,10 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "api"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
dioxus = { workspace = true, features = ["fullstack"] }
|
|
||||||
|
|
||||||
[features]
|
|
||||||
server = ["dioxus/server"]
|
|
||||||
@@ -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).
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#blog {
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#blog a {
|
|
||||||
color: #ffffff;
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
@@ -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 |
@@ -1,6 +0,0 @@
|
|||||||
body {
|
|
||||||
background-color: #0f1116;
|
|
||||||
color: #ffffff;
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
margin: 20px;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// AUTOGENERATED Components module
|
|
||||||
pub mod toast;
|
|
||||||
@@ -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}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
mod component;
|
|
||||||
pub use component::*;
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
use dioxus::prelude::*;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Home() -> Element {
|
|
||||||
rsx! {}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mod home;
|
|
||||||
pub use home::Home;
|
|
||||||
|
|
||||||
mod blog;
|
|
||||||
pub use blog::Blog;
|
|
||||||
49
src/infrastructure/db.rs
Normal file
49
src/infrastructure/db.rs
Normal 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
302
src/infrastructure/login.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/infrastructure/server_state.rs
Normal file
55
src/infrastructure/server_state.rs
Normal 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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/infrastructure/user.rs
Normal file
21
src/infrastructure/user.rs
Normal 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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user