Compare commits
5 Commits
main
...
feat/CAI-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37478ba8f9 | ||
|
|
f102d96c09 | ||
|
|
c44177cbc2 | ||
|
|
73ad7bd16d | ||
|
|
421f99537e |
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
**/target
|
||||
**/dist
|
||||
LICENSES
|
||||
LICENSE
|
||||
temp
|
||||
README.md
|
||||
9
.env.example
Normal file
9
.env.example
Normal 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
16
.gitignore
vendored
Normal 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
265
AGENTS.md
Normal 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
5492
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
94
Cargo.toml
Normal file
94
Cargo.toml
Normal 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
39
Dioxus.toml
Normal 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
BIN
assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
20
assets/header.svg
Normal file
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
107
assets/main.css
Normal 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
300
assets/tailwind.css
Normal 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
21
bin/main.rs
Normal 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
17
build.rs
Normal 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
8
clippy.toml
Normal 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
31
docker-compose.yml
Normal 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
122
src/app.rs
Normal 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
15
src/components/login.rs
Normal 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
2
src/components/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod login;
|
||||
pub use login::*;
|
||||
109
src/infrastructure/auth.rs
Normal file
109
src/infrastructure/auth.rs
Normal 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
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(())
|
||||
}
|
||||
}
|
||||
78
src/infrastructure/error.rs
Normal file
78
src/infrastructure/error.rs
Normal 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
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/infrastructure/mod.rs
Normal file
10
src/infrastructure/mod.rs
Normal 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;
|
||||
105
src/infrastructure/server.rs
Normal file
105
src/infrastructure/server.rs
Normal 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")
|
||||
})
|
||||
}
|
||||
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,
|
||||
}
|
||||
8
src/lib.rs
Normal file
8
src/lib.rs
Normal 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
2
src/pages/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod overview;
|
||||
pub use overview::*;
|
||||
8
src/pages/overview.rs
Normal file
8
src/pages/overview.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn OverviewPage() -> Element {
|
||||
rsx! {
|
||||
h1 { "Hello" }
|
||||
}
|
||||
}
|
||||
1
tailwind.css
Normal file
1
tailwind.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
Reference in New Issue
Block a user