6 Commits

Author SHA1 Message Date
Sharang Parnerkar
80faa4fa86 fix(ui): fix hero section layout with flex column and proper sizing
Some checks failed
CI / Format (push) Failing after 6m17s
CI / Clippy (push) Successful in 2m19s
CI / Security Audit (push) Successful in 1m37s
CI / Tests (push) Has been skipped
CI / Build & Push Image (push) Has been skipped
CI / Changelog (push) Has been skipped
Add explicit flex-column layout to .hero-content so child elements
stack vertically instead of flowing inline. Set proper width and
min-height on hero graphic container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 22:01:28 +01:00
Sharang Parnerkar
e0a4d2d888 feat(ui): add public landing page with impressum and privacy pages
Some checks failed
CI / Format (push) Failing after 6m21s
CI / Security Audit (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Build & Push Image (push) Has been cancelled
CI / Changelog (push) Has been cancelled
CI / Clippy (push) Has started running
Introduce a marketing landing page at `/` with hero section, feature grid,
how-it-works steps, CTA banner, and footer. Move the authenticated dashboard
to `/dashboard`. Add static Impressum and Privacy Policy pages for EU legal
compliance. Update login redirect defaults accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:52:45 +01:00
f699976f4d ci(fix): Dockerfile entrypoint (#4)
Some checks failed
CI / Format (push) Successful in 6m52s
CI / Clippy (push) Successful in 2m31s
CI / Security Audit (push) Successful in 1m45s
CI / Tests (push) Successful in 3m2s
CI / Build & Push Image (push) Successful in 3m13s
CI / Changelog (push) Failing after 1m37s
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #4
2026-02-18 15:38:05 +00:00
0673f7867c feat(ui): added daisy UI for beautification (#3)
Some checks failed
CI / Clippy (push) Successful in 2m33s
CI / Format (push) Successful in 7m0s
CI / Security Audit (push) Successful in 1m46s
CI / Tests (push) Successful in 3m1s
CI / Build & Push Image (push) Successful in 3m6s
CI / Changelog (push) Failing after 1m44s
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #3
2026-02-18 14:43:11 +00:00
6d3e99220c ci: added basic workflows (#2)
Some checks failed
CI / Clippy (push) Successful in 2m35s
CI / Security Audit (push) Successful in 1m46s
CI / Tests (push) Successful in 3m5s
CI / Format (push) Successful in 6m53s
CI / Build & Push Image (push) Failing after 1m54s
CI / Changelog (push) Failing after 1m39s
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #2
2026-02-18 09:46:29 +00:00
1072770d11 feat: added oauth based login and registration (#1)
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #1
2026-02-18 09:21:46 +00:00
46 changed files with 10792 additions and 26 deletions

6
.dockerignore Normal file
View File

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

9
.env.example Normal file
View File

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

171
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,171 @@
name: CI
on:
push:
branches:
- "**"
pull_request:
branches:
- main
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
# Cancel in-progress runs for the same branch/PR
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ---------------------------------------------------------------------------
# Stage 1: Code quality checks (run in parallel)
# ---------------------------------------------------------------------------
fmt:
name: Format
runs-on: docker
container:
image: rust:1.89-bookworm
steps:
- name: Checkout
run: |
git init
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- run: rustup component add rustfmt
- run: cargo fmt --check
- name: Install dx CLI
run: cargo install dioxus-cli@0.7.3 --locked
- name: RSX format check
run: dx fmt --check
clippy:
name: Clippy
runs-on: docker
container:
image: rust:1.89-bookworm
steps:
- name: Checkout
run: |
git init
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- run: rustup component add clippy
# Lint both feature sets independently
- name: Clippy (server)
run: cargo clippy --features server --no-default-features -- -D warnings
- name: Clippy (web)
run: cargo clippy --features web --no-default-features -- -D warnings
audit:
name: Security Audit
runs-on: docker
container:
image: rust:1.89-bookworm
steps:
- name: Checkout
run: |
git init
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- run: cargo install cargo-audit
- run: cargo audit
# ---------------------------------------------------------------------------
# Stage 2: Tests (only after all quality checks pass)
# ---------------------------------------------------------------------------
test:
name: Tests
runs-on: docker
needs: [fmt, clippy, audit]
container:
image: rust:1.89-bookworm
steps:
- name: Checkout
run: |
git init
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- name: Run tests (server)
run: cargo test --features server --no-default-features
- name: Run tests (web)
run: cargo test --features web --no-default-features
# ---------------------------------------------------------------------------
# Stage 3: Build Docker image and push to registry
# Only on main and release/* branches
# ---------------------------------------------------------------------------
build-and-push:
name: Build & Push Image
runs-on: docker
needs: [test]
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))
steps:
- name: Checkout
run: |
git init
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- name: Determine image tag
id: tag
run: |
BRANCH="${GITHUB_REF#refs/heads/}"
# Replace / with - for valid Docker tags (e.g. release/1.0 -> release-1.0)
BRANCH_SAFE=$(echo "$BRANCH" | tr '/' '-')
SHA=$(echo "$GITHUB_SHA" | head -c 8)
echo "tag=${BRANCH_SAFE}-${SHA}" >> "$GITHUB_OUTPUT"
- name: Log in to container registry
run: >-
echo "${{ secrets.REGISTRY_PASSWORD }}"
| docker login https://registry.meghsakha.com
-u "${{ secrets.REGISTRY_USERNAME }}"
--password-stdin
- name: Build Docker image
run: >-
docker build
-t registry.meghsakha.com/certifai/dashboard:${{ steps.tag.outputs.tag }}
-t registry.meghsakha.com/certifai/dashboard:latest
.
- name: Push Docker image
run: |
docker push registry.meghsakha.com/certifai/dashboard:${{ steps.tag.outputs.tag }}
docker push registry.meghsakha.com/certifai/dashboard:latest
# ---------------------------------------------------------------------------
# Stage 3b: Generate changelog from conventional commits
# Only on main and release/* branches
# ---------------------------------------------------------------------------
changelog:
name: Changelog
runs-on: docker
needs: [test]
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))
container:
image: rust:1.89-bookworm
steps:
- name: Checkout (full history)
run: |
git clone "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" .
git checkout "${GITHUB_SHA}"
- name: Install git-cliff
run: cargo install git-cliff --locked
- name: Generate changelog
run: git cliff --output CHANGELOG.md
- name: Upload changelog artifact
uses: actions/upload-artifact@v4
with:
name: changelog
path: CHANGELOG.md

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# 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 runtime data (but keep realm-export.json)
keycloak/*
!keycloak/realm-export.json
# Node modules
node_modules/

265
AGENTS.md Normal file
View File

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

View File

@@ -241,19 +241,12 @@ The SaaS application dashboard is the landing page for the company admin to view
All features are detailed and described under the features folder in clear markdown instructions which are valid for both human and AI code developers.
## Clean architecture
For the backend development, clean architecture is preferred. SOLID principles MUST be strictly followed. Clearly defined types, traits and their implementations MUST be used when generating new code. Individual files MUST be created if a file is exceeding more than 160 lines of code excluding any tests. The folder structure for clean architecure SHOULD BE as:
- service1/
- Infrastructure/
- Domain/
- Application/
- Presentation/
- service2/
- Infrastructure/
- Domain/
- Application/
- Presentation/
With each major service split in separate folders.
## Code structure
The following folder structure is maintained for separation of concerns:
- src/components/*.rs : All components that are required to be rendered are placed here. These are frontend only, reusable components that are specific for the application.
- src/infrastructure/*.rs : All backend related functions from the dioxus fullstack are placed here. This entire module is behind the feature "server".
- src/models/*.rs : All data models for use by the frontend pages and components.
- src/pages/*.rs : All view pages for the website, which utilize components, models to render the entire page. The pages are more towards the user as they group user-centered functions together in one view.
## Git Workflow

5494
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

98
Cargo.toml Normal file
View File

@@ -0,0 +1,98 @@
[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",
] }
sha2 = { version = "0.10.9", default-features = false, optional = true }
base64 = { version = "0.22.1", default-features = false, optional = true }
[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",
"dep:sha2",
"dep:base64",
]
[[bin]]
name = "dashboard"
path = "bin/main.rs"

39
Dioxus.toml Normal file
View File

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

57
Dockerfile Normal file
View File

@@ -0,0 +1,57 @@
# Stage 1: Generate dependency recipe for caching
FROM rust:1.89-bookworm AS chef
RUN cargo install cargo-chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# Stage 2: Build dependencies + application
FROM chef AS builder
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev curl unzip \
&& rm -rf /var/lib/apt/lists/*
# Install bun (for Tailwind CSS build step)
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:$PATH"
# Install dx CLI from source (binstall binaries require GLIBC >= 2.38)
RUN cargo install dioxus-cli@0.7.3 --locked
# Cook dependencies from recipe (cached layer)
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
# Copy source and build
COPY . .
# Install frontend dependencies (DaisyUI, Tailwind) for the build.rs CSS step
RUN bun install --frozen-lockfile
# Bundle the fullstack application
RUN dx bundle --release --fullstack
# Stage 3: Minimal runtime image
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash app
USER app
WORKDIR /home/app
# Copy the bundled output from builder
COPY --from=builder --chown=app:app /app/target/dx/dashboard/release/web/ ./
EXPOSE 8000
ENV IP=0.0.0.0
ENV PORT=8000
ENTRYPOINT ["./dashboard"]

View File

@@ -23,19 +23,13 @@ The SaaS application dashboard is the landing page for the company admin to view
All features are detailed and described under the features folder in clear markdown instructions which are valid for both human and AI code developers.
## Clean architecture
For the backend development, clean architecture is preferred. SOLID principles MUST be strictly followed. Clearly defined types, traits and their implementations MUST be used when generating new code. Individual files MUST be created if a file is exceeding more than 160 lines of code excluding any tests. The folder structure for clean architecure SHOULD BE as:
- service1/
- Infrastructure/
- Domain/
- Application/
- Presentation/
- service2/
- Infrastructure/
- Domain/
- Application/
- Presentation/
With each major service split in separate folders.
## Code structure
The following folder structure is maintained for separation of concerns:
- src/components/*.rs : All components that are required to be rendered are placed here. These are frontend only, reusable components that are specific for the application.
- src/infrastructure/*.rs : All backend related functions from the dioxus fullstack are placed here. This entire module is behind the feature "server".
- src/models/*.rs : All data models for use by the frontend pages and components.
- src/pages/*.rs : All view pages for the website, which utilize components, models to render the entire page. The pages are more towards the user as they group user-centered functions together in one view.
## Git Workflow

BIN
assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

20
assets/header.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

25
assets/logo.svg Normal file
View File

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<!-- Shield body -->
<path d="M32 4L8 16v16c0 14.4 10.24 27.2 24 32 13.76-4.8 24-17.6 24-32V16L32 4z"
fill="#4B3FE0" fill-opacity="0.12" stroke="#4B3FE0" stroke-width="2"
stroke-linejoin="round"/>
<!-- Inner shield highlight -->
<path d="M32 10L14 19v11c0 11.6 7.68 22 18 26 10.32-4 18-14.4 18-26V19L32 10z"
fill="none" stroke="#4B3FE0" stroke-width="1" stroke-opacity="0.3"
stroke-linejoin="round"/>
<!-- Neural network nodes -->
<circle cx="32" cy="24" r="3.5" fill="#38B2AC"/>
<circle cx="22" cy="36" r="3" fill="#38B2AC"/>
<circle cx="42" cy="36" r="3" fill="#38B2AC"/>
<circle cx="27" cy="48" r="2.5" fill="#38B2AC" fill-opacity="0.7"/>
<circle cx="37" cy="48" r="2.5" fill="#38B2AC" fill-opacity="0.7"/>
<!-- Neural network edges -->
<line x1="32" y1="24" x2="22" y2="36" stroke="#38B2AC" stroke-width="1.2" stroke-opacity="0.6"/>
<line x1="32" y1="24" x2="42" y2="36" stroke="#38B2AC" stroke-width="1.2" stroke-opacity="0.6"/>
<line x1="22" y1="36" x2="27" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
<line x1="22" y1="36" x2="37" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
<line x1="42" y1="36" x2="27" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
<line x1="42" y1="36" x2="37" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
<!-- Cross edge for connectivity -->
<line x1="22" y1="36" x2="42" y2="36" stroke="#38B2AC" stroke-width="0.8" stroke-opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

763
assets/main.css Normal file
View File

@@ -0,0 +1,763 @@
/* ===== Fonts ===== */
body {
font-family: 'Inter', sans-serif;
background-color: #0f1116;
color: #e2e8f0;
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Space Grotesk', sans-serif;
}
/* ===== App Shell ===== */
.app-shell {
display: flex;
min-height: 100vh;
}
/* ===== Sidebar ===== */
.sidebar {
width: 260px;
min-width: 260px;
background-color: #0a0c10;
border-right: 1px solid #1e222d;
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
}
/* -- Sidebar Header -- */
.sidebar-header {
display: flex;
align-items: center;
gap: 12px;
padding: 24px 20px 20px;
border-bottom: 1px solid #1e222d;
}
.avatar-circle {
width: 38px;
height: 38px;
min-width: 38px;
border-radius: 50%;
background: linear-gradient(135deg, #91a4d2, #6d85c6);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-initials {
font-family: 'Space Grotesk', sans-serif;
font-size: 14px;
font-weight: 600;
color: #0a0c10;
}
.sidebar-email {
font-size: 13px;
color: #8892a8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* -- Sidebar Navigation -- */
.sidebar-nav {
flex: 1;
display: flex;
flex-direction: column;
padding: 12px 10px;
gap: 2px;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: 8px;
color: #8892a8;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: background-color 0.15s ease, color 0.15s ease;
}
.sidebar-link:hover {
background-color: #1e222d;
color: #e2e8f0;
}
.sidebar-link.active {
background-color: rgba(145, 164, 210, 0.12);
color: #91a4d2;
}
/* -- Sidebar Logout -- */
.sidebar-logout {
padding: 4px 10px;
border-top: 1px solid #1e222d;
}
.logout-btn {
color: #8892a8;
}
.logout-btn:hover {
color: #f87171;
background-color: rgba(248, 113, 113, 0.08);
}
/* -- Sidebar Footer -- */
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid #1e222d;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.sidebar-social {
display: flex;
gap: 16px;
}
.social-link {
color: #5a6478;
transition: color 0.15s ease;
text-decoration: none;
}
.social-link:hover {
color: #91a4d2;
}
.sidebar-version {
font-size: 11px;
color: #3d4556;
font-family: 'Inter', monospace;
}
/* ===== Main Content ===== */
.main-content {
flex: 1;
overflow-y: auto;
padding: 40px 48px;
min-height: 100vh;
}
/* ===== Overview Page ===== */
.overview-page {
max-width: 960px;
}
.overview-heading {
font-size: 28px;
font-weight: 700;
color: #f1f5f9;
margin-bottom: 32px;
}
/* ===== Dashboard Grid ===== */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
/* ===== Dashboard Card ===== */
.dashboard-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 24px;
background-color: #1e222d;
border: 1px solid #2a2f3d;
border-radius: 12px;
text-decoration: none;
color: #e2e8f0;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.dashboard-card:hover {
border-color: #91a4d2;
box-shadow: 0 0 20px rgba(145, 164, 210, 0.10);
}
.card-icon {
color: #91a4d2;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: #f1f5f9;
margin: 0;
}
.card-description {
font-size: 14px;
color: #8892a8;
margin: 0;
}
/* ===== Landing Page ===== */
.landing {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* -- Landing Nav -- */
.landing-nav {
position: sticky;
top: 0;
z-index: 100;
background-color: rgba(15, 17, 22, 0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid #1e222d;
}
.landing-nav-inner {
max-width: 1200px;
margin: 0 auto;
padding: 16px 32px;
display: flex;
align-items: center;
gap: 32px;
}
.landing-logo {
display: flex;
align-items: center;
gap: 10px;
font-family: 'Space Grotesk', sans-serif;
font-size: 20px;
font-weight: 700;
color: #f1f5f9;
text-decoration: none;
}
.landing-logo-icon {
color: #91a4d2;
display: flex;
align-items: center;
}
.landing-nav-links {
display: flex;
gap: 28px;
flex: 1;
}
.landing-nav-links a {
color: #8892a8;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.15s ease;
}
.landing-nav-links a:hover {
color: #e2e8f0;
}
.landing-nav-actions {
display: flex;
gap: 12px;
align-items: center;
}
/* -- Hero Section -- */
.hero-section {
max-width: 1200px;
margin: 0 auto;
padding: 80px 32px 60px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 64px;
align-items: center;
width: 100%;
}
.hero-content {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.hero-badge {
font-size: 13px;
font-weight: 500;
color: #91a4d2;
border-color: rgba(145, 164, 210, 0.3);
margin-bottom: 24px;
}
.hero-title {
font-size: 52px;
font-weight: 700;
line-height: 1.1;
color: #f1f5f9;
margin: 0 0 24px;
width: 100%;
}
.hero-title-accent {
background: linear-gradient(135deg, #91a4d2, #6d85c6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
font-size: 18px;
line-height: 1.7;
color: #8892a8;
margin: 0 0 36px;
max-width: 520px;
width: 100%;
}
.hero-actions {
display: flex;
gap: 16px;
align-items: center;
}
.hero-graphic {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 350px;
}
/* -- Social Proof -- */
.social-proof {
border-top: 1px solid #1e222d;
border-bottom: 1px solid #1e222d;
padding: 40px 32px;
text-align: center;
}
.social-proof-text {
font-size: 16px;
color: #8892a8;
margin: 0 0 28px;
}
.social-proof-highlight {
color: #91a4d2;
font-weight: 600;
}
.social-proof-stats {
display: flex;
justify-content: center;
gap: 40px;
align-items: center;
flex-wrap: wrap;
}
.proof-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.proof-stat-value {
font-family: 'Space Grotesk', sans-serif;
font-size: 24px;
font-weight: 700;
color: #f1f5f9;
}
.proof-stat-label {
font-size: 13px;
color: #5a6478;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.proof-divider {
width: 1px;
height: 40px;
background-color: #1e222d;
}
/* -- Section Titles -- */
.section-title {
font-size: 36px;
font-weight: 700;
color: #f1f5f9;
text-align: center;
margin: 0 0 12px;
}
.section-subtitle {
font-size: 18px;
color: #8892a8;
text-align: center;
margin: 0 0 48px;
}
/* -- Features Section -- */
.features-section {
max-width: 1200px;
margin: 0 auto;
padding: 80px 32px;
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.feature-card {
background-color: #1a1d26;
border: 1px solid #2a2f3d;
border-radius: 12px;
padding: 32px 28px;
transition: border-color 0.2s ease, transform 0.2s ease;
}
.feature-card:hover {
border-color: #91a4d2;
transform: translateY(-2px);
}
.feature-card-icon {
color: #91a4d2;
margin-bottom: 16px;
}
.feature-card-title {
font-size: 18px;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 8px;
}
.feature-card-desc {
font-size: 14px;
line-height: 1.6;
color: #8892a8;
margin: 0;
}
/* -- How It Works -- */
.how-it-works-section {
max-width: 1200px;
margin: 0 auto;
padding: 80px 32px;
}
.steps-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 32px;
}
.step-card {
text-align: center;
padding: 40px 28px;
}
.step-number {
font-family: 'Space Grotesk', sans-serif;
font-size: 48px;
font-weight: 700;
background: linear-gradient(135deg, #91a4d2, #6d85c6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: block;
margin-bottom: 16px;
}
.step-title {
font-size: 22px;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 12px;
}
.step-desc {
font-size: 15px;
line-height: 1.6;
color: #8892a8;
margin: 0;
}
/* -- CTA Banner -- */
.cta-banner {
max-width: 900px;
margin: 0 auto 80px;
padding: 64px 48px;
text-align: center;
background: linear-gradient(
135deg,
rgba(145, 164, 210, 0.08),
rgba(109, 133, 198, 0.04)
);
border: 1px solid rgba(145, 164, 210, 0.15);
border-radius: 20px;
}
.cta-title {
font-size: 32px;
font-weight: 700;
color: #f1f5f9;
margin: 0 0 12px;
}
.cta-subtitle {
font-size: 18px;
color: #8892a8;
margin: 0 0 32px;
}
.cta-actions {
display: flex;
gap: 16px;
justify-content: center;
}
/* -- Landing Footer -- */
.landing-footer {
border-top: 1px solid #1e222d;
padding: 60px 32px 0;
margin-top: auto;
}
.landing-footer-inner {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 48px;
}
.footer-brand {
display: flex;
flex-direction: column;
gap: 12px;
}
.footer-tagline {
font-size: 14px;
color: #5a6478;
margin: 0;
max-width: 280px;
}
.footer-links-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.footer-links-heading {
font-size: 13px;
font-weight: 600;
color: #8892a8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 4px;
}
.footer-links-group a {
font-size: 14px;
color: #5a6478;
text-decoration: none;
transition: color 0.15s ease;
}
.footer-links-group a:hover {
color: #91a4d2;
}
.footer-bottom {
max-width: 1200px;
margin: 48px auto 0;
padding: 20px 0;
border-top: 1px solid #1e222d;
text-align: center;
}
.footer-bottom p {
font-size: 13px;
color: #3d4556;
margin: 0;
}
/* ===== Legal Pages (Impressum, Privacy) ===== */
.legal-page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.legal-nav {
padding: 20px 32px;
border-bottom: 1px solid #1e222d;
}
.legal-content {
max-width: 760px;
margin: 0 auto;
padding: 48px 32px 80px;
flex: 1;
}
.legal-content h1 {
font-size: 36px;
font-weight: 700;
color: #f1f5f9;
margin: 0 0 32px;
}
.legal-content h2 {
font-size: 22px;
font-weight: 600;
color: #f1f5f9;
margin: 40px 0 12px;
}
.legal-content p {
font-size: 15px;
line-height: 1.7;
color: #8892a8;
margin: 0 0 16px;
}
.legal-content ul {
padding-left: 24px;
margin: 0 0 16px;
}
.legal-content li {
font-size: 15px;
line-height: 1.7;
color: #8892a8;
margin-bottom: 8px;
}
.legal-updated {
font-size: 14px;
color: #5a6478;
font-style: italic;
}
.legal-footer {
padding: 20px 32px;
border-top: 1px solid #1e222d;
display: flex;
gap: 24px;
justify-content: center;
}
.legal-footer a {
font-size: 14px;
color: #5a6478;
text-decoration: none;
transition: color 0.15s ease;
}
.legal-footer a:hover {
color: #91a4d2;
}
/* ===== Responsive: Landing Page ===== */
@media (max-width: 1024px) {
.hero-section {
grid-template-columns: 1fr;
padding: 60px 24px 40px;
gap: 40px;
}
.hero-graphic {
max-width: 320px;
margin: 0 auto;
order: -1;
min-height: auto;
}
.features-grid {
grid-template-columns: repeat(2, 1fr);
}
.landing-footer-inner {
grid-template-columns: 1fr 1fr;
gap: 32px;
}
}
@media (max-width: 768px) {
.landing-nav-links {
display: none;
}
.hero-title {
font-size: 36px;
}
.hero-subtitle {
font-size: 16px;
}
.hero-actions {
flex-direction: column;
align-items: stretch;
}
.features-grid,
.steps-grid {
grid-template-columns: 1fr;
}
.social-proof-stats {
gap: 24px;
}
.proof-divider {
display: none;
}
.cta-banner {
margin: 0 16px 60px;
padding: 40px 24px;
}
.cta-title {
font-size: 24px;
}
.cta-actions {
flex-direction: column;
align-items: stretch;
}
.landing-footer-inner {
grid-template-columns: 1fr;
gap: 24px;
}
.section-title {
font-size: 28px;
}
}

1738
assets/tailwind.css Normal file

File diff suppressed because it is too large Load Diff

22
bin/main.rs Normal file
View File

@@ -0,0 +1,22 @@
#![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")]
{
dashboard::infrastructure::server_start(dashboard::App)
.map_err(|e| {
tracing::error!("Unable to start server: {e}");
})
.expect("Server start failed")
}
}

26
build.rs Normal file
View File

@@ -0,0 +1,26 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
use std::process::Command;
println!("cargo:rerun-if-changed=./styles/input.css");
// Tailwind build is optional - skip gracefully in CI or environments without bun
match Command::new("bunx")
.args([
"@tailwindcss/cli",
"-i",
"./styles/input.css",
"-o",
"./assets/tailwind.css",
])
.status()
{
Ok(status) if !status.success() => {
println!("cargo:warning=tailwind build exited with {status}, skipping CSS generation");
}
Err(e) => {
println!("cargo:warning=bunx not found ({e}), skipping tailwind CSS generation");
}
Ok(_) => {}
}
Ok(())
}

33
bun.lock Normal file
View File

@@ -0,0 +1,33 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "certifai",
"dependencies": {
"daisyui": "^5.5.18",
"tailwindcss": "^4.1.18",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}

52
cliff.toml Normal file
View File

@@ -0,0 +1,52 @@
[changelog]
header = """
# Changelog
All notable changes to this project will be documented in this file.
"""
body = """
{%- macro remote_url() -%}
https://gitea.meghsakha.com/{{ remote.github.owner }}/{{ remote.github.repo }}
{%- endmacro -%}
{% if version -%}
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else -%}
## [Unreleased]
{% endif -%}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }}\
{% if commit.breaking %} [**breaking**]{% endif %}\
{% endfor %}
{% endfor %}\n
"""
footer = """
---
*Generated by [git-cliff](https://git-cliff.org)*
"""
trim = true
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^doc", group = "Documentation" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactoring" },
{ message = "^style", group = "Styling" },
{ message = "^test", group = "Testing" },
{ message = "^ci", group = "CI/CD" },
{ message = "^chore", group = "Miscellaneous" },
{ message = "^build", group = "Build" },
]
protect_breaking_commits = false
filter_commits = false
tag_pattern = "v[0-9].*"
sort_commits = "oldest"

8
clippy.toml Normal file
View File

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

31
docker-compose.yml Normal file
View File

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

9
features/CAI-1.md Normal file
View File

@@ -0,0 +1,9 @@
# CAI-1
This feature creates a new login/registration page for the GenAI admin dashboard. The user management is provided by Keycloak, which also serves the login/registration flow. The dioxus app should detect if a user is already logged-in or not, and if not, redirect the user to the keycloak landing page and after successful login, capture the user's access token in a state and save a session state.
Steps to follow:
- Create a docker-compose file for hosting a local keycloak and create a realm for testing and a client for Oauth.
- Setup the environment variables using .env. Fill the environment with keycloak URL, realm, client ID and secret.
- Create a user state in Dioxus which manages the session and the access token. Add other user identifying information like email address to the state.
- Modify dioxus to check the state and load the correct URL based on the state.

3
features/CAI-2.md Normal file
View File

@@ -0,0 +1,3 @@
# CERTifAI 2
This feature defines the types for database as well as the API between the dashboard backend and frontend.

246
keycloak/realm-export.json Normal file
View File

@@ -0,0 +1,246 @@
{
"id": "certifai",
"realm": "certifai",
"displayName": "CERTifAI",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": true,
"registrationEmailAsUsername": true,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"permanentLockout": false,
"maxFailureWaitSeconds": 900,
"minimumQuickLoginWaitSeconds": 60,
"waitIncrementSeconds": 60,
"quickLoginCheckMilliSeconds": 1000,
"maxDeltaTimeSeconds": 43200,
"failureFactor": 5,
"defaultSignatureAlgorithm": "RS256",
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"offlineSessionIdleTimeout": 2592000,
"accessCodeLifespan": 60,
"accessCodeLifespanUserAction": 300,
"accessCodeLifespanLogin": 1800,
"roles": {
"realm": [
{
"name": "admin",
"description": "CERTifAI administrator with full access",
"composite": false,
"clientRole": false
},
{
"name": "user",
"description": "Standard CERTifAI user",
"composite": false,
"clientRole": false
}
]
},
"defaultRoles": [
"user"
],
"clients": [
{
"clientId": "certifai-dashboard",
"name": "CERTifAI Dashboard",
"description": "CERTifAI administration dashboard",
"enabled": true,
"publicClient": true,
"directAccessGrantsEnabled": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"serviceAccountsEnabled": false,
"protocol": "openid-connect",
"rootUrl": "http://localhost:8000",
"baseUrl": "http://localhost:8000",
"redirectUris": [
"http://localhost:8000/auth/callback"
],
"webOrigins": [
"http://localhost:8000"
],
"attributes": {
"post.logout.redirect.uris": "http://localhost:8000",
"pkce.code.challenge.method": "S256"
},
"defaultClientScopes": [
"openid",
"profile",
"email"
],
"optionalClientScopes": [
"offline_access"
]
}
],
"clientScopes": [
{
"name": "openid",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "sub",
"protocol": "openid-connect",
"protocolMapper": "oidc-sub-mapper",
"consentRequired": false,
"config": {
"id.token.claim": "true",
"access.token.claim": "true"
}
}
]
},
{
"name": "profile",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "User profile information"
},
"protocolMappers": [
{
"name": "full name",
"protocol": "openid-connect",
"protocolMapper": "oidc-full-name-mapper",
"consentRequired": false,
"config": {
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "given name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "firstName",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
"claim.name": "given_name",
"jsonType.label": "String"
}
},
{
"name": "family name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "lastName",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
"claim.name": "family_name",
"jsonType.label": "String"
}
},
{
"name": "picture",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "picture",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
"claim.name": "picture",
"jsonType.label": "String"
}
}
]
},
{
"name": "email",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Email address"
},
"protocolMappers": [
{
"name": "email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
"claim.name": "email",
"jsonType.label": "String"
}
},
{
"name": "email verified",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "emailVerified",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
"claim.name": "email_verified",
"jsonType.label": "boolean"
}
}
]
}
],
"users": [
{
"username": "admin@certifai.local",
"email": "admin@certifai.local",
"firstName": "Admin",
"lastName": "User",
"enabled": true,
"emailVerified": true,
"credentials": [
{
"type": "password",
"value": "admin",
"temporary": false
}
],
"realmRoles": [
"admin",
"user"
]
},
{
"username": "user@certifai.local",
"email": "user@certifai.local",
"firstName": "Test",
"lastName": "User",
"enabled": true,
"emailVerified": true,
"credentials": [
{
"type": "password",
"value": "user",
"temporary": false
}
],
"realmRoles": [
"user"
]
}
]
}

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "certifai",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"daisyui": "^5.5.18",
"tailwindcss": "^4.1.18"
}
}

52
src/app.rs Normal file
View File

@@ -0,0 +1,52 @@
use crate::{components::*, pages::*};
use dioxus::prelude::*;
/// Application routes.
///
/// Public pages (`LandingPage`, `ImpressumPage`, `PrivacyPage`) live
/// outside the `AppShell` layout. Authenticated pages like `OverviewPage`
/// are wrapped in `AppShell` which renders the sidebar.
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
pub enum Route {
#[route("/")]
LandingPage {},
#[route("/impressum")]
ImpressumPage {},
#[route("/privacy")]
PrivacyPage {},
#[layout(AppShell)]
#[route("/dashboard")]
OverviewPage {},
#[end_layout]
#[route("/login?:redirect_url")]
Login { redirect_url: String },
}
const FAVICON: Asset = asset!("/assets/favicon.ico");
const MAIN_CSS: Asset = asset!("/assets/main.css");
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
/// Google Fonts URL for Inter (body) and Space Grotesk (headings).
const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
family=Inter:wght@400;500;600&\
family=Space+Grotesk:wght@500;600;700&\
display=swap";
/// Root application component. Loads global assets and mounts the router.
#[component]
pub fn App() -> Element {
rsx! {
document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" }
document::Link {
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossorigin: "anonymous",
}
document::Link { rel: "stylesheet", href: GOOGLE_FONTS }
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
document::Link { rel: "stylesheet", href: MAIN_CSS }
div { "data-theme": "certifai-dark", Router::<Route> {} }
}
}

View File

@@ -0,0 +1,21 @@
use dioxus::prelude::*;
use crate::components::sidebar::Sidebar;
use crate::Route;
/// Application shell layout that wraps all authenticated pages.
///
/// Renders a fixed sidebar on the left and the active child route
/// in the scrollable main content area via `Outlet`.
#[component]
pub fn AppShell() -> Element {
rsx! {
div { class: "app-shell",
Sidebar {
email: "user@example.com".to_string(),
avatar_url: String::new(),
}
main { class: "main-content", Outlet::<Route> {} }
}
}
}

20
src/components/card.rs Normal file
View File

@@ -0,0 +1,20 @@
use dioxus::prelude::*;
/// Reusable dashboard card with icon, title, description and click-through link.
///
/// # Arguments
///
/// * `title` - Card heading text.
/// * `description` - Short description shown beneath the title.
/// * `href` - URL the card links to when clicked.
/// * `icon` - Element rendered as the card icon (typically a `dioxus_free_icons::Icon`).
#[component]
pub fn DashboardCard(title: String, description: String, href: String, icon: Element) -> Element {
rsx! {
a { class: "dashboard-card", href: "{href}",
div { class: "card-icon", {icon} }
h3 { class: "card-title", "{title}" }
p { class: "card-description", "{description}" }
}
}
}

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

@@ -0,0 +1,30 @@
use crate::Route;
use dioxus::prelude::*;
/// Login redirect component.
///
/// Redirects the user to the external OAuth authentication endpoint.
/// If no `redirect_url` is provided, defaults to `/dashboard`.
///
/// # Arguments
///
/// * `redirect_url` - URL to redirect to after successful authentication
#[component]
pub fn Login(redirect_url: String) -> Element {
let navigator = use_navigator();
use_effect(move || {
// Default to /dashboard when redirect_url is empty.
let destination = if redirect_url.is_empty() {
"/dashboard".to_string()
} else {
redirect_url.clone()
};
let target = format!("/auth?redirect_url={destination}");
navigator.push(NavigationTarget::<Route>::External(target));
});
rsx!(
div { class: "text-center p-6", "Redirecting to secure login page…" }
)
}

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

@@ -0,0 +1,8 @@
mod app_shell;
mod card;
mod login;
pub mod sidebar;
pub use app_shell::*;
pub use card::*;
pub use login::*;

141
src/components/sidebar.rs Normal file
View File

@@ -0,0 +1,141 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsBoxArrowRight, BsFileEarmarkText, BsGear, BsGithub, BsGrid, BsHouseDoor, BsRobot,
};
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
use dioxus_free_icons::Icon;
use crate::Route;
/// Navigation entry for the sidebar.
struct NavItem {
label: &'static str,
route: Route,
/// Bootstrap icon element rendered beside the label.
icon: Element,
}
/// Fixed left sidebar containing header, navigation, logout, and footer.
///
/// # Arguments
///
/// * `email` - Email address displayed beneath the avatar placeholder.
/// * `avatar_url` - URL for the avatar image (unused placeholder for now).
#[component]
pub fn Sidebar(email: String, avatar_url: String) -> Element {
let nav_items: Vec<NavItem> = vec![
NavItem {
label: "Overview",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
},
NavItem {
label: "Documentation",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsFileEarmarkText, width: 18, height: 18 } },
},
NavItem {
label: "Agents",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsRobot, width: 18, height: 18 } },
},
NavItem {
label: "Models",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: FaCubes, width: 18, height: 18 } },
},
NavItem {
label: "Settings",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsGear, width: 18, height: 18 } },
},
];
// Determine current path to highlight the active nav link.
let current_route = use_route::<Route>();
rsx! {
aside { class: "sidebar",
// -- Header: avatar circle + email --
SidebarHeader { email: email.clone(), avatar_url }
// -- Navigation links --
nav { class: "sidebar-nav",
for item in nav_items {
{
// Simple active check: highlight Overview only when on `/`.
let is_active = item.route == current_route;
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
rsx! {
Link { to: item.route, class: cls,
{item.icon}
span { "{item.label}" }
}
}
}
}
}
// -- Logout button --
div { class: "sidebar-logout",
Link {
to: NavigationTarget::<Route>::External("/auth/logout".into()),
class: "sidebar-link logout-btn",
Icon { icon: BsBoxArrowRight, width: 18, height: 18 }
span { "Logout" }
}
}
// -- Footer: version + social links --
SidebarFooter {}
}
}
}
/// Avatar circle and email display at the top of the sidebar.
///
/// # Arguments
///
/// * `email` - User email to display.
/// * `avatar_url` - Placeholder for future avatar image URL.
#[component]
fn SidebarHeader(email: String, avatar_url: String) -> Element {
// Extract initials from email (first two chars before @).
let initials: String = email
.split('@')
.next()
.unwrap_or("U")
.chars()
.take(2)
.collect::<String>()
.to_uppercase();
rsx! {
div { class: "sidebar-header",
div { class: "avatar-circle",
span { class: "avatar-initials", "{initials}" }
}
p { class: "sidebar-email", "{email}" }
}
}
}
/// Footer section with version string and placeholder social links.
#[component]
fn SidebarFooter() -> Element {
let version = env!("CARGO_PKG_VERSION");
rsx! {
footer { class: "sidebar-footer",
div { class: "sidebar-social",
a { href: "#", class: "social-link", title: "GitHub",
Icon { icon: BsGithub, width: 16, height: 16 }
}
a { href: "#", class: "social-link", title: "Impressum",
Icon { icon: BsGrid, width: 16, height: 16 }
}
}
p { class: "sidebar-version", "v{version}" }
}
}
}

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

@@ -0,0 +1,350 @@
use std::{
collections::HashMap,
sync::{Arc, RwLock},
};
use axum::{
extract::Query,
response::{IntoResponse, Redirect},
Extension,
};
use rand::RngExt;
use tower_sessions::Session;
use url::Url;
use crate::infrastructure::{state::User, Error, UserStateInner};
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
/// Data stored alongside each pending OAuth state. Holds the optional
/// post-login redirect URL and the PKCE code verifier needed for the
/// token exchange.
#[derive(Debug, Clone)]
struct PendingOAuthEntry {
redirect_url: Option<String>,
code_verifier: String,
}
/// In-memory store for pending OAuth states. Keyed by the random state
/// string. This avoids dependence on the session cookie surviving the
/// Keycloak redirect round-trip (the `dx serve` proxy can drop
/// `Set-Cookie` headers on 307 responses).
#[derive(Debug, Clone, Default)]
pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
impl PendingOAuthStore {
/// Insert a pending state with an optional redirect URL and PKCE verifier.
fn insert(&self, state: String, entry: PendingOAuthEntry) {
// RwLock::write only panics if the lock is poisoned, which
// indicates a prior panic -- propagating is acceptable here.
#[allow(clippy::expect_used)]
self.0
.write()
.expect("pending oauth store lock poisoned")
.insert(state, entry);
}
/// Remove and return the entry if the state was pending.
/// Returns `None` if the state was never stored (CSRF failure).
fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
#[allow(clippy::expect_used)]
self.0
.write()
.expect("pending oauth store lock poisoned")
.remove(state)
}
}
/// Configuration loaded from environment variables for Keycloak OAuth.
struct OAuthConfig {
keycloak_url: String,
realm: String,
client_id: String,
redirect_uri: String,
app_url: String,
}
impl OAuthConfig {
/// Load OAuth configuration from environment variables.
///
/// # Errors
///
/// Returns `Error::StateError` if any required env var is missing.
fn from_env() -> Result<Self, Error> {
dotenvy::dotenv().ok();
Ok(Self {
keycloak_url: std::env::var("KEYCLOAK_URL")
.map_err(|_| Error::StateError("KEYCLOAK_URL not set".into()))?,
realm: std::env::var("KEYCLOAK_REALM")
.map_err(|_| Error::StateError("KEYCLOAK_REALM not set".into()))?,
client_id: std::env::var("KEYCLOAK_CLIENT_ID")
.map_err(|_| Error::StateError("KEYCLOAK_CLIENT_ID not set".into()))?,
redirect_uri: std::env::var("REDIRECT_URI")
.map_err(|_| Error::StateError("REDIRECT_URI not set".into()))?,
app_url: std::env::var("APP_URL")
.map_err(|_| Error::StateError("APP_URL not set".into()))?,
})
}
/// Build the Keycloak OpenID Connect authorization endpoint URL.
fn auth_endpoint(&self) -> String {
format!(
"{}/realms/{}/protocol/openid-connect/auth",
self.keycloak_url, self.realm
)
}
/// Build the Keycloak OpenID Connect token endpoint URL.
fn token_endpoint(&self) -> String {
format!(
"{}/realms/{}/protocol/openid-connect/token",
self.keycloak_url, self.realm
)
}
/// Build the Keycloak OpenID Connect userinfo endpoint URL.
fn userinfo_endpoint(&self) -> String {
format!(
"{}/realms/{}/protocol/openid-connect/userinfo",
self.keycloak_url, self.realm
)
}
/// Build the Keycloak OpenID Connect end-session (logout) endpoint URL.
fn logout_endpoint(&self) -> String {
format!(
"{}/realms/{}/protocol/openid-connect/logout",
self.keycloak_url, self.realm
)
}
}
/// Generate a cryptographically random state string for CSRF protection.
fn generate_state() -> String {
let bytes: [u8; 32] = rand::rng().random();
// Encode as hex to produce a URL-safe string without padding.
bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
use std::fmt::Write;
// write! on a String is infallible, safe to ignore the result.
let _ = write!(acc, "{b:02x}");
acc
})
}
/// Generate a PKCE code verifier (43-128 char URL-safe random string).
///
/// Uses 32 random bytes encoded as base64url (no padding) to produce
/// a 43-character verifier per RFC 7636.
fn generate_code_verifier() -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let bytes: [u8; 32] = rand::rng().random();
URL_SAFE_NO_PAD.encode(bytes)
}
/// Derive the S256 code challenge from a code verifier per RFC 7636.
///
/// `code_challenge = BASE64URL(SHA256(code_verifier))`
fn derive_code_challenge(verifier: &str) -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use sha2::{Digest, Sha256};
let digest = Sha256::digest(verifier.as_bytes());
URL_SAFE_NO_PAD.encode(digest)
}
/// Redirect the user to Keycloak's authorization page.
///
/// Generates a random CSRF state, stores it (along with the optional
/// redirect URL) in the server-side `PendingOAuthStore`, and redirects
/// the browser to Keycloak.
///
/// # Query Parameters
///
/// * `redirect_url` - Optional URL to redirect to after successful login.
///
/// # Errors
///
/// Returns `Error` if env vars are missing.
#[axum::debug_handler]
pub async fn auth_login(
Extension(pending): Extension<PendingOAuthStore>,
Query(params): Query<HashMap<String, String>>,
) -> Result<impl IntoResponse, Error> {
let config = OAuthConfig::from_env()?;
let state = generate_state();
let code_verifier = generate_code_verifier();
let code_challenge = derive_code_challenge(&code_verifier);
let redirect_url = params.get("redirect_url").cloned();
pending.insert(
state.clone(),
PendingOAuthEntry {
redirect_url,
code_verifier,
},
);
let mut url = Url::parse(&config.auth_endpoint())
.map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?;
url.query_pairs_mut()
.append_pair("client_id", &config.client_id)
.append_pair("redirect_uri", &config.redirect_uri)
.append_pair("response_type", "code")
.append_pair("scope", "openid profile email")
.append_pair("state", &state)
.append_pair("code_challenge", &code_challenge)
.append_pair("code_challenge_method", "S256");
Ok(Redirect::temporary(url.as_str()))
}
/// Token endpoint response from Keycloak.
#[derive(serde::Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
}
/// Userinfo endpoint response from Keycloak.
#[derive(serde::Deserialize)]
struct UserinfoResponse {
/// The subject identifier (unique user ID in Keycloak).
sub: String,
email: Option<String>,
/// Keycloak may include a picture/avatar URL via protocol mappers.
picture: Option<String>,
}
/// Handle the OAuth callback from Keycloak after the user authenticates.
///
/// Validates the CSRF state against the `PendingOAuthStore`, exchanges
/// the authorization code for tokens, fetches user info, stores the
/// logged-in user in the tower-sessions session, and redirects to the app.
///
/// # Query Parameters
///
/// * `code` - The authorization code from Keycloak.
/// * `state` - The CSRF state to verify against the pending store.
///
/// # Errors
///
/// Returns `Error` on CSRF mismatch, token exchange failure, or session issues.
#[axum::debug_handler]
pub async fn auth_callback(
session: Session,
Extension(pending): Extension<PendingOAuthStore>,
Query(params): Query<HashMap<String, String>>,
) -> Result<impl IntoResponse, Error> {
let config = OAuthConfig::from_env()?;
// --- CSRF validation via the in-memory pending store ---
let returned_state = params
.get("state")
.ok_or_else(|| Error::StateError("missing state parameter".into()))?;
let entry = pending
.take(returned_state)
.ok_or_else(|| Error::StateError("unknown or expired oauth state".into()))?;
// --- Exchange code for tokens (with PKCE code_verifier) ---
let code = params
.get("code")
.ok_or_else(|| Error::StateError("missing code parameter".into()))?;
let client = reqwest::Client::new();
let token_resp = client
.post(config.token_endpoint())
.form(&[
("grant_type", "authorization_code"),
("client_id", &config.client_id),
("redirect_uri", &config.redirect_uri),
("code", code),
("code_verifier", &entry.code_verifier),
])
.send()
.await
.map_err(|e| Error::StateError(format!("token request failed: {e}")))?;
if !token_resp.status().is_success() {
let body = token_resp.text().await.unwrap_or_default();
return Err(Error::StateError(format!("token exchange failed: {body}")));
}
let tokens: TokenResponse = token_resp
.json()
.await
.map_err(|e| Error::StateError(format!("token parse failed: {e}")))?;
// --- Fetch userinfo ---
let userinfo: UserinfoResponse = client
.get(config.userinfo_endpoint())
.bearer_auth(&tokens.access_token)
.send()
.await
.map_err(|e| Error::StateError(format!("userinfo request failed: {e}")))?
.json()
.await
.map_err(|e| Error::StateError(format!("userinfo parse failed: {e}")))?;
// --- Build user state and persist in session ---
let user_state = UserStateInner {
sub: userinfo.sub,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token.unwrap_or_default(),
user: User {
email: userinfo.email.unwrap_or_default(),
avatar_url: userinfo.picture.unwrap_or_default(),
},
};
set_login_session(session, user_state).await?;
let target = entry
.redirect_url
.filter(|u| !u.is_empty())
.unwrap_or_else(|| "/".into());
Ok(Redirect::temporary(&target))
}
/// Clear the user session and redirect to Keycloak's logout endpoint.
///
/// After Keycloak finishes its own logout flow it will redirect
/// back to the application root.
///
/// # Errors
///
/// Returns `Error` if env vars are missing or the session cannot be flushed.
#[axum::debug_handler]
pub async fn logout(session: Session) -> Result<impl IntoResponse, Error> {
let config = OAuthConfig::from_env()?;
// Flush all session data.
session
.flush()
.await
.map_err(|e| Error::StateError(format!("session flush failed: {e}")))?;
let mut url = Url::parse(&config.logout_endpoint())
.map_err(|e| Error::StateError(format!("invalid logout endpoint URL: {e}")))?;
url.query_pairs_mut()
.append_pair("client_id", &config.client_id)
.append_pair("post_logout_redirect_uri", &config.app_url);
Ok(Redirect::temporary(url.as_str()))
}
/// Persist user data into the session.
///
/// # Errors
///
/// Returns `Error` if the session store write fails.
pub async fn set_login_session(session: Session, data: UserStateInner) -> Result<(), Error> {
session
.insert(LOGGED_IN_USER_SESS_KEY, data)
.await
.map_err(|e| Error::StateError(format!("session insert failed: {e}")))
}

View File

@@ -0,0 +1,22 @@
use axum::response::IntoResponse;
use reqwest::StatusCode;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("{0}")]
StateError(String),
#[error("IoError: {0}")]
IoError(#[from] std::io::Error),
}
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
let msg = self.to_string();
tracing::error!("Converting Error to Response: {msg}");
match self {
Self::StateError(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Unknown error").into_response(),
}
}
}

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

@@ -0,0 +1,10 @@
#![cfg(feature = "server")]
mod auth;
mod error;
mod server;
mod state;
pub use auth::*;
pub use error::*;
pub use server::*;
pub use state::*;

View File

@@ -0,0 +1,56 @@
use crate::infrastructure::{
auth_callback, auth_login, logout, PendingOAuthStore, UserState, UserStateInner,
};
use dioxus::prelude::*;
use axum::routing::get;
use axum::Extension;
use time::Duration;
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer};
/// Start the Axum server with Dioxus fullstack, session management,
/// and Keycloak OAuth routes.
///
/// # Errors
///
/// Returns `Error` if the tokio runtime or TCP listener fails to start.
pub fn server_start(app: fn() -> Element) -> Result<(), super::Error> {
tokio::runtime::Runtime::new()?.block_on(async move {
let state: UserState = UserStateInner {
access_token: "abcd".into(),
sub: "abcd".into(),
refresh_token: "abcd".into(),
..Default::default()
}
.into();
let key = Key::generate();
let store = MemoryStore::default();
let session = SessionManagerLayer::new(store)
.with_secure(false)
// Lax is required so the browser sends the session cookie
// on the redirect back from Keycloak (cross-origin GET).
// Strict would silently drop the cookie on that navigation.
.with_same_site(tower_sessions::cookie::SameSite::Lax)
.with_expiry(tower_sessions::Expiry::OnInactivity(Duration::hours(24)))
.with_signed(key);
let addr = dioxus_cli_config::fullstack_address_or_localhost();
let listener = tokio::net::TcpListener::bind(addr).await?;
// Layers are applied AFTER serve_dioxus_application so they
// wrap both the custom Axum routes AND the Dioxus server
// function routes (e.g. check_auth needs Session access).
let router = axum::Router::new()
.route("/auth", get(auth_login))
.route("/auth/callback", get(auth_callback))
.route("/logout", get(logout))
.serve_dioxus_application(ServeConfig::new(), app)
.layer(Extension(PendingOAuthStore::default()))
.layer(Extension(state))
.layer(session);
info!("Serving at {addr}");
axum::serve(listener, router.into_make_service()).await?;
Ok(())
})
}

View File

@@ -0,0 +1,57 @@
use std::{ops::Deref, sync::Arc};
use axum::extract::FromRequestParts;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct UserState(Arc<UserStateInner>);
impl Deref for UserState {
type Target = UserStateInner;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<UserStateInner> for UserState {
fn from(value: UserStateInner) -> Self {
Self(Arc::new(value))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserStateInner {
/// Subject in Oauth
pub sub: String,
/// Access Token
pub access_token: String,
/// Refresh Token
pub refresh_token: String,
/// User
pub user: User,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct User {
/// Email
pub email: String,
/// Avatar Url
pub avatar_url: String,
}
impl<S> FromRequestParts<S> for UserState
where
S: std::marker::Sync + std::marker::Send,
{
type Rejection = super::Error;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_: &S,
) -> Result<Self, super::Error> {
parts
.extensions
.get::<UserState>()
.cloned()
.ok_or(super::Error::StateError("Unable to get extension".into()))
}
}

11
src/lib.rs Normal file
View File

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

3
src/models/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
mod user;
pub use user::*;

21
src/models/user.rs Normal file
View File

@@ -0,0 +1,21 @@
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UserData {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggedInState {
pub access_token: String,
pub email: String,
}
impl LoggedInState {
pub fn new(access_token: String, email: String) -> Self {
Self {
access_token,
email,
}
}
}

74
src/pages/impressum.rs Normal file
View File

@@ -0,0 +1,74 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
use dioxus_free_icons::Icon;
use crate::Route;
/// Impressum (legal notice) page required by German/EU law.
///
/// Displays placeholder company information. This page is publicly
/// accessible without authentication.
#[component]
pub fn ImpressumPage() -> Element {
rsx! {
div { class: "legal-page",
nav { class: "legal-nav",
Link { to: Route::LandingPage {}, class: "landing-logo",
span { class: "landing-logo-icon",
Icon { icon: BsShieldCheck, width: 20, height: 20 }
}
span { "CERTifAI" }
}
}
main { class: "legal-content",
h1 { "Impressum" }
h2 { "Information according to 5 TMG" }
p {
"CERTifAI GmbH"
br {}
"Musterstrasse 1"
br {}
"10115 Berlin"
br {}
"Germany"
}
h2 { "Represented by" }
p { "Managing Director: [Name]" }
h2 { "Contact" }
p {
"Email: info@certifai.example"
br {}
"Phone: +49 (0) 30 1234567"
}
h2 { "Commercial Register" }
p {
"Registered at: Amtsgericht Berlin-Charlottenburg"
br {}
"Registration number: HRB XXXXXX"
}
h2 { "VAT ID" }
p { "VAT identification number according to 27a UStG: DE XXXXXXXXX" }
h2 { "Responsible for content according to 55 Abs. 2 RStV" }
p {
"[Name]"
br {}
"CERTifAI GmbH"
br {}
"Musterstrasse 1"
br {}
"10115 Berlin"
}
}
footer { class: "legal-footer",
Link { to: Route::LandingPage {}, "Back to Home" }
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
}
}
}
}

419
src/pages/landing.rs Normal file
View File

@@ -0,0 +1,419 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsArrowRight, BsGlobe2, BsKey, BsRobot, BsServer, BsShieldCheck,
};
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
use dioxus_free_icons::Icon;
use crate::Route;
/// Public landing page for the CERTifAI platform.
///
/// Displays a marketing-oriented page with hero section, feature grid,
/// how-it-works steps, and call-to-action banners. This page is accessible
/// without authentication.
#[component]
pub fn LandingPage() -> Element {
rsx! {
div { class: "landing",
LandingNav {}
HeroSection {}
SocialProof {}
FeaturesGrid {}
HowItWorks {}
CtaBanner {}
LandingFooter {}
}
}
}
/// Sticky top navigation bar with logo, nav links, and CTA buttons.
#[component]
fn LandingNav() -> Element {
rsx! {
nav { class: "landing-nav",
div { class: "landing-nav-inner",
Link { to: Route::LandingPage {}, class: "landing-logo",
span { class: "landing-logo-icon",
Icon { icon: BsShieldCheck, width: 24, height: 24 }
}
span { "CERTifAI" }
}
div { class: "landing-nav-links",
a { href: "#features", "Features" }
a { href: "#how-it-works", "How It Works" }
a { href: "#pricing", "Pricing" }
}
div { class: "landing-nav-actions",
Link {
to: Route::Login { redirect_url: "/dashboard".into() },
class: "btn btn-ghost btn-sm",
"Log In"
}
Link {
to: Route::Login { redirect_url: "/dashboard".into() },
class: "btn btn-primary btn-sm",
"Get Started"
}
}
}
}
}
}
/// Hero section with headline, subtitle, and CTA buttons.
#[component]
fn HeroSection() -> Element {
rsx! {
section { class: "hero-section",
div { class: "hero-content",
div { class: "hero-badge badge badge-outline",
"Privacy-First GenAI Infrastructure"
}
h1 { class: "hero-title",
"Your AI. Your Data."
br {}
span { class: "hero-title-accent", "Your Infrastructure." }
}
p { class: "hero-subtitle",
"Self-hosted, GDPR-compliant generative AI platform for "
"enterprises that refuse to compromise on data sovereignty. "
"Deploy LLMs, agents, and MCP servers on your own terms."
}
div { class: "hero-actions",
Link {
to: Route::Login { redirect_url: "/dashboard".into() },
class: "btn btn-primary btn-lg",
"Get Started"
Icon { icon: BsArrowRight, width: 18, height: 18 }
}
a {
href: "#features",
class: "btn btn-outline btn-lg",
"Learn More"
}
}
}
div { class: "hero-graphic",
// Abstract shield/network SVG motif
svg {
view_box: "0 0 400 400",
fill: "none",
width: "100%",
height: "100%",
// Gradient definitions
defs {
linearGradient {
id: "grad1",
x1: "0%", y1: "0%",
x2: "100%", y2: "100%",
stop { offset: "0%", stop_color: "#91a4d2" }
stop { offset: "100%", stop_color: "#6d85c6" }
}
linearGradient {
id: "grad2",
x1: "0%", y1: "100%",
x2: "100%", y2: "0%",
stop { offset: "0%", stop_color: "#f97066" }
stop { offset: "100%", stop_color: "#f9a066" }
}
radialGradient {
id: "glow",
cx: "50%", cy: "50%", r: "50%",
stop { offset: "0%", stop_color: "rgba(145,164,210,0.3)" }
stop { offset: "100%", stop_color: "rgba(145,164,210,0)" }
}
}
// Background glow
circle { cx: "200", cy: "200", r: "180", fill: "url(#glow)" }
// Shield outline
path {
d: "M200 40 L340 110 L340 230 C340 300 270 360 200 380 \
C130 360 60 300 60 230 L60 110 Z",
stroke: "url(#grad1)",
stroke_width: "2",
fill: "none",
opacity: "0.6",
}
// Inner shield
path {
d: "M200 80 L310 135 L310 225 C310 280 255 330 200 345 \
C145 330 90 280 90 225 L90 135 Z",
stroke: "url(#grad1)",
stroke_width: "1.5",
fill: "rgba(145,164,210,0.05)",
opacity: "0.8",
}
// Network nodes
circle { cx: "200", cy: "180", r: "8", fill: "url(#grad1)" }
circle { cx: "150", cy: "230", r: "6", fill: "url(#grad2)" }
circle { cx: "250", cy: "230", r: "6", fill: "url(#grad2)" }
circle { cx: "200", cy: "280", r: "6", fill: "url(#grad1)" }
circle { cx: "130", cy: "170", r: "4", fill: "#91a4d2", opacity: "0.6" }
circle { cx: "270", cy: "170", r: "4", fill: "#91a4d2", opacity: "0.6" }
// Network connections
line {
x1: "200", y1: "180", x2: "150", y2: "230",
stroke: "#91a4d2", stroke_width: "1", opacity: "0.4",
}
line {
x1: "200", y1: "180", x2: "250", y2: "230",
stroke: "#91a4d2", stroke_width: "1", opacity: "0.4",
}
line {
x1: "150", y1: "230", x2: "200", y2: "280",
stroke: "#91a4d2", stroke_width: "1", opacity: "0.4",
}
line {
x1: "250", y1: "230", x2: "200", y2: "280",
stroke: "#91a4d2", stroke_width: "1", opacity: "0.4",
}
line {
x1: "200", y1: "180", x2: "130", y2: "170",
stroke: "#91a4d2", stroke_width: "1", opacity: "0.3",
}
line {
x1: "200", y1: "180", x2: "270", y2: "170",
stroke: "#91a4d2", stroke_width: "1", opacity: "0.3",
}
// Checkmark inside shield center
path {
d: "M180 200 L195 215 L225 185",
stroke: "url(#grad1)",
stroke_width: "3",
stroke_linecap: "round",
stroke_linejoin: "round",
fill: "none",
}
}
}
}
}
}
/// Social proof / trust indicator strip.
#[component]
fn SocialProof() -> Element {
rsx! {
section { class: "social-proof",
p { class: "social-proof-text",
"Built for enterprises that value "
span { class: "social-proof-highlight", "data sovereignty" }
}
div { class: "social-proof-stats",
div { class: "proof-stat",
span { class: "proof-stat-value", "100%" }
span { class: "proof-stat-label", "On-Premise" }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "GDPR" }
span { class: "proof-stat-label", "Compliant" }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "EU" }
span { class: "proof-stat-label", "Data Residency" }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "Zero" }
span { class: "proof-stat-label", "Third-Party Sharing" }
}
}
}
}
}
/// Feature cards grid section.
#[component]
fn FeaturesGrid() -> Element {
rsx! {
section { id: "features", class: "features-section",
h2 { class: "section-title", "Everything You Need" }
p { class: "section-subtitle",
"A complete, self-hosted GenAI stack under your full control."
}
div { class: "features-grid",
FeatureCard {
icon: rsx! { Icon { icon: BsServer, width: 28, height: 28 } },
title: "Self-Hosted Infrastructure",
description: "Deploy on your own hardware or private cloud. \
Full control over your AI stack with no external dependencies.",
}
FeatureCard {
icon: rsx! { Icon { icon: BsShieldCheck, width: 28, height: 28 } },
title: "GDPR Compliant",
description: "EU data residency guaranteed. Your data never \
leaves your infrastructure or gets shared with third parties.",
}
FeatureCard {
icon: rsx! { Icon { icon: FaCubes, width: 28, height: 28 } },
title: "LLM Management",
description: "Deploy, monitor, and manage multiple language \
models. Switch between models with zero downtime.",
}
FeatureCard {
icon: rsx! { Icon { icon: BsRobot, width: 28, height: 28 } },
title: "Agent Builder",
description: "Create custom AI agents with integrated Langchain \
and Langfuse for full observability and control.",
}
FeatureCard {
icon: rsx! { Icon { icon: BsGlobe2, width: 28, height: 28 } },
title: "MCP Server Management",
description: "Manage Model Context Protocol servers to extend \
your AI capabilities with external tool integrations.",
}
FeatureCard {
icon: rsx! { Icon { icon: BsKey, width: 28, height: 28 } },
title: "API Key Management",
description: "Generate API keys, track usage per seat, and \
set fine-grained permissions for every integration.",
}
}
}
}
}
/// Individual feature card.
///
/// # Arguments
///
/// * `icon` - The icon element to display
/// * `title` - Feature title
/// * `description` - Feature description text
#[component]
fn FeatureCard(icon: Element, title: &'static str, description: &'static str) -> Element {
rsx! {
div { class: "card feature-card",
div { class: "feature-card-icon", {icon} }
h3 { class: "feature-card-title", "{title}" }
p { class: "feature-card-desc", "{description}" }
}
}
}
/// Three-step "How It Works" section.
#[component]
fn HowItWorks() -> Element {
rsx! {
section { id: "how-it-works", class: "how-it-works-section",
h2 { class: "section-title", "Up and Running in Minutes" }
p { class: "section-subtitle",
"Three steps to sovereign AI infrastructure."
}
div { class: "steps-grid",
StepCard {
number: "01",
title: "Deploy",
description: "Install CERTifAI on your infrastructure \
with a single command. Supports Docker, Kubernetes, \
and bare metal.",
}
StepCard {
number: "02",
title: "Configure",
description: "Connect your identity provider, select \
your models, and set up team permissions through \
the admin dashboard.",
}
StepCard {
number: "03",
title: "Scale",
description: "Add users, deploy more models, and \
integrate with your existing tools via API keys \
and MCP servers.",
}
}
}
}
}
/// Individual step card.
///
/// # Arguments
///
/// * `number` - Step number string (e.g. "01")
/// * `title` - Step title
/// * `description` - Step description text
#[component]
fn StepCard(number: &'static str, title: &'static str, description: &'static str) -> Element {
rsx! {
div { class: "step-card",
span { class: "step-number", "{number}" }
h3 { class: "step-title", "{title}" }
p { class: "step-desc", "{description}" }
}
}
}
/// Call-to-action banner before the footer.
#[component]
fn CtaBanner() -> Element {
rsx! {
section { class: "cta-banner",
h2 { class: "cta-title",
"Ready to take control of your AI infrastructure?"
}
p { class: "cta-subtitle",
"Start deploying sovereign GenAI today. No credit card required."
}
div { class: "cta-actions",
Link {
to: Route::Login { redirect_url: "/dashboard".into() },
class: "btn btn-primary btn-lg",
"Get Started Free"
Icon { icon: BsArrowRight, width: 18, height: 18 }
}
Link {
to: Route::Login { redirect_url: "/dashboard".into() },
class: "btn btn-outline btn-lg",
"Log In"
}
}
}
}
}
/// Landing page footer with links and copyright.
#[component]
fn LandingFooter() -> Element {
rsx! {
footer { class: "landing-footer",
div { class: "landing-footer-inner",
div { class: "footer-brand",
div { class: "landing-logo",
span { class: "landing-logo-icon",
Icon { icon: BsShieldCheck, width: 20, height: 20 }
}
span { "CERTifAI" }
}
p { class: "footer-tagline",
"Sovereign GenAI infrastructure for enterprises."
}
}
div { class: "footer-links-group",
h4 { class: "footer-links-heading", "Product" }
a { href: "#features", "Features" }
a { href: "#how-it-works", "How It Works" }
a { href: "#pricing", "Pricing" }
}
div { class: "footer-links-group",
h4 { class: "footer-links-heading", "Legal" }
Link { to: Route::ImpressumPage {}, "Impressum" }
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
}
div { class: "footer-links-group",
h4 { class: "footer-links-heading", "Resources" }
a { href: "#", "Documentation" }
a { href: "#", "API Reference" }
a { href: "#", "Support" }
}
}
div { class: "footer-bottom",
p { "2026 CERTifAI. All rights reserved." }
}
}
}
}

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

@@ -0,0 +1,9 @@
mod impressum;
mod landing;
mod overview;
mod privacy;
pub use impressum::*;
pub use landing::*;
pub use overview::*;
pub use privacy::*;

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

@@ -0,0 +1,102 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::BsBook;
use dioxus_free_icons::icons::fa_solid_icons::{FaChartLine, FaCubes, FaGears};
use dioxus_free_icons::Icon;
use crate::components::DashboardCard;
use crate::Route;
/// Overview dashboard page rendered inside the `AppShell` layout.
///
/// Displays a welcome heading and a grid of quick-access cards
/// for the main GenAI platform tools.
#[component]
pub fn OverviewPage() -> Element {
// Check authentication status on mount via a server function.
let auth_check = use_resource(check_auth);
let navigator = use_navigator();
// Once the server responds, redirect unauthenticated users to /auth.
use_effect(move || {
if let Some(Ok(false)) = auth_check() {
navigator.push(NavigationTarget::<Route>::External(
"/auth?redirect_url=/dashboard".into(),
));
}
});
match auth_check() {
// Still waiting for the server to respond.
None => rsx! {},
// Not authenticated -- render nothing while the redirect fires.
Some(Ok(false)) => rsx! {},
// Authenticated -- render the overview dashboard.
Some(Ok(true)) => rsx! {
section { class: "overview-page",
h1 { class: "overview-heading", "GenAI Dashboard" }
div { class: "dashboard-grid",
DashboardCard {
title: "Documentation".to_string(),
description: "Guides & API Reference".to_string(),
href: "#".to_string(),
icon: rsx! {
Icon { icon: BsBook, width: 28, height: 28 }
},
}
DashboardCard {
title: "Langfuse".to_string(),
description: "Observability & Analytics".to_string(),
href: "#".to_string(),
icon: rsx! {
Icon { icon: FaChartLine, width: 28, height: 28 }
},
}
DashboardCard {
title: "Langchain".to_string(),
description: "Agent Framework".to_string(),
href: "#".to_string(),
icon: rsx! {
Icon { icon: FaGears, width: 28, height: 28 }
},
}
DashboardCard {
title: "Hugging Face".to_string(),
description: "Browse Models".to_string(),
href: "#".to_string(),
icon: rsx! {
Icon { icon: FaCubes, width: 28, height: 28 }
},
}
}
}
},
// Server error -- surface it so it is not silently swallowed.
Some(Err(err)) => rsx! {
p { "Error: {err}" }
},
}
}
/// Check whether the current request has an active logged-in session.
///
/// # Returns
///
/// `true` if the session contains a logged-in user, `false` otherwise.
///
/// # Errors
///
/// Returns `ServerFnError` if the session cannot be extracted from the request.
#[server]
async fn check_auth() -> Result<bool, ServerFnError> {
use crate::infrastructure::{UserStateInner, LOGGED_IN_USER_SESS_KEY};
use tower_sessions::Session;
// Extract the tower_sessions::Session from the Axum request.
let session: Session = FullstackContext::extract().await?;
let user: Option<UserStateInner> = session
.get(LOGGED_IN_USER_SESS_KEY)
.await
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
Ok(user.is_some())
}

110
src/pages/privacy.rs Normal file
View File

@@ -0,0 +1,110 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
use dioxus_free_icons::Icon;
use crate::Route;
/// Privacy Policy page.
///
/// Displays the platform's privacy policy. Publicly accessible
/// without authentication.
#[component]
pub fn PrivacyPage() -> Element {
rsx! {
div { class: "legal-page",
nav { class: "legal-nav",
Link { to: Route::LandingPage {}, class: "landing-logo",
span { class: "landing-logo-icon",
Icon { icon: BsShieldCheck, width: 20, height: 20 }
}
span { "CERTifAI" }
}
}
main { class: "legal-content",
h1 { "Privacy Policy" }
p { class: "legal-updated",
"Last updated: February 2026"
}
h2 { "1. Introduction" }
p {
"CERTifAI GmbH (\"we\", \"our\", \"us\") is committed to "
"protecting your personal data. This privacy policy explains "
"how we collect, use, and safeguard your information when you "
"use our platform."
}
h2 { "2. Data Controller" }
p {
"CERTifAI GmbH"
br {}
"Musterstrasse 1, 10115 Berlin, Germany"
br {}
"Email: privacy@certifai.example"
}
h2 { "3. Data We Collect" }
p {
"We collect only the minimum data necessary to provide "
"our services:"
}
ul {
li {
strong { "Account data: " }
"Name, email address, and organization details "
"provided during registration."
}
li {
strong { "Usage data: " }
"API call logs, token counts, and feature usage "
"metrics for billing and analytics."
}
li {
strong { "Technical data: " }
"IP addresses, browser type, and session identifiers "
"for security and platform stability."
}
}
h2 { "4. How We Use Your Data" }
ul {
li { "To provide and maintain the CERTifAI platform" }
li { "To manage your account and subscription" }
li { "To communicate service updates and security notices" }
li { "To comply with legal obligations" }
}
h2 { "5. Data Storage and Sovereignty" }
p {
"CERTifAI is a self-hosted platform. All AI workloads, "
"model data, and inference results remain entirely within "
"your own infrastructure. We do not access, store, or "
"process your AI data on our servers."
}
h2 { "6. Your Rights (GDPR)" }
p { "Under the GDPR, you have the right to:" }
ul {
li { "Access your personal data" }
li { "Rectify inaccurate data" }
li { "Request erasure of your data" }
li { "Restrict or object to processing" }
li { "Data portability" }
li {
"Lodge a complaint with a supervisory authority"
}
}
h2 { "7. Contact" }
p {
"For privacy-related inquiries, contact us at "
"privacy@certifai.example."
}
}
footer { class: "legal-footer",
Link { to: Route::LandingPage {}, "Back to Home" }
Link { to: Route::ImpressumPage {}, "Impressum" }
}
}
}
}

112
styles/input.css Normal file
View File

@@ -0,0 +1,112 @@
@import "tailwindcss";
@plugin "daisyui";
/* ===== CERTifAI Dark Theme (default) ===== */
@plugin "daisyui/theme" {
name: "certifai-dark";
default: true;
prefersdark: true;
color-scheme: dark;
/* Base: deep navy-charcoal */
--color-base-100: oklch(18% 0.02 260);
--color-base-200: oklch(14% 0.02 260);
--color-base-300: oklch(11% 0.02 260);
--color-base-content: oklch(90% 0.01 260);
/* Primary: electric indigo */
--color-primary: oklch(62% 0.26 275);
--color-primary-content: oklch(98% 0.01 275);
/* Secondary: coral */
--color-secondary: oklch(68% 0.18 25);
--color-secondary-content: oklch(98% 0.01 25);
/* Accent: teal */
--color-accent: oklch(72% 0.15 185);
--color-accent-content: oklch(12% 0.03 185);
/* Neutral */
--color-neutral: oklch(25% 0.02 260);
--color-neutral-content: oklch(85% 0.01 260);
/* Semantic */
--color-info: oklch(70% 0.18 230);
--color-info-content: oklch(98% 0.01 230);
--color-success: oklch(68% 0.19 145);
--color-success-content: oklch(98% 0.01 145);
--color-warning: oklch(82% 0.22 85);
--color-warning-content: oklch(18% 0.04 85);
--color-error: oklch(65% 0.26 25);
--color-error-content: oklch(98% 0.01 25);
/* Sharp, modern radii */
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
/* ===== CERTifAI Light Theme ===== */
@plugin "daisyui/theme" {
name: "certifai-light";
default: false;
prefersdark: false;
color-scheme: light;
/* Base: clean off-white */
--color-base-100: oklch(98% 0.005 260);
--color-base-200: oklch(95% 0.008 260);
--color-base-300: oklch(91% 0.012 260);
--color-base-content: oklch(20% 0.03 260);
/* Primary: indigo (adjusted for light bg) */
--color-primary: oklch(50% 0.26 275);
--color-primary-content: oklch(98% 0.01 275);
/* Secondary: coral (adjusted for light bg) */
--color-secondary: oklch(58% 0.18 25);
--color-secondary-content: oklch(98% 0.01 25);
/* Accent: teal (adjusted for light bg) */
--color-accent: oklch(55% 0.15 185);
--color-accent-content: oklch(98% 0.01 185);
/* Neutral */
--color-neutral: oklch(35% 0.02 260);
--color-neutral-content: oklch(98% 0.01 260);
/* Semantic */
--color-info: oklch(55% 0.18 230);
--color-info-content: oklch(98% 0.01 230);
--color-success: oklch(52% 0.19 145);
--color-success-content: oklch(98% 0.01 145);
--color-warning: oklch(72% 0.22 85);
--color-warning-content: oklch(18% 0.04 85);
--color-error: oklch(55% 0.26 25);
--color-error-content: oklch(98% 0.01 25);
/* Same sharp radii */
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}