feat: basic project restructure
This commit is contained in:
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
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -5,3 +5,12 @@
|
|||||||
|
|
||||||
# These are backup files generated by rustfmt
|
# These are backup files generated by rustfmt
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# Environment variables and secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Keycloak data
|
||||||
|
keycloak/
|
||||||
|
|||||||
1803
Cargo.lock
generated
1803
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
99
Cargo.toml
99
Cargo.toml
@@ -1,9 +1,94 @@
|
|||||||
[workspace]
|
[package]
|
||||||
resolver = "2"
|
name = "dashboard"
|
||||||
members = ["packages/web", "packages/api"]
|
version = "0.1.0"
|
||||||
|
authors = ["Sharang Parnerkar <parnerkarsharang@gmail.com>"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
[workspace.dependencies]
|
default-run = "dashboard"
|
||||||
dioxus = { version = "0.7.1" }
|
|
||||||
|
|
||||||
# workspace
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
api = { path = "packages/api" }
|
|
||||||
|
[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"]
|
||||||
|
Before Width: | Height: | Size: 130 KiB 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(())
|
||||||
|
}
|
||||||
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
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "api"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
dioxus = { workspace = true, features = ["fullstack"] }
|
|
||||||
|
|
||||||
[features]
|
|
||||||
server = ["dioxus/server"]
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# API
|
|
||||||
|
|
||||||
This crate contains all shared fullstack server functions. This is a great place to place any server-only logic you would like to expose in multiple platforms like a method that accesses your database or a method that sends an email.
|
|
||||||
|
|
||||||
This crate will be built twice:
|
|
||||||
1. Once for the server build with the `dioxus/server` feature enabled
|
|
||||||
2. Once for the client build with the client feature disabled
|
|
||||||
|
|
||||||
During the server build, the server functions will be collected and hosted on a public API for the client to call. During the client build, the server functions will be compiled into the client build.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
Most server dependencies (like sqlx and tokio) will not compile on client platforms like WASM. To avoid building server dependencies on the client, you should add platform specific dependencies under the `server` feature in the [Cargo.toml](../Cargo.toml) file. More details about managing server only dependencies can be found in the [Dioxus guide](https://dioxuslabs.com/learn/0.7/guides/fullstack/managing_dependencies#adding-server-only-dependencies).
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
//! This crate contains all shared fullstack server functions.
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
|
|
||||||
/// Echo the user input on the server.
|
|
||||||
#[post("/api/echo")]
|
|
||||||
pub async fn echo(input: String) -> Result<String, ServerFnError> {
|
|
||||||
Ok(input)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "web"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
dioxus = { workspace = true, features = ["router", "fullstack"] }
|
|
||||||
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
web = ["dioxus/web"]
|
|
||||||
server = ["dioxus/server"]
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Development
|
|
||||||
|
|
||||||
The web crate defines the entrypoint for the web app along with any assets, components and dependencies that are specific to web builds. The web crate starts out something like this:
|
|
||||||
|
|
||||||
```
|
|
||||||
web/
|
|
||||||
├─ assets/ # Assets used by the web app - Any platform specific assets should go in this folder
|
|
||||||
├─ src/
|
|
||||||
│ ├─ main.rs # The entrypoint for the web app.It also defines the routes for the web platform
|
|
||||||
│ ├─ views/ # The views each route will render in the web version of the app
|
|
||||||
│ │ ├─ mod.rs # Defines the module for the views route and re-exports the components for each route
|
|
||||||
│ │ ├─ blog.rs # The component that will render at the /blog/:id route
|
|
||||||
│ │ ├─ home.rs # The component that will render at the / route
|
|
||||||
├─ Cargo.toml # The web crate's Cargo.toml - This should include all web specific dependencies
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
Since you have fullstack enabled, the web crate will be built two times:
|
|
||||||
1. Once for the server build with the `server` feature enabled
|
|
||||||
2. Once for the client build with the `web` feature enabled
|
|
||||||
|
|
||||||
You should make all web specific dependencies optional and only enabled in the `web` feature. This will ensure that the server builds don't pull in web specific dependencies which cuts down on build times significantly.
|
|
||||||
|
|
||||||
### Serving Your Web App
|
|
||||||
|
|
||||||
You can start your web app with the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dx serve
|
|
||||||
```
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#blog {
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#blog a {
|
|
||||||
color: #ffffff;
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/* This file contains the global styles for the styled dioxus components. You only
|
|
||||||
* need to import this file once in your project root.
|
|
||||||
*/
|
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
|
|
||||||
|
|
||||||
body {
|
|
||||||
color: var(--secondary-color-4);
|
|
||||||
font-family: Inter, sans-serif;
|
|
||||||
font-optical-sizing: auto;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="dark"] {
|
|
||||||
--dark: initial;
|
|
||||||
--light: ;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="light"] {
|
|
||||||
--dark: ;
|
|
||||||
--light: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--dark: initial;
|
|
||||||
--light: ;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
--dark: ;
|
|
||||||
--light: initial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* Primary colors */
|
|
||||||
--primary-color: var(--dark, #000) var(--light, #fff);
|
|
||||||
--primary-color-1: var(--dark, #0e0e0e) var(--light, #fbfbfb);
|
|
||||||
--primary-color-2: var(--dark, #0a0a0a) var(--light, #fff);
|
|
||||||
--primary-color-3: var(--dark, #141313) var(--light, #f8f8f8);
|
|
||||||
--primary-color-4: var(--dark, #1a1a1a) var(--light, #f8f8f8);
|
|
||||||
--primary-color-5: var(--dark, #262626) var(--light, #f5f5f5);
|
|
||||||
--primary-color-6: var(--dark, #232323) var(--light, #e5e5e5);
|
|
||||||
--primary-color-7: var(--dark, #3e3e3e) var(--light, #b0b0b0);
|
|
||||||
|
|
||||||
/* Secondary colors */
|
|
||||||
--secondary-color: var(--dark, #fff) var(--light, #000);
|
|
||||||
--secondary-color-1: var(--dark, #fafafa) var(--light, #000);
|
|
||||||
--secondary-color-2: var(--dark, #e6e6e6) var(--light, #0d0d0d);
|
|
||||||
--secondary-color-3: var(--dark, #dcdcdc) var(--light, #2b2b2b);
|
|
||||||
--secondary-color-4: var(--dark, #d4d4d4) var(--light, #111);
|
|
||||||
--secondary-color-5: var(--dark, #a1a1a1) var(--light, #848484);
|
|
||||||
--secondary-color-6: var(--dark, #5d5d5d) var(--light, #d0d0d0);
|
|
||||||
|
|
||||||
/* Highlight colors */
|
|
||||||
--focused-border-color: var(--dark, #2b7fff) var(--light, #2b7fff);
|
|
||||||
--primary-success-color: var(--dark, #02271c) var(--light, #ecfdf5);
|
|
||||||
--secondary-success-color: var(--dark, #b6fae3) var(--light, #10b981);
|
|
||||||
--primary-warning-color: var(--dark, #342203) var(--light, #fffbeb);
|
|
||||||
--secondary-warning-color: var(--dark, #feeac7) var(--light, #f59e0b);
|
|
||||||
--primary-error-color: var(--dark, #a22e2e) var(--light, #dc2626);
|
|
||||||
--secondary-error-color: var(--dark, #9b1c1c) var(--light, #ef4444);
|
|
||||||
--contrast-error-color: var(--dark, var(--secondary-color-3)) var(--light, var(--primary-color));
|
|
||||||
--primary-info-color: var(--dark, var(--primary-color-5)) var(--light, var(--primary-color));
|
|
||||||
--secondary-info-color: var(--dark, var(--primary-color-7)) var(--light, var(--secondary-color-3));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modern browsers with `scrollbar-*` support */
|
|
||||||
@supports (scrollbar-width: auto) {
|
|
||||||
:not(:hover) {
|
|
||||||
scrollbar-color: rgb(0 0 0 / 0%) rgb(0 0 0 / 0%);
|
|
||||||
}
|
|
||||||
|
|
||||||
:hover {
|
|
||||||
scrollbar-color: var(--secondary-color-2) rgb(0 0 0 / 0%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Legacy browsers with `::-webkit-scrollbar-*` support */
|
|
||||||
@supports selector(::-webkit-scrollbar) {
|
|
||||||
:root::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
body {
|
|
||||||
background-color: #0f1116;
|
|
||||||
color: #ffffff;
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
margin: 20px;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// AUTOGENERATED Components module
|
|
||||||
pub mod toast;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_primitives::toast::{self, ToastProviderProps};
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn ToastProvider(props: ToastProviderProps) -> Element {
|
|
||||||
rsx! {
|
|
||||||
document::Link { rel: "stylesheet", href: asset!("./style.css") }
|
|
||||||
toast::ToastProvider {
|
|
||||||
default_duration: props.default_duration,
|
|
||||||
max_toasts: props.max_toasts,
|
|
||||||
render_toast: props.render_toast,
|
|
||||||
{props.children}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
mod component;
|
|
||||||
pub use component::*;
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
.toast-container {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 9999;
|
|
||||||
right: 20px;
|
|
||||||
bottom: 20px;
|
|
||||||
max-width: 350px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-item {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
z-index: calc(var(--toast-count) - var(--toast-index));
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 18rem;
|
|
||||||
height: 4rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 1px solid var(--light, var(--primary-color-6))
|
|
||||||
var(--dark, var(--primary-color-7));
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin-top: -4rem;
|
|
||||||
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
|
||||||
filter: var(--light, none)
|
|
||||||
var(
|
|
||||||
--dark,
|
|
||||||
brightness(calc(0.5 + 0.5 * (1 - ((var(--toast-index) + 1) / 4))))
|
|
||||||
);
|
|
||||||
opacity: calc(1 - var(--toast-hidden));
|
|
||||||
transform: scale(
|
|
||||||
calc(100% - var(--toast-index) * 5%),
|
|
||||||
calc(100% - var(--toast-index) * 2%)
|
|
||||||
);
|
|
||||||
transition: transform 0.2s ease, margin-top 0.2s ease, opacity 0.2s ease;
|
|
||||||
|
|
||||||
--toast-hidden: calc(min(max(0, var(--toast-index) - 2), 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-container:not(:hover, :focus-within)
|
|
||||||
.toast[data-toast-even]:not([data-top]) {
|
|
||||||
animation: slide-up-even 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-container:not(:hover, :focus-within)
|
|
||||||
.toast[data-toast-odd]:not([data-top]) {
|
|
||||||
animation: slide-up-odd 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slide-up-even {
|
|
||||||
from {
|
|
||||||
transform: translateY(0.5rem)
|
|
||||||
scale(
|
|
||||||
calc(100% - var(--toast-index) * 5%),
|
|
||||||
calc(100% - var(--toast-index) * 2%)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0)
|
|
||||||
scale(
|
|
||||||
calc(100% - var(--toast-index) * 5%),
|
|
||||||
calc(100% - var(--toast-index) * 2%)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slide-up-odd {
|
|
||||||
from {
|
|
||||||
transform: translateY(0.5rem)
|
|
||||||
scale(
|
|
||||||
calc(100% - var(--toast-index) * 5%),
|
|
||||||
calc(100% - var(--toast-index) * 2%)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0)
|
|
||||||
scale(
|
|
||||||
calc(100% - var(--toast-index) * 5%),
|
|
||||||
calc(100% - var(--toast-index) * 2%)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast[data-top] {
|
|
||||||
animation: slide-in 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-container:hover .toast[data-top],
|
|
||||||
.toast-container:focus-within .toast[data-top] {
|
|
||||||
animation: slide-in 0 ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slide-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(100%)
|
|
||||||
scale(
|
|
||||||
calc(110% - var(--toast-index) * 5%),
|
|
||||||
calc(110% - var(--toast-index) * 2%)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0)
|
|
||||||
scale(
|
|
||||||
calc(100% - var(--toast-index) * 5%),
|
|
||||||
calc(100% - var(--toast-index) * 2%)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-container:hover .toast,
|
|
||||||
.toast-container:focus-within .toast {
|
|
||||||
margin-top: var(--toast-padding);
|
|
||||||
filter: brightness(1);
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(calc(100%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast[data-type="success"] {
|
|
||||||
background-color: var(--primary-success-color);
|
|
||||||
color: var(--secondary-success-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast[data-type="error"] {
|
|
||||||
background-color: var(--primary-error-color);
|
|
||||||
color: var(--contrast-error-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast[data-type="warning"] {
|
|
||||||
background-color: var(--primary-warning-color);
|
|
||||||
color: var(--secondary-warning-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast[data-type="info"] {
|
|
||||||
background-color: var(--primary-info-color);
|
|
||||||
color: var(--secondary-info-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-content {
|
|
||||||
flex: 1;
|
|
||||||
margin-right: 8px;
|
|
||||||
transition: filter 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-title {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: var(--secondary-color-4);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-description {
|
|
||||||
color: var(--secondary-color-3);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-close {
|
|
||||||
align-self: flex-start;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
margin: 0;
|
|
||||||
background: none;
|
|
||||||
color: var(--secondary-color-3);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-close:hover {
|
|
||||||
color: var(--secondary-color-1);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
use dioxus::prelude::*;
|
|
||||||
|
|
||||||
use views::{Blog, Home};
|
|
||||||
|
|
||||||
mod views;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Routable, PartialEq)]
|
|
||||||
#[rustfmt::skip]
|
|
||||||
enum Route {
|
|
||||||
#[layout(WebNavbar)]
|
|
||||||
#[route("/")]
|
|
||||||
Home {},
|
|
||||||
#[route("/blog/:id")]
|
|
||||||
Blog { id: i32 },
|
|
||||||
}
|
|
||||||
|
|
||||||
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
|
||||||
const MAIN_CSS: Asset = asset!("/assets/main.css");
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
dioxus::launch(App);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn App() -> Element {
|
|
||||||
// Build cool things ✌️
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
// Global app resources
|
|
||||||
document::Link { rel: "icon", href: FAVICON }
|
|
||||||
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
|
||||||
|
|
||||||
Router::<Route> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A web-specific Router around the shared `Navbar` component
|
|
||||||
/// which allows us to use the web-specific `Route` enum.
|
|
||||||
#[component]
|
|
||||||
fn WebNavbar() -> Element {
|
|
||||||
rsx! {
|
|
||||||
|
|
||||||
Outlet::<Route> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
use crate::Route;
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
|
|
||||||
const BLOG_CSS: Asset = asset!("/assets/blog.css");
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Blog(id: i32) -> Element {
|
|
||||||
rsx! {
|
|
||||||
document::Link { rel: "stylesheet", href: BLOG_CSS}
|
|
||||||
|
|
||||||
div {
|
|
||||||
id: "blog",
|
|
||||||
|
|
||||||
// Content
|
|
||||||
h1 { "This is blog #{id}!" }
|
|
||||||
p { "In blog #{id}, we show how the Dioxus router works and how URL parameters can be passed as props to our route components." }
|
|
||||||
|
|
||||||
// Navigation links
|
|
||||||
Link {
|
|
||||||
to: Route::Blog { id: id - 1 },
|
|
||||||
"Previous"
|
|
||||||
}
|
|
||||||
span { " <---> " }
|
|
||||||
Link {
|
|
||||||
to: Route::Blog { id: id + 1 },
|
|
||||||
"Next"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
use dioxus::prelude::*;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Home() -> Element {
|
|
||||||
rsx! {}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mod home;
|
|
||||||
pub use home::Home;
|
|
||||||
|
|
||||||
mod blog;
|
|
||||||
pub use blog::Blog;
|
|
||||||
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