5 Commits

Author SHA1 Message Date
Sharang Parnerkar
37478ba8f9 feat: basic project restructure 2026-02-16 21:23:25 +01:00
Sharang Parnerkar
f102d96c09 feat: added AGENTS.md 2026-02-15 23:38:13 +01:00
Sharang Parnerkar
c44177cbc2 feat: added cargo and clippy 2026-02-15 23:37:58 +01:00
Sharang Parnerkar
73ad7bd16d feat: added gitignore 2026-02-15 23:37:42 +01:00
Sharang Parnerkar
421f99537e feat: added server and web base code 2026-02-15 23:37:25 +01:00
30 changed files with 7312 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
**/target
**/dist
LICENSES
LICENSE
temp
README.md

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Keycloak Configuration (frontend public client)
KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=certifai
KEYCLOAK_CLIENT_ID=certifai-dashboard
# Application Configuration
APP_URL=http://localhost:8000
REDIRECT_URI=http://localhost:8000/auth/callback
ALLOWED_ORIGINS=http://localhost:8000

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Generated by Cargo
# will have compiled files and executables
/target
.DS_Store
# These are backup files generated by rustfmt
**/*.rs.bk
# Environment variables and secrets
.env
# Logs
*.log
# Keycloak data
keycloak/

265
AGENTS.md Normal file
View File

@@ -0,0 +1,265 @@
You are an expert [0.7 Dioxus](https://dioxuslabs.com/learn/0.7) assistant. Dioxus 0.7 changes every api in dioxus. Only use this up to date documentation. `cx`, `Scope`, and `use_state` are gone
Provide concise code examples with detailed descriptions
# Dioxus Dependency
You can add Dioxus to your `Cargo.toml` like this:
```toml
[dependencies]
dioxus = { version = "0.7.1" }
[features]
default = ["web", "webview", "server"]
web = ["dioxus/web"]
webview = ["dioxus/desktop"]
server = ["dioxus/server"]
```
# Launching your application
You need to create a main function that sets up the Dioxus runtime and mounts your root component.
```rust
use dioxus::prelude::*;
fn main() {
dioxus::launch(App);
}
#[component]
fn App() -> Element {
rsx! { "Hello, Dioxus!" }
}
```
Then serve with `dx serve`:
```sh
curl -sSL http://dioxus.dev/install.sh | sh
dx serve
```
# UI with RSX
```rust
rsx! {
div {
class: "container", // Attribute
color: "red", // Inline styles
width: if condition { "100%" }, // Conditional attributes
"Hello, Dioxus!"
}
// Prefer loops over iterators
for i in 0..5 {
div { "{i}" } // use elements or components directly in loops
}
if condition {
div { "Condition is true!" } // use elements or components directly in conditionals
}
{children} // Expressions are wrapped in brace
{(0..5).map(|i| rsx! { span { "Item {i}" } })} // Iterators must be wrapped in braces
}
```
# Assets
The asset macro can be used to link to local files to use in your project. All links start with `/` and are relative to the root of your project.
```rust
rsx! {
img {
src: asset!("/assets/image.png"),
alt: "An image",
}
}
```
## Styles
The `document::Stylesheet` component will inject the stylesheet into the `<head>` of the document
```rust
rsx! {
document::Stylesheet {
href: asset!("/assets/styles.css"),
}
}
```
# Components
Components are the building blocks of apps
* Component are functions annotated with the `#[component]` macro.
* The function name must start with a capital letter or contain an underscore.
* A component re-renders only under two conditions:
1. Its props change (as determined by `PartialEq`).
2. An internal reactive state it depends on is updated.
```rust
#[component]
fn Input(mut value: Signal<String>) -> Element {
rsx! {
input {
value,
oninput: move |e| {
*value.write() = e.value();
},
onkeydown: move |e| {
if e.key() == Key::Enter {
value.write().clear();
}
},
}
}
}
```
Each component accepts function arguments (props)
* Props must be owned values, not references. Use `String` and `Vec<T>` instead of `&str` or `&[T]`.
* Props must implement `PartialEq` and `Clone`.
* To make props reactive and copy, you can wrap the type in `ReadOnlySignal`. Any reactive state like memos and resources that read `ReadOnlySignal` props will automatically re-run when the prop changes.
# State
A signal is a wrapper around a value that automatically tracks where it's read and written. Changing a signal's value causes code that relies on the signal to rerun.
## Local State
The `use_signal` hook creates state that is local to a single component. You can call the signal like a function (e.g. `my_signal()`) to clone the value, or use `.read()` to get a reference. `.write()` gets a mutable reference to the value.
Use `use_memo` to create a memoized value that recalculates when its dependencies change. Memos are useful for expensive calculations that you don't want to repeat unnecessarily.
```rust
#[component]
fn Counter() -> Element {
let mut count = use_signal(|| 0);
let mut doubled = use_memo(move || count() * 2); // doubled will re-run when count changes because it reads the signal
rsx! {
h1 { "Count: {count}" } // Counter will re-render when count changes because it reads the signal
h2 { "Doubled: {doubled}" }
button {
onclick: move |_| *count.write() += 1, // Writing to the signal rerenders Counter
"Increment"
}
button {
onclick: move |_| count.with_mut(|count| *count += 1), // use with_mut to mutate the signal
"Increment with with_mut"
}
}
}
```
## Context API
The Context API allows you to share state down the component tree. A parent provides the state using `use_context_provider`, and any child can access it with `use_context`
```rust
#[component]
fn App() -> Element {
let mut theme = use_signal(|| "light".to_string());
use_context_provider(|| theme); // Provide a type to children
rsx! { Child {} }
}
#[component]
fn Child() -> Element {
let theme = use_context::<Signal<String>>(); // Consume the same type
rsx! {
div {
"Current theme: {theme}"
}
}
}
```
# Async
For state that depends on an asynchronous operation (like a network request), Dioxus provides a hook called `use_resource`. This hook manages the lifecycle of the async task and provides the result to your component.
* The `use_resource` hook takes an `async` closure. It re-runs this closure whenever any signals it depends on (reads) are updated
* The `Resource` object returned can be in several states when read:
1. `None` if the resource is still loading
2. `Some(value)` if the resource has successfully loaded
```rust
let mut dog = use_resource(move || async move {
// api request
});
match dog() {
Some(dog_info) => rsx! { Dog { dog_info } },
None => rsx! { "Loading..." },
}
```
# Routing
All possible routes are defined in a single Rust `enum` that derives `Routable`. Each variant represents a route and is annotated with `#[route("/path")]`. Dynamic Segments can capture parts of the URL path as parameters by using `:name` in the route string. These become fields in the enum variant.
The `Router<Route> {}` component is the entry point that manages rendering the correct component for the current URL.
You can use the `#[layout(NavBar)]` to create a layout shared between pages and place an `Outlet<Route> {}` inside your layout component. The child routes will be rendered in the outlet.
```rust
#[derive(Routable, Clone, PartialEq)]
enum Route {
#[layout(NavBar)] // This will use NavBar as the layout for all routes
#[route("/")]
Home {},
#[route("/blog/:id")] // Dynamic segment
BlogPost { id: i32 },
}
#[component]
fn NavBar() -> Element {
rsx! {
a { href: "/", "Home" }
Outlet<Route> {} // Renders Home or BlogPost
}
}
#[component]
fn App() -> Element {
rsx! { Router::<Route> {} }
}
```
```toml
dioxus = { version = "0.7.1", features = ["router"] }
```
# Fullstack
Fullstack enables server rendering and ipc calls. It uses Cargo features (`server` and a client feature like `web`) to split the code into a server and client binaries.
```toml
dioxus = { version = "0.7.1", features = ["fullstack"] }
```
## Server Functions
Use the `#[post]` / `#[get]` macros to define an `async` function that will only run on the server. On the server, this macro generates an API endpoint. On the client, it generates a function that makes an HTTP request to that endpoint.
```rust
#[post("/api/double/:path/&query")]
async fn double_server(number: i32, path: String, query: i32) -> Result<i32, ServerFnError> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(number * 2)
}
```
## Hydration
Hydration is the process of making a server-rendered HTML page interactive on the client. The server sends the initial HTML, and then the client-side runs, attaches event listeners, and takes control of future rendering.
### Errors
The initial UI rendered by the component on the client must be identical to the UI rendered on the server.
* Use the `use_server_future` hook instead of `use_resource`. It runs the future on the server, serializes the result, and sends it to the client, ensuring the client has the data immediately for its first render.
* Any code that relies on browser-specific APIs (like accessing `localStorage`) must be run *after* hydration. Place this code inside a `use_effect` hook.

5492
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

94
Cargo.toml Normal file
View File

@@ -0,0 +1,94 @@
[package]
name = "dashboard"
version = "0.1.0"
authors = ["Sharang Parnerkar <parnerkarsharang@gmail.com>"]
edition = "2021"
default-run = "dashboard"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lints.clippy]
# We avoid panicking behavior in our code.
# In some places where panicking is desired, such as in tests,
# we can allow it by using #[allow(clippy::unwrap_used, clippy::expect_used].
unwrap_used = "deny"
expect_used = "deny"
[dependencies]
dioxus = { version = "=0.7.3", features = ["fullstack", "router"] }
dioxus-sdk = { version = "0.7", default-features = false, features = [
"time",
"storage",
] }
axum = { version = "0.8.8", optional = true }
chrono = { version = "0.4" }
tower-http = { version = "0.6.2", features = [
"cors",
"trace",
], optional = true }
tokio = { version = "1.4", features = ["time"] }
serde = { version = "1.0.210", features = ["derive"] }
thiserror = { version = "2.0", default-features = false }
dotenvy = { version = "0.15", default-features = false }
mongodb = { version = "3.2", default-features = false, features = [
"rustls-tls",
"compat-3-0-0",
], optional = true }
futures = { version = "0.3.31", default-features = false }
reqwest = { version = "0.13", optional = true, features = ["json", "form"] }
tower-sessions = { version = "0.15", default-features = false, features = [
"axum-core",
"memory-store",
"signed",
], optional = true }
time = { version = "0.3", default-features = false, optional = true }
rand = { version = "0.10", optional = true }
petname = { version = "2.0", default-features = false, features = [
"default-rng",
"default-words",
], optional = true }
async-stripe = { version = "0.41", optional = true, default-features = false, features = [
"runtime-tokio-hyper-rustls-webpki",
"webhook-events",
"billing",
"checkout",
"products",
"connect",
"stream",
] }
secrecy = { version = "0.10", default-features = false, optional = true }
serde_json = { version = "1.0.133", default-features = false }
maud = { version = "0.27", default-features = false }
url = { version = "2.5.4", default-features = false, optional = true }
web-sys = { version = "0.3", optional = true, features = [
"Clipboard",
"Navigator",
] }
tracing = "0.1.40"
# Debug
dioxus-logger = "=0.7.3"
dioxus-cli-config = "=0.7.3"
dioxus-free-icons = { version = "0.10", features = [
"bootstrap",
"font-awesome-solid",
] }
[features]
# default = ["web"]
web = ["dioxus/web", "dep:reqwest", "dep:web-sys"]
server = [
"dioxus/server",
"dep:axum",
"dep:mongodb",
"dep:reqwest",
"dep:tower-sessions",
"dep:tower-http",
"dep:time",
"dep:rand",
"dep:url",
]
[[bin]]
name = "dashboard"
path = "bin/main.rs"

39
Dioxus.toml Normal file
View File

@@ -0,0 +1,39 @@
[application]
# App (Project) Name
name = "dashboard"
# Dioxus App Default Platform
default_platform = "web"
# resource (assets) file folder
asset_dir = "assets"
[web.app]
# HTML title tag content
title = "GenAI Dashboard"
# include `assets` in web platform
[web.resource]
# Additional CSS style files
style = []
# Additional JavaScript files
script = []
[web.resource.dev]
# Javascript code file
# serve: [dev-server] only
script = []
[web.watcher]
# when watcher trigger, regenerate the `index.html`
reload_html = true
# which files or dirs will be watcher monitoring
watch_path = ["src", "assets"]

BIN
assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

20
assets/header.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

107
assets/main.css Normal file
View File

@@ -0,0 +1,107 @@
/* App-wide styling */
body {
background-color: #0f1116;
color: #ffffff;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 20px;
}
#hero {
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#links {
width: 400px;
text-align: left;
font-size: x-large;
color: white;
display: flex;
flex-direction: column;
}
#links a {
color: white;
text-decoration: none;
margin-top: 20px;
margin: 10px 0px;
border: white 1px solid;
border-radius: 5px;
padding: 10px;
}
#links a:hover {
background-color: #1f1f1f;
cursor: pointer;
}
#header {
max-width: 1200px;
}
/* Navbar */
#navbar {
display: flex;
flex-direction: row;
}
#navbar a {
color: #ffffff;
margin-right: 20px;
text-decoration: none;
transition: color 0.2s ease;
}
#navbar a:hover {
cursor: pointer;
color: #91a4d2;
}
/* Blog page */
#blog {
margin-top: 50px;
}
#blog a {
color: #ffffff;
margin-top: 50px;
}
/* Echo */
#echo {
width: 360px;
margin-left: auto;
margin-right: auto;
margin-top: 50px;
background-color: #1e222d;
padding: 20px;
border-radius: 10px;
}
#echo>h4 {
margin: 0px 0px 15px 0px;
}
#echo>input {
border: none;
border-bottom: 1px white solid;
background-color: transparent;
color: #ffffff;
transition: border-bottom-color 0.2s ease;
outline: none;
display: block;
padding: 0px 0px 5px 0px;
width: 100%;
}
#echo>input:focus {
border-bottom-color: #6d85c6;
}
#echo>p {
margin: 20px 0px 0px auto;
}

300
assets/tailwind.css Normal file
View File

@@ -0,0 +1,300 @@
/*! tailwindcss v4.1.5 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--spacing: 0.25rem;
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden='until-found'])) {
display: none !important;
}
}
@layer utilities {
.visible {
visibility: visible;
}
.relative {
position: relative;
}
.static {
position: static;
}
.container {
width: 100%;
@media (width >= 40rem) {
max-width: 40rem;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 64rem) {
max-width: 64rem;
}
@media (width >= 80rem) {
max-width: 80rem;
}
@media (width >= 96rem) {
max-width: 96rem;
}
}
.hidden {
display: none;
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
.p-6 {
padding: calc(var(--spacing) * 6);
}
.text-center {
text-align: center;
}
.filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
}
@property --tw-rotate-x {
syntax: "*";
inherits: false;
}
@property --tw-rotate-y {
syntax: "*";
inherits: false;
}
@property --tw-rotate-z {
syntax: "*";
inherits: false;
}
@property --tw-skew-x {
syntax: "*";
inherits: false;
}
@property --tw-skew-y {
syntax: "*";
inherits: false;
}
@property --tw-blur {
syntax: "*";
inherits: false;
}
@property --tw-brightness {
syntax: "*";
inherits: false;
}
@property --tw-contrast {
syntax: "*";
inherits: false;
}
@property --tw-grayscale {
syntax: "*";
inherits: false;
}
@property --tw-hue-rotate {
syntax: "*";
inherits: false;
}
@property --tw-invert {
syntax: "*";
inherits: false;
}
@property --tw-opacity {
syntax: "*";
inherits: false;
}
@property --tw-saturate {
syntax: "*";
inherits: false;
}
@property --tw-sepia {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-drop-shadow-size {
syntax: "*";
inherits: false;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-rotate-x: initial;
--tw-rotate-y: initial;
--tw-rotate-z: initial;
--tw-skew-x: initial;
--tw-skew-y: initial;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
--tw-grayscale: initial;
--tw-hue-rotate: initial;
--tw-invert: initial;
--tw-opacity: initial;
--tw-saturate: initial;
--tw-sepia: initial;
--tw-drop-shadow: initial;
--tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial;
}
}
}

21
bin/main.rs Normal file
View File

@@ -0,0 +1,21 @@
#![allow(non_snake_case)]
#[allow(clippy::expect_used)]
fn main() {
// Init logger
dioxus_logger::init(tracing::Level::DEBUG).expect("Failed to init logger");
#[cfg(feature = "web")]
{
tracing::info!("Starting app...");
// Hydrate the application on the client
dioxus::web::launch::launch_cfg(dashboard::App, dioxus::web::Config::new().hydrate(true));
}
#[cfg(feature = "server")]
{
tracing::info!("Starting server...");
dashboard::infrastructure::server::server_start(dashboard::App)
.map_err(|e| tracing::error! {"Failed to start server: {:?}", e})
.expect("Failed to start server");
}
}

17
build.rs Normal file
View File

@@ -0,0 +1,17 @@
#[allow(clippy::expect_used)]
fn main() -> Result<(), Box<dyn std::error::Error>> {
use std::process::Command;
println!("cargo:rerun-if-changed=./styles/input.css");
Command::new("bunx")
.args([
"@tailwindcss/cli",
"-i",
"./styles/input.css",
"-o",
"./assets/tailwind.css",
])
.status()
.expect("could not run tailwind");
Ok(())
}

8
clippy.toml Normal file
View File

@@ -0,0 +1,8 @@
await-holding-invalid-types = [
"generational_box::GenerationalRef",
{ path = "generational_box::GenerationalRef", reason = "Reads should not be held over an await point. This will cause any writes to fail while the await is pending since the read borrow is still active." },
"generational_box::GenerationalRefMut",
{ path = "generational_box::GenerationalRefMut", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." },
"dioxus_signals::WriteLock",
{ path = "dioxus_signals::WriteLock", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." },
]

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
version: '3.8'
services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
container_name: certifai-keycloak
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_DB: dev-mem
ports:
- "8080:8080"
command:
- start-dev
- --import-realm
volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
interval: 10s
timeout: 5s
retries: 5
mongo:
image: mongo:latest
restart: unless-stopped
ports:
- 27017:27017
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example

122
src/app.rs Normal file
View File

@@ -0,0 +1,122 @@
use crate::{components::*, pages::*};
use dioxus::prelude::*;
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
pub enum Route {
#[layout(Navbar)]
#[route("/")]
OverviewPage {},
#[route("/login?:redirect_url")]
Login { redirect_url: String },
#[route("/blog/:id")]
Blog { id: i32 },
}
const FAVICON: Asset = asset!("/assets/favicon.ico");
const MAIN_CSS: Asset = asset!("/assets/main.css");
const HEADER_SVG: Asset = asset!("/assets/header.svg");
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
#[component]
pub fn App() -> Element {
rsx! {
document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "stylesheet", href: MAIN_CSS }
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
Router::<Route> {}
}
}
#[component]
pub fn Hero() -> Element {
rsx! {
div { id: "hero",
img { src: HEADER_SVG, id: "header" }
div { id: "links",
a { href: "https://dioxuslabs.com/learn/0.7/", "📚 Learn Dioxus" }
a { href: "https://dioxuslabs.com/awesome", "🚀 Awesome Dioxus" }
a { href: "https://github.com/dioxus-community/", "📡 Community Libraries" }
a { href: "https://github.com/DioxusLabs/sdk", "⚙️ Dioxus Development Kit" }
a { href: "https://marketplace.visualstudio.com/items?itemName=DioxusLabs.dioxus",
"💫 VSCode Extension"
}
a { href: "https://discord.gg/XgGxMSkvUM", "👋 Community Discord" }
}
}
}
}
/// Home page
#[component]
fn Home() -> Element {
rsx! {
Hero {}
Echo {}
}
}
/// Blog page
#[component]
pub fn Blog(id: i32) -> Element {
rsx! {
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" }
}
}
}
/// Shared navbar component.
#[component]
fn Navbar() -> Element {
rsx! {
div { id: "navbar",
Link { to: Route::OverviewPage {}, "Home" }
Link { to: Route::Blog { id: 1 }, "Blog" }
}
Outlet::<Route> {}
}
}
/// Echo component that demonstrates fullstack server functions.
#[component]
fn Echo() -> Element {
let mut response = use_signal(|| String::new());
rsx! {
div { id: "echo",
h4 { "ServerFn Echo" }
input {
placeholder: "Type here to echo...",
oninput: move |event| async move {
let data = echo_server(event.value()).await.unwrap();
response.set(data);
},
}
if !response().is_empty() {
p {
"Server echoed: "
i { "{response}" }
}
}
}
}
}
/// Echo the user input on the server.
#[post("/api/echo")]
async fn echo_server(input: String) -> Result<String, ServerFnError> {
Ok(input)
}

15
src/components/login.rs Normal file
View File

@@ -0,0 +1,15 @@
use crate::Route;
use dioxus::prelude::*;
#[component]
pub fn Login(redirect_url: String) -> Element {
let navigator = use_navigator();
use_effect(move || {
let target = format!("/auth?redirect_url={}", redirect_url);
navigator.push(NavigationTarget::<Route>::External(target));
});
rsx!(
div { class: "text-center p-6", "Redirecting to secure login page…" }
)
}

2
src/components/mod.rs Normal file
View File

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

109
src/infrastructure/auth.rs Normal file
View File

@@ -0,0 +1,109 @@
use super::error::{Error, Result};
use axum::Extension;
use axum::{
extract::FromRequestParts,
http::request::Parts,
response::{IntoResponse, Redirect, Response},
};
use url::form_urlencoded;
pub struct KeycloakVariables {
pub base_url: String,
pub realm: String,
pub client_id: String,
pub client_secret: String,
pub enable_test_user: bool,
}
/// Session data available to the backend when the user is logged in
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct LoggedInData {
pub id: String,
// ID Token value associated with the authenticated session.
pub token_id: String,
pub username: String,
pub avatar_url: Option<String>,
}
/// Used for extracting in the server functions.
/// If the `data` is `Some`, the user is logged in.
pub struct UserSession {
data: Option<LoggedInData>,
}
impl UserSession {
/// Get the [`LoggedInData`].
///
/// Raises a [`Error::UserNotLoggedIn`] error if the user is not logged in.
pub fn data(self) -> Result<LoggedInData> {
self.data.ok_or(Error::UserNotLoggedIn)
}
}
const LOGGED_IN_USER_SESSION_KEY: &str = "logged_in_data";
impl<S: std::marker::Sync + std::marker::Send> FromRequestParts<S> for UserSession {
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self> {
let session = parts
.extensions
.get::<tower_sessions::Session>()
.cloned()
.ok_or(Error::AuthSessionLayerNotFound(
"Auth Session Layer not found".to_string(),
))?;
let data: Option<LoggedInData> = session
.get::<LoggedInData>(LOGGED_IN_USER_SESSION_KEY)
.await?;
Ok(Self { data })
}
}
/// Helper function to log the user in by setting the session data
pub async fn login(session: &tower_sessions::Session, data: &LoggedInData) -> Result<()> {
session.insert(LOGGED_IN_USER_SESSION_KEY, data).await?;
Ok(())
}
/// Handler to run when the user wants to logout
#[axum::debug_handler]
pub async fn logout(
state: Extension<super::server_state::ServerState>,
session: tower_sessions::Session,
) -> Result<Response> {
let dashboard_base_url = "http://localhost:8000";
let redirect_uri = format!("{dashboard_base_url}/");
let encoded_redirect_uri: String =
form_urlencoded::byte_serialize(redirect_uri.as_bytes()).collect();
// clear the session value for this session
if let Some(login_data) = session
.remove::<LoggedInData>(LOGGED_IN_USER_SESSION_KEY)
.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;
// 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");
let token_id = login_data.token_id;
// redirect to Keycloak logout endpoint
let logout_url = format!(
"{routed_kc_base_url}/realms/{kc_realm}/protocol/openid-connect/logout\
?post_logout_redirect_uri={encoded_redirect_uri}\
&client_id={kc_client_id}\
&id_token_hint={token_id}"
);
Ok(Redirect::to(&logout_url).into_response())
} else {
// No id_token in session; just redirect to homepage
Ok(Redirect::to(&redirect_uri).into_response())
}
}

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(())
}
}

View File

@@ -0,0 +1,78 @@
use axum::response::{IntoResponse, Redirect, Response};
use reqwest::StatusCode;
use crate::Route;
pub type Result<T> = core::result::Result<T, Error>;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("{0}")]
NotFound(String),
#[error("{0}")]
BadRequest(String),
#[error("ReqwestError: {0}")]
ReqwestError(#[from] reqwest::Error),
#[error("ServerStateError: {0}")]
ServerStateError(String),
#[error("SessionError: {0}")]
SessionError(#[from] tower_sessions::session::Error),
#[error("AuthSessionLayerNotFound: {0}")]
AuthSessionLayerNotFound(String),
#[error("UserNotLoggedIn")]
UserNotLoggedIn,
#[error("MongoDbError: {0}")]
MongoDbError(#[from] mongodb::error::Error),
#[error("MongoBsonError: {0}")]
MongoBsonError(#[from] mongodb::bson::ser::Error),
#[error("MongoObjectIdParseError: {0}")]
MongoObjectIdParseError(#[from] mongodb::bson::oid::Error),
#[error("IoError: {0}")]
IoError(#[from] std::io::Error),
#[error("GeneralError: {0}")]
GeneralError(String),
#[error("SerdeError: {0}")]
SerdeError(#[from] serde_json::Error),
#[error("Forbidden: {0}")]
Forbidden(String),
}
impl IntoResponse for Error {
#[tracing::instrument]
fn into_response(self) -> Response {
let message = self.to_string();
tracing::error!("Converting Error to Reponse: {message}");
match self {
Error::NotFound(_) => (StatusCode::NOT_FOUND, message).into_response(),
Error::BadRequest(_) => (StatusCode::BAD_REQUEST, message).into_response(),
// ideally we would like to redirect with the original URL as the target, but we do not have access to it here
Error::UserNotLoggedIn => Redirect::to(
&Route::Login {
redirect_url: Route::OverviewPage {}.to_string(),
}
.to_string(),
)
.into_response(),
Error::Forbidden(_) => (StatusCode::FORBIDDEN, message).into_response(),
// INTERNAL_SERVER_ERROR variants
_ => {
tracing::error!("Internal Server Error: {:?}", message);
(StatusCode::INTERNAL_SERVER_ERROR, message).into_response()
}
}
}
}

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)
}
}
}

10
src/infrastructure/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
#![cfg(feature = "server")]
mod login;
pub mod auth;
pub mod db;
pub mod error;
pub mod server;
pub mod server_state;
pub mod user;

View File

@@ -0,0 +1,105 @@
use super::error::Error;
use super::server_state::ServerState;
use crate::infrastructure::{auth::KeycloakVariables, server_state::ServerStateInner};
use axum::{routing::*, Extension};
use dioxus::dioxus_core::Element;
use dioxus::prelude::*;
use dioxus_logger::tracing::info;
use reqwest::{
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
Method,
};
use time::Duration;
use tower_http::cors::{Any, CorsLayer};
use tower_sessions::{
cookie::{Key, SameSite},
Expiry, MemoryStore, SessionManagerLayer,
};
pub fn server_start(app_fn: fn() -> Element) -> Result<(), Error> {
dotenvy::dotenv().ok();
tokio::runtime::Runtime::new()?.block_on(async move {
info!("Connecting to the database ...");
let mongodb_uri = get_env_variable("MONGODB_URI");
let client = mongodb::Client::with_uri_str(mongodb_uri).await?;
let db = super::db::Database::new(client).await;
info!("Connected");
let keycloak_variables: KeycloakVariables = KeycloakVariables {
base_url: get_env_variable("BASE_URL_AUTH"),
realm: get_env_variable("KC_REALM"),
client_id: get_env_variable("KC_CLIENT_ID"),
client_secret: get_env_variable("KC_CLIENT_SECRET"),
enable_test_user: std::env::var("ENABLE_TEST_USER").is_ok_and(|v| v == "yes"),
};
let state: ServerState = ServerStateInner {
db,
keycloak_variables: Box::leak(Box::new(keycloak_variables)),
}
.into();
// This uses `tower-sessions` to establish a layer that will provide the session
// as a request extension.
let key = Key::generate(); // This is only used for demonstration purposes; provide a proper
// cryptographic key in a real application.
let session_store = MemoryStore::default();
let session_layer = SessionManagerLayer::new(session_store)
// only allow session cookie in HTTPS connections (also works on localhost)
.with_secure(true)
.with_expiry(Expiry::OnInactivity(Duration::days(1)))
// Allow the session cookie to be sent when request originates from outside our
// domain. Required for the browser to pass the cookie when returning from github auth page.
.with_same_site(SameSite::Lax)
.with_signed(key);
let cors = CorsLayer::new()
// allow `GET` and `POST` when accessing the resource
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
// .allow_credentials(true)
.allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE])
// allow requests from any origin
.allow_origin(Any);
// Build our application web api router.
let web_api_router = Router::new()
// .route("/webhook/gitlab", post(super::gitlab::webhook_handler))
.route("/auth", get(super::login::redirect_to_keycloack_login))
.route("/auth/logout", get(super::auth::logout))
.route("/auth/callback", get(super::login::handle_login_callback))
// Server side render the application, serve static assets, and register the server functions.
.serve_dioxus_application(ServeConfig::default(), app_fn)
.layer(Extension(state))
.layer(session_layer)
.layer(cors)
.layer(tower_http::trace::TraceLayer::new_for_http());
// Start it.
let addr = dioxus_cli_config::fullstack_address_or_localhost();
info!("Server address: {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, web_api_router.into_make_service()).await?;
Ok(())
})
}
/// Tries to load the value from an environment as String.
///
/// # Arguments
///
/// * `key` - the environment variable key to try to load
///
/// # Panics
///
/// Panics if the environment variable does not exist.
fn get_env_variable(key: &str) -> String {
std::env::var(key).unwrap_or_else(|_| {
tracing::error!("{key} environment variable not set. {key} must be set!");
panic!("Environment variable {key} not present")
})
}

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,
}

8
src/lib.rs Normal file
View File

@@ -0,0 +1,8 @@
mod app;
mod components;
pub mod infrastructure;
mod pages;
pub use app::*;
pub use components::*;
pub use pages::*;

2
src/pages/mod.rs Normal file
View File

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

8
src/pages/overview.rs Normal file
View File

@@ -0,0 +1,8 @@
use dioxus::prelude::*;
#[component]
pub fn OverviewPage() -> Element {
rsx! {
h1 { "Hello" }
}
}

1
tailwind.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";