Compare commits
5 Commits
feat/CAI-1
...
a588be306a
| Author | SHA1 | Date | |
|---|---|---|---|
| a588be306a | |||
| f699976f4d | |||
| 0673f7867c | |||
| 6d3e99220c | |||
| 1072770d11 |
114
.gitea/workflows/ci.yml
Normal file
114
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,114 @@
|
||||
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: Deploy (only after tests pass, only on main)
|
||||
# ---------------------------------------------------------------------------
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: docker
|
||||
needs: [test]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
container:
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- name: Trigger Coolify deploy
|
||||
run: |
|
||||
apk add --no-cache curl
|
||||
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -12,5 +12,11 @@
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Keycloak data
|
||||
keycloak/
|
||||
# Keycloak runtime data (but keep realm-export.json)
|
||||
keycloak/*
|
||||
!keycloak/realm-export.json
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
searxng/
|
||||
|
||||
23
CLAUDE.md
23
CLAUDE.md
@@ -237,23 +237,12 @@ The SaaS application dashboard is the landing page for the company admin to view
|
||||
|
||||
This project is written in dioxus with fullstack and router features. MongoDB is used as a database for maintaining user state. Keycloak is used as identity provider for user management.
|
||||
|
||||
## Features management
|
||||
|
||||
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
|
||||
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -739,6 +739,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-stripe",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dioxus",
|
||||
"dioxus-cli-config",
|
||||
@@ -755,6 +756,7 @@ dependencies = [
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
|
||||
@@ -73,6 +73,8 @@ 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"]
|
||||
@@ -87,6 +89,8 @@ server = [
|
||||
"dep:time",
|
||||
"dep:rand",
|
||||
"dep:url",
|
||||
"dep:sha2",
|
||||
"dep:base64",
|
||||
]
|
||||
|
||||
[[bin]]
|
||||
|
||||
57
Dockerfile
Normal file
57
Dockerfile
Normal 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"]
|
||||
29
README.md
29
README.md
@@ -1,5 +1,11 @@
|
||||
# CERTifAI
|
||||
|
||||
[](https://gitea.meghsakha.com/sharang/certifai/actions?workflow=ci.yml)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://dioxuslabs.com/)
|
||||
[](LICENSE)
|
||||
[](https://gdpr.eu/)
|
||||
|
||||
This project is a SaaS application dashboard for administation of self-hosted private GenAI (generative AI) toolbox for companies and individuals. The purpose of the dashboard is to manage LLMs, Agents, MCP Servers and other GenAI related features.
|
||||
|
||||
The purpose of `CERTifAI`is to provide self-hosted or GDPR-Conform GenAI infrastructure to companies who do not wish to subscribe to non-EU cloud providers to protect their intellectual property from being used as training data.
|
||||
@@ -19,23 +25,14 @@ The SaaS application dashboard is the landing page for the company admin to view
|
||||
|
||||
This project is written in dioxus with fullstack and router features. MongoDB is used as a database for maintaining user state. Keycloak is used as identity provider for user management.
|
||||
|
||||
## Features management
|
||||
|
||||
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
|
||||
|
||||
|
||||
25
assets/logo.svg
Normal file
25
assets/logo.svg
Normal 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 |
1894
assets/main.css
1894
assets/main.css
File diff suppressed because it is too large
Load Diff
1586
assets/tailwind.css
1586
assets/tailwind.css
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,10 @@ fn main() {
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
tracing::info!("Starting server...");
|
||||
dashboard::infrastructure::server::server_start(dashboard::App)
|
||||
.map_err(|e| tracing::error! {"Failed to start server: {:?}", e})
|
||||
.expect("Failed to start server");
|
||||
dashboard::infrastructure::server_start(dashboard::App)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Unable to start server: {e}");
|
||||
})
|
||||
.expect("Server start failed")
|
||||
}
|
||||
}
|
||||
|
||||
15
build.rs
15
build.rs
@@ -1,8 +1,9 @@
|
||||
#[allow(clippy::expect_used)]
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::process::Command;
|
||||
println!("cargo:rerun-if-changed=./styles/input.css");
|
||||
Command::new("bunx")
|
||||
|
||||
// Tailwind build is optional - skip gracefully in CI or environments without bun
|
||||
match Command::new("bunx")
|
||||
.args([
|
||||
"@tailwindcss/cli",
|
||||
"-i",
|
||||
@@ -11,7 +12,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"./assets/tailwind.css",
|
||||
])
|
||||
.status()
|
||||
.expect("could not run tailwind");
|
||||
{
|
||||
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
33
bun.lock
Normal 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
52
cliff.toml
Normal 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"
|
||||
@@ -28,4 +28,15 @@ services:
|
||||
- 27017:27017
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
MONGO_INITDB_ROOT_PASSWORD: example
|
||||
MONGO_INITDB_ROOT_PASSWORD: example
|
||||
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
container_name: certifai-searxng
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8888:8080"
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:8888
|
||||
volumes:
|
||||
- ./searxng:/etc/searxng:rw
|
||||
246
keycloak/realm-export.json
Normal file
246
keycloak/realm-export.json
Normal 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
16
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
156
src/app.rs
156
src/app.rs
@@ -1,122 +1,78 @@
|
||||
use crate::{components::*, pages::*};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Application routes.
|
||||
///
|
||||
/// Public pages (`LandingPage`, `ImpressumPage`, `PrivacyPage`) live
|
||||
/// outside the `AppShell` layout. Authenticated pages are wrapped in
|
||||
/// `AppShell` which renders the sidebar. `DeveloperShell` and `OrgShell`
|
||||
/// provide nested tab navigation within the app shell.
|
||||
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||
#[rustfmt::skip]
|
||||
pub enum Route {
|
||||
#[layout(Navbar)]
|
||||
#[route("/")]
|
||||
OverviewPage {},
|
||||
LandingPage {},
|
||||
#[route("/impressum")]
|
||||
ImpressumPage {},
|
||||
#[route("/privacy")]
|
||||
PrivacyPage {},
|
||||
#[layout(AppShell)]
|
||||
#[route("/dashboard")]
|
||||
DashboardPage {},
|
||||
#[route("/providers")]
|
||||
ProvidersPage {},
|
||||
#[route("/chat")]
|
||||
ChatPage {},
|
||||
#[route("/tools")]
|
||||
ToolsPage {},
|
||||
#[route("/knowledge")]
|
||||
KnowledgePage {},
|
||||
|
||||
#[layout(DeveloperShell)]
|
||||
#[route("/developer/agents")]
|
||||
AgentsPage {},
|
||||
#[route("/developer/flow")]
|
||||
FlowPage {},
|
||||
#[route("/developer/analytics")]
|
||||
AnalyticsPage {},
|
||||
#[end_layout]
|
||||
|
||||
#[layout(OrgShell)]
|
||||
#[route("/organization/pricing")]
|
||||
OrgPricingPage {},
|
||||
#[route("/organization/dashboard")]
|
||||
OrgDashboardPage {},
|
||||
#[end_layout]
|
||||
#[end_layout]
|
||||
|
||||
#[route("/login?:redirect_url")]
|
||||
Login { redirect_url: String },
|
||||
#[route("/blog/:id")]
|
||||
Blog { id: i32 },
|
||||
}
|
||||
|
||||
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
||||
const MAIN_CSS: Asset = asset!("/assets/main.css");
|
||||
const HEADER_SVG: Asset = asset!("/assets/header.svg");
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
|
||||
/// 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: "stylesheet", href: MAIN_CSS }
|
||||
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 }
|
||||
Router::<Route> {}
|
||||
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||
div { "data-theme": "certifai-dark", Router::<Route> {} }
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Hero() -> Element {
|
||||
rsx! {
|
||||
div { id: "hero",
|
||||
img { src: HEADER_SVG, id: "header" }
|
||||
div { id: "links",
|
||||
a { href: "https://dioxuslabs.com/learn/0.7/", "📚 Learn Dioxus" }
|
||||
a { href: "https://dioxuslabs.com/awesome", "🚀 Awesome Dioxus" }
|
||||
a { href: "https://github.com/dioxus-community/", "📡 Community Libraries" }
|
||||
a { href: "https://github.com/DioxusLabs/sdk", "⚙️ Dioxus Development Kit" }
|
||||
a { href: "https://marketplace.visualstudio.com/items?itemName=DioxusLabs.dioxus",
|
||||
"💫 VSCode Extension"
|
||||
}
|
||||
a { href: "https://discord.gg/XgGxMSkvUM", "👋 Community Discord" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Home page
|
||||
#[component]
|
||||
fn Home() -> Element {
|
||||
rsx! {
|
||||
Hero {}
|
||||
Echo {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blog page
|
||||
#[component]
|
||||
pub fn Blog(id: i32) -> Element {
|
||||
rsx! {
|
||||
div { id: "blog",
|
||||
|
||||
// Content
|
||||
h1 { "This is blog #{id}!" }
|
||||
p {
|
||||
"In blog #{id}, we show how the Dioxus router works and how URL parameters can be passed as props to our route components."
|
||||
}
|
||||
|
||||
// Navigation links
|
||||
Link { to: Route::Blog { id: id - 1 }, "Previous" }
|
||||
span { " <---> " }
|
||||
Link { to: Route::Blog { id: id + 1 }, "Next" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared navbar component.
|
||||
#[component]
|
||||
fn Navbar() -> Element {
|
||||
rsx! {
|
||||
div { id: "navbar",
|
||||
Link { to: Route::OverviewPage {}, "Home" }
|
||||
Link { to: Route::Blog { id: 1 }, "Blog" }
|
||||
}
|
||||
|
||||
Outlet::<Route> {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Echo component that demonstrates fullstack server functions.
|
||||
#[component]
|
||||
fn Echo() -> Element {
|
||||
let mut response = use_signal(|| String::new());
|
||||
|
||||
rsx! {
|
||||
div { id: "echo",
|
||||
h4 { "ServerFn Echo" }
|
||||
input {
|
||||
placeholder: "Type here to echo...",
|
||||
oninput: move |event| async move {
|
||||
let data = echo_server(event.value()).await.unwrap();
|
||||
response.set(data);
|
||||
},
|
||||
}
|
||||
|
||||
if !response().is_empty() {
|
||||
p {
|
||||
"Server echoed: "
|
||||
i { "{response}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Echo the user input on the server.
|
||||
#[post("/api/echo")]
|
||||
async fn echo_server(input: String) -> Result<String, ServerFnError> {
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
21
src/components/app_shell.rs
Normal file
21
src/components/app_shell.rs
Normal 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
20
src/components/card.rs
Normal 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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/components/chat_bubble.rs
Normal file
41
src/components/chat_bubble.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use crate::models::{ChatMessage, ChatRole};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Renders a single chat message bubble with role-based styling.
|
||||
///
|
||||
/// User messages are right-aligned; assistant messages are left-aligned.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `message` - The chat message to render
|
||||
#[component]
|
||||
pub fn ChatBubble(message: ChatMessage) -> Element {
|
||||
let bubble_class = match message.role {
|
||||
ChatRole::User => "chat-bubble chat-bubble--user",
|
||||
ChatRole::Assistant => "chat-bubble chat-bubble--assistant",
|
||||
ChatRole::System => "chat-bubble chat-bubble--system",
|
||||
};
|
||||
|
||||
let role_label = match message.role {
|
||||
ChatRole::User => "You",
|
||||
ChatRole::Assistant => "Assistant",
|
||||
ChatRole::System => "System",
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "{bubble_class}",
|
||||
div { class: "chat-bubble-header",
|
||||
span { class: "chat-bubble-role", "{role_label}" }
|
||||
span { class: "chat-bubble-time", "{message.timestamp}" }
|
||||
}
|
||||
div { class: "chat-bubble-content", "{message.content}" }
|
||||
if !message.attachments.is_empty() {
|
||||
div { class: "chat-bubble-attachments",
|
||||
for att in &message.attachments {
|
||||
span { class: "chat-attachment", "{att.name}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/components/file_row.rs
Normal file
54
src/components/file_row.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use crate::models::KnowledgeFile;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Renders a table row for a knowledge base file.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file` - The knowledge file data to render
|
||||
/// * `on_delete` - Callback fired when the delete button is clicked
|
||||
#[component]
|
||||
pub fn FileRow(file: KnowledgeFile, on_delete: EventHandler<String>) -> Element {
|
||||
// Format file size for human readability (Python devs: similar to humanize.naturalsize)
|
||||
let size_display = format_size(file.size_bytes);
|
||||
|
||||
rsx! {
|
||||
tr { class: "file-row",
|
||||
td { class: "file-row-name",
|
||||
span { class: "file-row-icon", "{file.kind.icon()}" }
|
||||
"{file.name}"
|
||||
}
|
||||
td { "{file.kind.label()}" }
|
||||
td { "{size_display}" }
|
||||
td { "{file.chunk_count} chunks" }
|
||||
td { "{file.uploaded_at}" }
|
||||
td {
|
||||
button {
|
||||
class: "btn-icon btn-danger",
|
||||
onclick: {
|
||||
let id = file.id.clone();
|
||||
move |_| on_delete.call(id.clone())
|
||||
},
|
||||
"Delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a byte count into a human-readable string (e.g. "1.2 MB").
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = KB * 1024;
|
||||
const GB: u64 = MB * 1024;
|
||||
|
||||
if bytes >= GB {
|
||||
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{:.1} KB", bytes as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,26 @@
|
||||
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 || {
|
||||
let target = format!("/auth?redirect_url={}", redirect_url);
|
||||
// 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));
|
||||
});
|
||||
|
||||
|
||||
38
src/components/member_row.rs
Normal file
38
src/components/member_row.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use crate::models::{MemberRole, OrgMember};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Renders a table row for an organization member with a role dropdown.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `member` - The organization member data to render
|
||||
/// * `on_role_change` - Callback fired with (member_id, new_role) when role changes
|
||||
#[component]
|
||||
pub fn MemberRow(member: OrgMember, on_role_change: EventHandler<(String, String)>) -> Element {
|
||||
rsx! {
|
||||
tr { class: "member-row",
|
||||
td { class: "member-row-name", "{member.name}" }
|
||||
td { "{member.email}" }
|
||||
td {
|
||||
select {
|
||||
class: "member-role-select",
|
||||
value: "{member.role.label()}",
|
||||
onchange: {
|
||||
let id = member.id.clone();
|
||||
move |evt: Event<FormData>| {
|
||||
on_role_change.call((id.clone(), evt.value()));
|
||||
}
|
||||
},
|
||||
for role in MemberRole::all() {
|
||||
option {
|
||||
value: "{role.label()}",
|
||||
selected: *role == member.role,
|
||||
"{role.label()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
td { "{member.joined_at}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,24 @@
|
||||
mod app_shell;
|
||||
mod card;
|
||||
mod chat_bubble;
|
||||
mod file_row;
|
||||
mod login;
|
||||
mod member_row;
|
||||
pub mod news_card;
|
||||
mod page_header;
|
||||
mod pricing_card;
|
||||
pub mod sidebar;
|
||||
pub mod sub_nav;
|
||||
mod tool_card;
|
||||
|
||||
pub use app_shell::*;
|
||||
pub use card::*;
|
||||
pub use chat_bubble::*;
|
||||
pub use file_row::*;
|
||||
pub use login::*;
|
||||
pub use member_row::*;
|
||||
pub use news_card::*;
|
||||
pub use page_header::*;
|
||||
pub use pricing_card::*;
|
||||
pub use sub_nav::*;
|
||||
pub use tool_card::*;
|
||||
|
||||
138
src/components/news_card.rs
Normal file
138
src/components/news_card.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use crate::models::{NewsCard as NewsCardModel, NewsCategory};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Renders a news feed card with title, source, category badge, and summary.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `card` - The news card model data to render
|
||||
#[component]
|
||||
pub fn NewsCardView(card: NewsCardModel) -> Element {
|
||||
let badge_class = format!("news-badge news-badge--{}", card.category.css_class());
|
||||
|
||||
rsx! {
|
||||
article { class: "news-card",
|
||||
if let Some(ref thumb) = card.thumbnail_url {
|
||||
div { class: "news-card-thumb",
|
||||
img {
|
||||
src: "{thumb}",
|
||||
alt: "{card.title}",
|
||||
loading: "lazy",
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "news-card-body",
|
||||
div { class: "news-card-meta",
|
||||
span { class: "{badge_class}", "{card.category.label()}" }
|
||||
span { class: "news-card-source", "{card.source}" }
|
||||
span { class: "news-card-date", "{card.published_at}" }
|
||||
}
|
||||
h3 { class: "news-card-title",
|
||||
a {
|
||||
href: "{card.url}",
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
"{card.title}"
|
||||
}
|
||||
}
|
||||
p { class: "news-card-summary", "{card.summary}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock news data for the dashboard.
|
||||
pub fn mock_news() -> Vec<NewsCardModel> {
|
||||
vec![
|
||||
NewsCardModel {
|
||||
title: "Llama 4 Released with 1M Context Window".into(),
|
||||
source: "Meta AI Blog".into(),
|
||||
summary: "Meta releases Llama 4 with a 1 million token context window.".into(),
|
||||
category: NewsCategory::Llm,
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-18".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "EU AI Act Enforcement Begins".into(),
|
||||
source: "TechCrunch".into(),
|
||||
summary: "The EU AI Act enters its enforcement phase across member states.".into(),
|
||||
category: NewsCategory::Privacy,
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-17".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "LangChain v0.4 Introduces Native MCP Support".into(),
|
||||
source: "LangChain Blog".into(),
|
||||
summary: "New version adds first-class MCP server integration.".into(),
|
||||
category: NewsCategory::Agents,
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-16".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "Ollama Adds Multi-GPU Scheduling".into(),
|
||||
source: "Ollama".into(),
|
||||
summary: "Run large models across multiple GPUs with automatic sharding.".into(),
|
||||
category: NewsCategory::Infrastructure,
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-15".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "Mistral Open Sources Codestral 2".into(),
|
||||
source: "Mistral AI".into(),
|
||||
summary: "Codestral 2 achieves state-of-the-art on HumanEval benchmarks.".into(),
|
||||
category: NewsCategory::OpenSource,
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-14".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "NVIDIA Releases NeMo 3.0 Framework".into(),
|
||||
source: "NVIDIA Developer".into(),
|
||||
summary: "Updated framework simplifies enterprise LLM fine-tuning.".into(),
|
||||
category: NewsCategory::Infrastructure,
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-13".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "Anthropic Claude 4 Sets New Reasoning Records".into(),
|
||||
source: "Anthropic".into(),
|
||||
summary: "Claude 4 achieves top scores across major reasoning benchmarks.".into(),
|
||||
category: NewsCategory::Llm,
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-12".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "CrewAI Raises $52M for Agent Orchestration".into(),
|
||||
source: "VentureBeat".into(),
|
||||
summary: "Series B funding to expand multi-agent orchestration platform.".into(),
|
||||
category: NewsCategory::Agents,
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-11".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "DeepSeek V4 Released Under Apache 2.0".into(),
|
||||
source: "DeepSeek".into(),
|
||||
summary: "Latest open-weight model competes with proprietary offerings.".into(),
|
||||
category: NewsCategory::OpenSource,
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-10".into(),
|
||||
},
|
||||
NewsCardModel {
|
||||
title: "GDPR Fines for AI Training Data Reach Record High".into(),
|
||||
source: "Reuters".into(),
|
||||
summary: "European regulators issue largest penalties yet for AI data misuse.".into(),
|
||||
category: NewsCategory::Privacy,
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-09".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
23
src/components/page_header.rs
Normal file
23
src/components/page_header.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Reusable page header with title, subtitle, and an optional action slot.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `title` - Main heading text for the page
|
||||
/// * `subtitle` - Secondary descriptive text below the title
|
||||
/// * `actions` - Optional element rendered on the right side (e.g. buttons)
|
||||
#[component]
|
||||
pub fn PageHeader(title: String, subtitle: String, actions: Option<Element>) -> Element {
|
||||
rsx! {
|
||||
div { class: "page-header",
|
||||
div { class: "page-header-text",
|
||||
h1 { class: "page-title", "{title}" }
|
||||
p { class: "page-subtitle", "{subtitle}" }
|
||||
}
|
||||
if let Some(actions) = actions {
|
||||
div { class: "page-header-actions", {actions} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/components/pricing_card.rs
Normal file
46
src/components/pricing_card.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use crate::models::PricingPlan;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Renders a pricing plan card with features list and call-to-action button.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `plan` - The pricing plan data to render
|
||||
/// * `on_select` - Callback fired when the CTA button is clicked
|
||||
#[component]
|
||||
pub fn PricingCard(plan: PricingPlan, on_select: EventHandler<String>) -> Element {
|
||||
let card_class = if plan.highlighted {
|
||||
"pricing-card pricing-card--highlighted"
|
||||
} else {
|
||||
"pricing-card"
|
||||
};
|
||||
|
||||
let seats_label = match plan.max_seats {
|
||||
Some(n) => format!("Up to {n} seats"),
|
||||
None => "Unlimited seats".to_string(),
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "{card_class}",
|
||||
h3 { class: "pricing-card-name", "{plan.name}" }
|
||||
div { class: "pricing-card-price",
|
||||
span { class: "pricing-card-amount", "{plan.price_eur}" }
|
||||
span { class: "pricing-card-period", " EUR / month" }
|
||||
}
|
||||
p { class: "pricing-card-seats", "{seats_label}" }
|
||||
ul { class: "pricing-card-features",
|
||||
for feature in &plan.features {
|
||||
li { "{feature}" }
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "pricing-card-cta",
|
||||
onclick: {
|
||||
let id = plan.id.clone();
|
||||
move |_| on_select.call(id.clone())
|
||||
},
|
||||
"Get Started"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
156
src/components/sidebar.rs
Normal file
156
src/components/sidebar.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::{
|
||||
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub,
|
||||
BsGrid, BsHouseDoor, BsPuzzle,
|
||||
};
|
||||
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: "Dashboard",
|
||||
route: Route::DashboardPage {},
|
||||
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Providers",
|
||||
route: Route::ProvidersPage {},
|
||||
icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Chat",
|
||||
route: Route::ChatPage {},
|
||||
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Tools",
|
||||
route: Route::ToolsPage {},
|
||||
icon: rsx! { Icon { icon: BsPuzzle, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Knowledge Base",
|
||||
route: Route::KnowledgePage {},
|
||||
icon: rsx! { Icon { icon: BsCollection, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Developer",
|
||||
route: Route::AgentsPage {},
|
||||
icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Organization",
|
||||
route: Route::OrgPricingPage {},
|
||||
icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } },
|
||||
},
|
||||
];
|
||||
|
||||
// Determine current path to highlight the active nav link.
|
||||
let current_route = use_route::<Route>();
|
||||
|
||||
rsx! {
|
||||
aside { class: "sidebar",
|
||||
SidebarHeader { email: email.clone(), avatar_url }
|
||||
|
||||
nav { class: "sidebar-nav",
|
||||
for item in nav_items {
|
||||
{
|
||||
// Active detection for nested routes: highlight the parent nav
|
||||
// item when any child route within the nested shell is active.
|
||||
let is_active = match ¤t_route {
|
||||
Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => {
|
||||
item.label == "Developer"
|
||||
}
|
||||
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
|
||||
item.label == "Organization"
|
||||
}
|
||||
_ => 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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
|
||||
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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/components/sub_nav.rs
Normal file
44
src/components/sub_nav.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use crate::app::Route;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// A single tab item for the sub-navigation bar.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `label` - Display text for the tab
|
||||
/// * `route` - Route to navigate to when clicked
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct SubNavItem {
|
||||
pub label: &'static str,
|
||||
pub route: Route,
|
||||
}
|
||||
|
||||
/// Horizontal tab navigation bar used inside nested shell layouts.
|
||||
///
|
||||
/// Highlights the active tab based on the current route.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `items` - List of tab items to render
|
||||
#[component]
|
||||
pub fn SubNav(items: Vec<SubNavItem>) -> Element {
|
||||
let current_route = use_route::<Route>();
|
||||
|
||||
rsx! {
|
||||
nav { class: "sub-nav",
|
||||
for item in &items {
|
||||
{
|
||||
let is_active = item.route == current_route;
|
||||
let class = if is_active {
|
||||
"sub-nav-item sub-nav-item--active"
|
||||
} else {
|
||||
"sub-nav-item"
|
||||
};
|
||||
rsx! {
|
||||
Link { class: "{class}", to: item.route.clone(), "{item.label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/components/tool_card.rs
Normal file
44
src/components/tool_card.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use crate::models::McpTool;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Renders an MCP tool card with name, description, status indicator, and toggle.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `tool` - The MCP tool data to render
|
||||
/// * `on_toggle` - Callback fired when the enable/disable toggle is clicked
|
||||
#[component]
|
||||
pub fn ToolCard(tool: McpTool, on_toggle: EventHandler<String>) -> Element {
|
||||
let status_class = format!("tool-status tool-status--{}", tool.status.css_class());
|
||||
let toggle_class = if tool.enabled {
|
||||
"tool-toggle tool-toggle--on"
|
||||
} else {
|
||||
"tool-toggle tool-toggle--off"
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "tool-card",
|
||||
div { class: "tool-card-header",
|
||||
div { class: "tool-card-icon", "\u{2699}" }
|
||||
span { class: "{status_class}", "" }
|
||||
}
|
||||
h3 { class: "tool-card-name", "{tool.name}" }
|
||||
p { class: "tool-card-desc", "{tool.description}" }
|
||||
div { class: "tool-card-footer",
|
||||
span { class: "tool-card-category", "{tool.category.label()}" }
|
||||
button {
|
||||
class: "{toggle_class}",
|
||||
onclick: {
|
||||
let id = tool.id.clone();
|
||||
move |_| on_toggle.call(id.clone())
|
||||
},
|
||||
if tool.enabled {
|
||||
"ON"
|
||||
} else {
|
||||
"OFF"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +1,350 @@
|
||||
use super::error::{Error, Result};
|
||||
use axum::Extension;
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::request::Parts,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
use url::form_urlencoded;
|
||||
|
||||
pub struct KeycloakVariables {
|
||||
pub base_url: String,
|
||||
pub realm: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub enable_test_user: bool,
|
||||
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,
|
||||
}
|
||||
|
||||
/// Session data available to the backend when the user is logged in
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LoggedInData {
|
||||
pub id: String,
|
||||
// ID Token value associated with the authenticated session.
|
||||
pub token_id: String,
|
||||
pub username: String,
|
||||
pub avatar_url: Option<String>,
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Used for extracting in the server functions.
|
||||
/// If the `data` is `Some`, the user is logged in.
|
||||
pub struct UserSession {
|
||||
data: Option<LoggedInData>,
|
||||
/// 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 UserSession {
|
||||
/// Get the [`LoggedInData`].
|
||||
impl OAuthConfig {
|
||||
/// Load OAuth configuration from environment variables.
|
||||
///
|
||||
/// Raises a [`Error::UserNotLoggedIn`] error if the user is not logged in.
|
||||
pub fn data(self) -> Result<LoggedInData> {
|
||||
self.data.ok_or(Error::UserNotLoggedIn)
|
||||
/// # 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const LOGGED_IN_USER_SESSION_KEY: &str = "logged_in_data";
|
||||
|
||||
impl<S: std::marker::Sync + std::marker::Send> FromRequestParts<S> for UserSession {
|
||||
type Rejection = Error;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self> {
|
||||
let session = parts
|
||||
.extensions
|
||||
.get::<tower_sessions::Session>()
|
||||
.cloned()
|
||||
.ok_or(Error::AuthSessionLayerNotFound(
|
||||
"Auth Session Layer not found".to_string(),
|
||||
))?;
|
||||
|
||||
let data: Option<LoggedInData> = session
|
||||
.get::<LoggedInData>(LOGGED_IN_USER_SESSION_KEY)
|
||||
.await?;
|
||||
|
||||
Ok(Self { data })
|
||||
}
|
||||
/// 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
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper function to log the user in by setting the session data
|
||||
pub async fn login(session: &tower_sessions::Session, data: &LoggedInData) -> Result<()> {
|
||||
session.insert(LOGGED_IN_USER_SESSION_KEY, data).await?;
|
||||
Ok(())
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Handler to run when the user wants to logout
|
||||
/// 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 logout(
|
||||
state: Extension<super::server_state::ServerState>,
|
||||
session: tower_sessions::Session,
|
||||
) -> Result<Response> {
|
||||
let dashboard_base_url = "http://localhost:8000";
|
||||
let redirect_uri = format!("{dashboard_base_url}/");
|
||||
let encoded_redirect_uri: String =
|
||||
form_urlencoded::byte_serialize(redirect_uri.as_bytes()).collect();
|
||||
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);
|
||||
|
||||
// clear the session value for this session
|
||||
if let Some(login_data) = session
|
||||
.remove::<LoggedInData>(LOGGED_IN_USER_SESSION_KEY)
|
||||
.await?
|
||||
{
|
||||
let kc_base_url = &state.keycloak_variables.base_url;
|
||||
let kc_realm = &state.keycloak_variables.realm;
|
||||
let kc_client_id = &state.keycloak_variables.client_id;
|
||||
let redirect_url = params.get("redirect_url").cloned();
|
||||
pending.insert(
|
||||
state.clone(),
|
||||
PendingOAuthEntry {
|
||||
redirect_url,
|
||||
code_verifier,
|
||||
},
|
||||
);
|
||||
|
||||
// Needed for running locally.
|
||||
// This will not panic on production and it will return the original so we can keep it
|
||||
let routed_kc_base_url = kc_base_url.replace("keycloak", "localhost");
|
||||
let mut url = Url::parse(&config.auth_endpoint())
|
||||
.map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?;
|
||||
|
||||
let token_id = login_data.token_id;
|
||||
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");
|
||||
|
||||
// redirect to Keycloak logout endpoint
|
||||
let logout_url = format!(
|
||||
"{routed_kc_base_url}/realms/{kc_realm}/protocol/openid-connect/logout\
|
||||
?post_logout_redirect_uri={encoded_redirect_uri}\
|
||||
&client_id={kc_client_id}\
|
||||
&id_token_hint={token_id}"
|
||||
);
|
||||
Ok(Redirect::to(&logout_url).into_response())
|
||||
} else {
|
||||
// No id_token in session; just redirect to homepage
|
||||
Ok(Redirect::to(&redirect_uri).into_response())
|
||||
}
|
||||
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}")))
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
use super::error::Result;
|
||||
use super::user::{KeyCloakSub, UserEntity};
|
||||
use mongodb::{bson::doc, Client, Collection};
|
||||
pub struct Database {
|
||||
client: Client,
|
||||
}
|
||||
impl Database {
|
||||
pub async fn new(client: Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
/// Impl of project related DB actions
|
||||
impl Database {}
|
||||
|
||||
/// Impl of user-related actions
|
||||
impl Database {
|
||||
async fn users_collection(&self) -> Collection<UserEntity> {
|
||||
self.client
|
||||
.database("dashboard")
|
||||
.collection::<UserEntity>("users")
|
||||
}
|
||||
|
||||
pub async fn get_user_by_kc_sub(&self, kc_sub: KeyCloakSub) -> Result<Option<UserEntity>> {
|
||||
let c = self.users_collection().await;
|
||||
let result = c
|
||||
.find_one(doc! {
|
||||
"kc_sub" : kc_sub.0
|
||||
})
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn get_user_by_id(&self, user_id: &str) -> Result<Option<UserEntity>> {
|
||||
let c = self.users_collection().await;
|
||||
|
||||
let user_id: mongodb::bson::oid::ObjectId = user_id.parse()?;
|
||||
|
||||
let filter = doc! { "_id" : user_id };
|
||||
let result = c.find_one(filter).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn insert_user(&self, user: &UserEntity) -> Result<()> {
|
||||
let c = self.users_collection().await;
|
||||
let _ = c.insert_one(user).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,78 +1,22 @@
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::response::IntoResponse;
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use crate::Route;
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("{0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("ReqwestError: {0}")]
|
||||
ReqwestError(#[from] reqwest::Error),
|
||||
|
||||
#[error("ServerStateError: {0}")]
|
||||
ServerStateError(String),
|
||||
|
||||
#[error("SessionError: {0}")]
|
||||
SessionError(#[from] tower_sessions::session::Error),
|
||||
|
||||
#[error("AuthSessionLayerNotFound: {0}")]
|
||||
AuthSessionLayerNotFound(String),
|
||||
|
||||
#[error("UserNotLoggedIn")]
|
||||
UserNotLoggedIn,
|
||||
|
||||
#[error("MongoDbError: {0}")]
|
||||
MongoDbError(#[from] mongodb::error::Error),
|
||||
|
||||
#[error("MongoBsonError: {0}")]
|
||||
MongoBsonError(#[from] mongodb::bson::ser::Error),
|
||||
|
||||
#[error("MongoObjectIdParseError: {0}")]
|
||||
MongoObjectIdParseError(#[from] mongodb::bson::oid::Error),
|
||||
StateError(String),
|
||||
|
||||
#[error("IoError: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
#[error("GeneralError: {0}")]
|
||||
GeneralError(String),
|
||||
|
||||
#[error("SerdeError: {0}")]
|
||||
SerdeError(#[from] serde_json::Error),
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
#[tracing::instrument]
|
||||
fn into_response(self) -> Response {
|
||||
let message = self.to_string();
|
||||
tracing::error!("Converting Error to Reponse: {message}");
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let msg = self.to_string();
|
||||
tracing::error!("Converting Error to Response: {msg}");
|
||||
match self {
|
||||
Error::NotFound(_) => (StatusCode::NOT_FOUND, message).into_response(),
|
||||
Error::BadRequest(_) => (StatusCode::BAD_REQUEST, message).into_response(),
|
||||
// ideally we would like to redirect with the original URL as the target, but we do not have access to it here
|
||||
Error::UserNotLoggedIn => Redirect::to(
|
||||
&Route::Login {
|
||||
redirect_url: Route::OverviewPage {}.to_string(),
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
.into_response(),
|
||||
Error::Forbidden(_) => (StatusCode::FORBIDDEN, message).into_response(),
|
||||
|
||||
// INTERNAL_SERVER_ERROR variants
|
||||
_ => {
|
||||
tracing::error!("Internal Server Error: {:?}", message);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, message).into_response()
|
||||
}
|
||||
Self::StateError(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Unknown error").into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
use super::error::Result;
|
||||
use super::user::{KeyCloakSub, UserEntity};
|
||||
use crate::Route;
|
||||
use axum::{
|
||||
extract::Query,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
Extension,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use tracing::{info, warn};
|
||||
use url::form_urlencoded;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CallbackCode {
|
||||
code: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
const LOGIN_REDIRECT_URL_SESSION_KEY: &str = "login.redirect.url";
|
||||
const TEST_USER_SUB: KeyCloakSub = KeyCloakSub(String::new());
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct LoginRedirectQuery {
|
||||
redirect_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Handler that redirects the user to the login page of Keycloack.
|
||||
#[axum::debug_handler]
|
||||
pub async fn redirect_to_keycloack_login(
|
||||
state: Extension<super::server_state::ServerState>,
|
||||
user_session: super::auth::UserSession,
|
||||
session: tower_sessions::Session,
|
||||
query: Query<LoginRedirectQuery>,
|
||||
) -> Result<Response> {
|
||||
// check if already logged in before redirecting again
|
||||
if user_session.data().is_ok() {
|
||||
return Ok(Redirect::to(&Route::OverviewPage {}.to_string()).into_response());
|
||||
}
|
||||
|
||||
if let Some(url) = &query.redirect_url {
|
||||
if !url.is_empty() {
|
||||
session.insert(LOGIN_REDIRECT_URL_SESSION_KEY, &url).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// if this is a test user then skip login
|
||||
if state.keycloak_variables.enable_test_user {
|
||||
return login_test_user(state, session).await;
|
||||
}
|
||||
|
||||
let kc_base_url = &state.keycloak_variables.base_url;
|
||||
let kc_realm = &state.keycloak_variables.realm;
|
||||
let kc_client_id = &state.keycloak_variables.client_id;
|
||||
let redirect_uri = format!("http://localhost:8000/auth/callback");
|
||||
let encoded_redirect_uri: String =
|
||||
form_urlencoded::byte_serialize(redirect_uri.as_bytes()).collect();
|
||||
|
||||
// Needed for running locally.
|
||||
// This will not panic on production and it will return the original so we can keep it
|
||||
let routed_kc_base_url = kc_base_url.replace("keycloak", "localhost");
|
||||
|
||||
Ok(Redirect::to(
|
||||
format!("{routed_kc_base_url}/realms/{kc_realm}/protocol/openid-connect/auth?client_id={kc_client_id}&response_type=code&scope=openid%20profile%20email&redirect_uri={encoded_redirect_uri}").as_str())
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Helper function that automatically logs the user in as a test user.
|
||||
async fn login_test_user(
|
||||
state: Extension<super::server_state::ServerState>,
|
||||
session: tower_sessions::Session,
|
||||
) -> Result<Response> {
|
||||
let user = state.db.get_user_by_kc_sub(TEST_USER_SUB).await?;
|
||||
|
||||
// if we do not have a test user already, create one
|
||||
let user = if let Some(user) = user {
|
||||
info!("Existing test user logged in");
|
||||
user
|
||||
} else {
|
||||
info!("Test User not found, inserting ...");
|
||||
|
||||
let user = UserEntity {
|
||||
_id: mongodb::bson::oid::ObjectId::new(),
|
||||
created_at: mongodb::bson::DateTime::now(),
|
||||
kc_sub: TEST_USER_SUB,
|
||||
email: "exampleuser@domain.com".to_string(),
|
||||
};
|
||||
|
||||
state.db.insert_user(&user).await?;
|
||||
user
|
||||
};
|
||||
|
||||
info!("Test User successfuly logged in: {:?}", user);
|
||||
|
||||
let data = super::auth::LoggedInData {
|
||||
id: user._id.to_string(),
|
||||
token_id: String::new(),
|
||||
username: "tester".to_string(),
|
||||
avatar_url: None,
|
||||
};
|
||||
super::auth::login(&session, &data).await?;
|
||||
|
||||
// redirect to the URL stored in the session if available
|
||||
let redirect_url = session
|
||||
.remove::<String>(LOGIN_REDIRECT_URL_SESSION_KEY)
|
||||
.await?
|
||||
.unwrap_or_else(|| Route::OverviewPage {}.to_string());
|
||||
|
||||
Ok(Redirect::to(&redirect_url).into_response())
|
||||
}
|
||||
|
||||
/// Handler function executed once KC redirects back to us. Creates database entries if
|
||||
/// needed and initializes the user session to mark the user as "logged in".
|
||||
#[axum::debug_handler]
|
||||
pub async fn handle_login_callback(
|
||||
state: Extension<super::server_state::ServerState>,
|
||||
session: tower_sessions::Session,
|
||||
Query(params): Query<CallbackCode>,
|
||||
) -> Result<Response> {
|
||||
// now make sure the user actually authorized the app and that there was no error
|
||||
let Some(code) = params.code else {
|
||||
warn!("Code was not provided, error: {:?}", params.error);
|
||||
return Ok(Redirect::to(&Route::OverviewPage {}.to_string()).into_response());
|
||||
};
|
||||
|
||||
// if on dev environment we get the internal kc url
|
||||
let kc_base_url = std::env::var("KEYCLOAK_ADMIN_URL")
|
||||
.unwrap_or_else(|_| state.keycloak_variables.base_url.clone());
|
||||
let kc_realm = &state.keycloak_variables.realm;
|
||||
let kc_client_id = &state.keycloak_variables.client_id;
|
||||
let kc_client_secret = &state.keycloak_variables.client_secret;
|
||||
let redirect_uri = format!("http://localhost:8000/auth/callback");
|
||||
|
||||
// exchange the code for an access token
|
||||
let token = exchange_code(
|
||||
&code,
|
||||
&kc_base_url,
|
||||
kc_realm,
|
||||
kc_client_id,
|
||||
kc_client_secret,
|
||||
redirect_uri.as_str(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// use the access token to get the user information
|
||||
let user_info = get_user_info(&token, &kc_base_url, kc_realm).await?;
|
||||
|
||||
// Check if the user is a member of the organization (only on dev and demo environments)
|
||||
let base_url = state.keycloak_variables.base_url.clone();
|
||||
let is_for_devs = base_url.contains("dev") || base_url.contains("demo");
|
||||
if is_for_devs {
|
||||
let Some(github_login) = user_info.github_login.as_ref() else {
|
||||
return Err(crate::infrastructure::error::Error::Forbidden(
|
||||
"GitHub login not available.".to_string(),
|
||||
));
|
||||
};
|
||||
if !is_org_member(github_login).await? {
|
||||
return Err(crate::infrastructure::error::Error::Forbidden(
|
||||
"You are not a member of the organization.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// now check if we have a user already
|
||||
let kc_sub = KeyCloakSub(user_info.sub);
|
||||
|
||||
let user = state.db.get_user_by_kc_sub(kc_sub.clone()).await?;
|
||||
|
||||
// if we do not have a user already, create one
|
||||
let user = if let Some(user) = user {
|
||||
info!("Existing user logged in");
|
||||
user
|
||||
} else {
|
||||
info!("User not found, creating ...");
|
||||
|
||||
let user = UserEntity {
|
||||
_id: mongodb::bson::oid::ObjectId::new(),
|
||||
created_at: mongodb::bson::DateTime::now(),
|
||||
kc_sub,
|
||||
email: user_info.email.clone(),
|
||||
};
|
||||
|
||||
state.db.insert_user(&user).await?;
|
||||
user
|
||||
};
|
||||
|
||||
info!("User successfuly logged in");
|
||||
|
||||
// we now have access token and information about the user that just logged in, as well as an
|
||||
// existing or newly created user database entity.
|
||||
// Store information in session storage that we want (eg name and avatar url + databae id) to make the user "logged in"!
|
||||
// Redirect the user somewhere
|
||||
let data = super::auth::LoggedInData {
|
||||
id: user._id.to_string(),
|
||||
token_id: token.id_token,
|
||||
username: user_info.preferred_username,
|
||||
avatar_url: user_info.picture,
|
||||
};
|
||||
super::auth::login(&session, &data).await?;
|
||||
|
||||
// redirect to the URL stored in the session if available
|
||||
let redirect_url = session
|
||||
.remove::<String>(LOGIN_REDIRECT_URL_SESSION_KEY)
|
||||
.await?
|
||||
.unwrap_or_else(|| Route::OverviewPage {}.to_string());
|
||||
|
||||
Ok(Redirect::to(&redirect_url).into_response())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[allow(dead_code)] // not all fields are currently used
|
||||
struct AccessToken {
|
||||
access_token: String,
|
||||
expires_in: u64,
|
||||
refresh_token: String,
|
||||
refresh_expires_in: u64,
|
||||
id_token: String,
|
||||
}
|
||||
|
||||
/// Exchange KC code for an access token
|
||||
async fn exchange_code(
|
||||
code: &str,
|
||||
kc_base_url: &str,
|
||||
kc_realm: &str,
|
||||
kc_client_id: &str,
|
||||
kc_client_secret: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<AccessToken> {
|
||||
let res = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{kc_base_url}/realms/{kc_realm}/protocol/openid-connect/token",
|
||||
))
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("client_id", kc_client_id),
|
||||
("client_secret", kc_client_secret),
|
||||
("code", code),
|
||||
("redirect_uri", redirect_uri),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let res: AccessToken = res.json().await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Query the openid-connect endpoint to get the user info by using the access token.
|
||||
async fn get_user_info(token: &AccessToken, kc_base_url: &str, kc_realm: &str) -> Result<UserInfo> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{kc_base_url}/realms/{kc_realm}/protocol/openid-connect/userinfo");
|
||||
|
||||
let mut request = client.get(&url).bearer_auth(token.access_token.clone());
|
||||
|
||||
// If KEYCLOAK_ADMIN_URL is NOT set (i.e. we're on the local Keycloak),
|
||||
// add the HOST header for local testing.
|
||||
if std::env::var("KEYCLOAK_ADMIN_URL").is_err() {
|
||||
request = request.header("HOST", "localhost:8888");
|
||||
}
|
||||
|
||||
let res = request.send().await?;
|
||||
let res: UserInfo = res.json().await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Contains selected fields from the user information call to KC
|
||||
/// https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
|
||||
#[derive(serde::Deserialize)]
|
||||
#[allow(dead_code)] // not all fields are currently used
|
||||
struct UserInfo {
|
||||
sub: String, // subject element of the ID Token
|
||||
name: String,
|
||||
given_name: String,
|
||||
family_name: String,
|
||||
preferred_username: String,
|
||||
email: String,
|
||||
picture: Option<String>,
|
||||
github_login: Option<String>,
|
||||
}
|
||||
|
||||
/// Check if a user is a member of the organization
|
||||
const GITHUB_ORG: &str = "etospheres-labs";
|
||||
async fn is_org_member(username: &str) -> Result<bool> {
|
||||
let url = format!("https://api.github.com/orgs/{GITHUB_ORG}/members/{username}");
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json") // GitHub requires a User-Agent header.
|
||||
.header("User-Agent", "etopay-app")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
match response.status() {
|
||||
StatusCode::NO_CONTENT => Ok(true),
|
||||
status => {
|
||||
tracing::warn!(
|
||||
"{}: User '{}' is not a member of the organization",
|
||||
status.as_str(),
|
||||
username
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
#![cfg(feature = "server")]
|
||||
mod auth;
|
||||
mod error;
|
||||
mod server;
|
||||
mod state;
|
||||
|
||||
mod login;
|
||||
|
||||
pub mod auth;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod server;
|
||||
pub mod server_state;
|
||||
pub mod user;
|
||||
pub use auth::*;
|
||||
pub use error::*;
|
||||
pub use server::*;
|
||||
pub use state::*;
|
||||
|
||||
@@ -1,105 +1,56 @@
|
||||
use super::error::Error;
|
||||
use super::server_state::ServerState;
|
||||
use crate::infrastructure::{auth::KeycloakVariables, server_state::ServerStateInner};
|
||||
use crate::infrastructure::{
|
||||
auth_callback, auth_login, logout, PendingOAuthStore, UserState, UserStateInner,
|
||||
};
|
||||
|
||||
use axum::{routing::*, Extension};
|
||||
use dioxus::dioxus_core::Element;
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_logger::tracing::info;
|
||||
use reqwest::{
|
||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||
Method,
|
||||
};
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::Extension;
|
||||
use time::Duration;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_sessions::{
|
||||
cookie::{Key, SameSite},
|
||||
Expiry, MemoryStore, SessionManagerLayer,
|
||||
};
|
||||
|
||||
pub fn server_start(app_fn: fn() -> Element) -> Result<(), Error> {
|
||||
dotenvy::dotenv().ok();
|
||||
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 {
|
||||
info!("Connecting to the database ...");
|
||||
|
||||
let mongodb_uri = get_env_variable("MONGODB_URI");
|
||||
let client = mongodb::Client::with_uri_str(mongodb_uri).await?;
|
||||
|
||||
let db = super::db::Database::new(client).await;
|
||||
info!("Connected");
|
||||
|
||||
let keycloak_variables: KeycloakVariables = KeycloakVariables {
|
||||
base_url: get_env_variable("BASE_URL_AUTH"),
|
||||
realm: get_env_variable("KC_REALM"),
|
||||
client_id: get_env_variable("KC_CLIENT_ID"),
|
||||
client_secret: get_env_variable("KC_CLIENT_SECRET"),
|
||||
enable_test_user: std::env::var("ENABLE_TEST_USER").is_ok_and(|v| v == "yes"),
|
||||
};
|
||||
|
||||
let state: ServerState = ServerStateInner {
|
||||
db,
|
||||
keycloak_variables: Box::leak(Box::new(keycloak_variables)),
|
||||
let state: UserState = UserStateInner {
|
||||
access_token: "abcd".into(),
|
||||
sub: "abcd".into(),
|
||||
refresh_token: "abcd".into(),
|
||||
..Default::default()
|
||||
}
|
||||
.into();
|
||||
|
||||
// This uses `tower-sessions` to establish a layer that will provide the session
|
||||
// as a request extension.
|
||||
let key = Key::generate(); // This is only used for demonstration purposes; provide a proper
|
||||
// cryptographic key in a real application.
|
||||
let session_store = MemoryStore::default();
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
// only allow session cookie in HTTPS connections (also works on localhost)
|
||||
.with_secure(true)
|
||||
.with_expiry(Expiry::OnInactivity(Duration::days(1)))
|
||||
// Allow the session cookie to be sent when request originates from outside our
|
||||
// domain. Required for the browser to pass the cookie when returning from github auth page.
|
||||
.with_same_site(SameSite::Lax)
|
||||
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 cors = CorsLayer::new()
|
||||
// allow `GET` and `POST` when accessing the resource
|
||||
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
|
||||
// .allow_credentials(true)
|
||||
.allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE])
|
||||
// allow requests from any origin
|
||||
.allow_origin(Any);
|
||||
|
||||
// Build our application web api router.
|
||||
let web_api_router = Router::new()
|
||||
// .route("/webhook/gitlab", post(super::gitlab::webhook_handler))
|
||||
.route("/auth", get(super::login::redirect_to_keycloack_login))
|
||||
.route("/auth/logout", get(super::auth::logout))
|
||||
.route("/auth/callback", get(super::login::handle_login_callback))
|
||||
// Server side render the application, serve static assets, and register the server functions.
|
||||
.serve_dioxus_application(ServeConfig::default(), app_fn)
|
||||
.layer(Extension(state))
|
||||
.layer(session_layer)
|
||||
.layer(cors)
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http());
|
||||
|
||||
// Start it.
|
||||
let addr = dioxus_cli_config::fullstack_address_or_localhost();
|
||||
info!("Server address: {}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
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?;
|
||||
|
||||
axum::serve(listener, web_api_router.into_make_service()).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Tries to load the value from an environment as String.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - the environment variable key to try to load
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the environment variable does not exist.
|
||||
fn get_env_variable(key: &str) -> String {
|
||||
std::env::var(key).unwrap_or_else(|_| {
|
||||
tracing::error!("{key} environment variable not set. {key} must be set!");
|
||||
panic!("Environment variable {key} not present")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
//! Implements a [`ServerState`] that is available in the dioxus server functions
|
||||
//! as well as in axum handlers.
|
||||
//! Taken from https://github.com/dxps/dioxus_playground/tree/44a4ddb223e6afe50ef195e61aa2b7182762c7da/dioxus-05-fullstack-routing-axum-pgdb
|
||||
|
||||
use super::auth::KeycloakVariables;
|
||||
use super::error::{Error, Result};
|
||||
|
||||
use axum::http;
|
||||
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// This is stored as an "extension" object in the axum webserver
|
||||
/// We can get it in the dioxus server functions using
|
||||
/// ```rust
|
||||
/// let state: crate::infrastructure::server_state::ServerState = extract().await?;
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct ServerState(Arc<ServerStateInner>);
|
||||
|
||||
impl Deref for ServerState {
|
||||
type Target = ServerStateInner;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ServerStateInner {
|
||||
pub db: crate::infrastructure::db::Database,
|
||||
pub keycloak_variables: &'static KeycloakVariables,
|
||||
}
|
||||
|
||||
impl From<ServerStateInner> for ServerState {
|
||||
fn from(value: ServerStateInner) -> Self {
|
||||
Self(Arc::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> axum::extract::FromRequestParts<S> for ServerState
|
||||
where
|
||||
S: std::marker::Sync + std::marker::Send,
|
||||
{
|
||||
type Rejection = Error;
|
||||
|
||||
async fn from_request_parts(parts: &mut http::request::Parts, _: &S) -> Result<Self> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<ServerState>()
|
||||
.cloned()
|
||||
.ok_or(Error::ServerStateError(
|
||||
"ServerState extension should exist".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
57
src/infrastructure/state.rs
Normal file
57
src/infrastructure/state.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Wraps a `String` to store the sub from KC
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeyCloakSub(pub String);
|
||||
|
||||
/// database entity to store our users
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UserEntity {
|
||||
/// Our unique id of the user, for now this is just the mongodb assigned id
|
||||
pub _id: mongodb::bson::oid::ObjectId,
|
||||
|
||||
/// Time the user was created
|
||||
pub created_at: mongodb::bson::DateTime,
|
||||
|
||||
/// KC subject element of the ID Token
|
||||
pub kc_sub: KeyCloakSub,
|
||||
|
||||
/// User email as provided during signup with the identity provider
|
||||
pub email: String,
|
||||
}
|
||||
@@ -1,8 +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::*;
|
||||
|
||||
71
src/models/chat.rs
Normal file
71
src/models/chat.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The role of a participant in a chat conversation.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ChatRole {
|
||||
/// Message sent by the human user
|
||||
User,
|
||||
/// Message generated by the AI assistant
|
||||
Assistant,
|
||||
/// System-level instruction (not displayed in UI)
|
||||
System,
|
||||
}
|
||||
|
||||
/// The type of file attached to a chat message.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AttachmentKind {
|
||||
/// Image file (png, jpg, webp, etc.)
|
||||
Image,
|
||||
/// Document file (pdf, docx, txt, etc.)
|
||||
Document,
|
||||
/// Source code file
|
||||
Code,
|
||||
}
|
||||
|
||||
/// A file attachment on a chat message.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `name` - Original filename
|
||||
/// * `kind` - Type of attachment for rendering
|
||||
/// * `size_bytes` - File size in bytes
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Attachment {
|
||||
pub name: String,
|
||||
pub kind: AttachmentKind,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
/// A single message in a chat conversation.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique message identifier
|
||||
/// * `role` - Who sent this message
|
||||
/// * `content` - The message text content
|
||||
/// * `attachments` - Optional file attachments
|
||||
/// * `timestamp` - ISO 8601 timestamp string
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub id: String,
|
||||
pub role: ChatRole,
|
||||
pub content: String,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// A chat session containing a conversation history.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique session identifier
|
||||
/// * `title` - Display title (usually derived from first message)
|
||||
/// * `messages` - Ordered list of messages in the session
|
||||
/// * `created_at` - ISO 8601 creation timestamp
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChatSession {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub created_at: String,
|
||||
}
|
||||
47
src/models/developer.rs
Normal file
47
src/models/developer.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An AI agent entry managed through the developer tools.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique agent identifier
|
||||
/// * `name` - Human-readable agent name
|
||||
/// * `description` - What this agent does
|
||||
/// * `status` - Current running status label
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// A workflow/flow entry from the flow builder.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique flow identifier
|
||||
/// * `name` - Human-readable flow name
|
||||
/// * `node_count` - Number of nodes in the flow graph
|
||||
/// * `last_run` - ISO 8601 timestamp of the last execution
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FlowEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub node_count: u32,
|
||||
pub last_run: Option<String>,
|
||||
}
|
||||
|
||||
/// A single analytics metric for the developer dashboard.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `label` - Display name of the metric
|
||||
/// * `value` - Current value as a formatted string
|
||||
/// * `change_pct` - Percentage change from previous period (positive = increase)
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnalyticsMetric {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
pub change_pct: f64,
|
||||
}
|
||||
60
src/models/knowledge.rs
Normal file
60
src/models/knowledge.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The type of file stored in the knowledge base.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum FileKind {
|
||||
/// PDF document
|
||||
Pdf,
|
||||
/// Plain text or markdown file
|
||||
Text,
|
||||
/// Spreadsheet (csv, xlsx)
|
||||
Spreadsheet,
|
||||
/// Source code file
|
||||
Code,
|
||||
/// Image file
|
||||
Image,
|
||||
}
|
||||
|
||||
impl FileKind {
|
||||
/// Returns the display label for a file kind.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pdf => "PDF",
|
||||
Self::Text => "Text",
|
||||
Self::Spreadsheet => "Spreadsheet",
|
||||
Self::Code => "Code",
|
||||
Self::Image => "Image",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an icon identifier for rendering.
|
||||
pub fn icon(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pdf => "file-pdf",
|
||||
Self::Text => "file-text",
|
||||
Self::Spreadsheet => "file-spreadsheet",
|
||||
Self::Code => "file-code",
|
||||
Self::Image => "file-image",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file stored in the knowledge base for RAG retrieval.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique file identifier
|
||||
/// * `name` - Original filename
|
||||
/// * `kind` - Type classification of the file
|
||||
/// * `size_bytes` - File size in bytes
|
||||
/// * `uploaded_at` - ISO 8601 upload timestamp
|
||||
/// * `chunk_count` - Number of vector chunks created from this file
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KnowledgeFile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: FileKind,
|
||||
pub size_bytes: u64,
|
||||
pub uploaded_at: String,
|
||||
pub chunk_count: u32,
|
||||
}
|
||||
17
src/models/mod.rs
Normal file
17
src/models/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
mod chat;
|
||||
mod developer;
|
||||
mod knowledge;
|
||||
mod news;
|
||||
mod organization;
|
||||
mod provider;
|
||||
mod tool;
|
||||
mod user;
|
||||
|
||||
pub use chat::*;
|
||||
pub use developer::*;
|
||||
pub use knowledge::*;
|
||||
pub use news::*;
|
||||
pub use organization::*;
|
||||
pub use provider::*;
|
||||
pub use tool::*;
|
||||
pub use user::*;
|
||||
62
src/models/news.rs
Normal file
62
src/models/news.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Categories for classifying AI news articles.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum NewsCategory {
|
||||
/// Large language model announcements and updates
|
||||
Llm,
|
||||
/// AI agent frameworks and tooling
|
||||
Agents,
|
||||
/// Data privacy and regulatory compliance
|
||||
Privacy,
|
||||
/// AI infrastructure and deployment
|
||||
Infrastructure,
|
||||
/// Open-source AI project releases
|
||||
OpenSource,
|
||||
}
|
||||
|
||||
impl NewsCategory {
|
||||
/// Returns the display label for a news category.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Llm => "LLM",
|
||||
Self::Agents => "Agents",
|
||||
Self::Privacy => "Privacy",
|
||||
Self::Infrastructure => "Infrastructure",
|
||||
Self::OpenSource => "Open Source",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the CSS class suffix for styling category badges.
|
||||
pub fn css_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Llm => "llm",
|
||||
Self::Agents => "agents",
|
||||
Self::Privacy => "privacy",
|
||||
Self::Infrastructure => "infrastructure",
|
||||
Self::OpenSource => "open-source",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single news feed card representing an AI-related article.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `title` - Headline of the article
|
||||
/// * `source` - Publishing outlet or author
|
||||
/// * `summary` - Brief summary text
|
||||
/// * `category` - Classification category
|
||||
/// * `url` - Link to the full article
|
||||
/// * `thumbnail_url` - Optional thumbnail image URL
|
||||
/// * `published_at` - ISO 8601 date string
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NewsCard {
|
||||
pub title: String,
|
||||
pub source: String,
|
||||
pub summary: String,
|
||||
pub category: NewsCategory,
|
||||
pub url: String,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub published_at: String,
|
||||
}
|
||||
84
src/models/organization.rs
Normal file
84
src/models/organization.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Role assigned to an organization member.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum MemberRole {
|
||||
/// Full administrative access
|
||||
Admin,
|
||||
/// Standard user access
|
||||
Member,
|
||||
/// Read-only access
|
||||
Viewer,
|
||||
}
|
||||
|
||||
impl MemberRole {
|
||||
/// Returns the display label for a member role.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Admin => "Admin",
|
||||
Self::Member => "Member",
|
||||
Self::Viewer => "Viewer",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all available roles for populating dropdowns.
|
||||
pub fn all() -> &'static [Self] {
|
||||
&[Self::Admin, Self::Member, Self::Viewer]
|
||||
}
|
||||
}
|
||||
|
||||
/// A member of the organization.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique member identifier
|
||||
/// * `name` - Display name
|
||||
/// * `email` - Email address
|
||||
/// * `role` - Assigned role within the organization
|
||||
/// * `joined_at` - ISO 8601 join date
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OrgMember {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub role: MemberRole,
|
||||
pub joined_at: String,
|
||||
}
|
||||
|
||||
/// A pricing plan tier.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique plan identifier
|
||||
/// * `name` - Plan display name (e.g. "Starter", "Team", "Enterprise")
|
||||
/// * `price_eur` - Monthly price in euros
|
||||
/// * `features` - List of included features
|
||||
/// * `highlighted` - Whether this plan should be visually emphasized
|
||||
/// * `max_seats` - Maximum number of seats, None for unlimited
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PricingPlan {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub price_eur: u32,
|
||||
pub features: Vec<String>,
|
||||
pub highlighted: bool,
|
||||
pub max_seats: Option<u32>,
|
||||
}
|
||||
|
||||
/// Billing usage statistics for the current cycle.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `seats_used` - Number of active seats
|
||||
/// * `seats_total` - Total seats in the plan
|
||||
/// * `tokens_used` - Tokens consumed this billing cycle
|
||||
/// * `tokens_limit` - Token limit for the billing cycle
|
||||
/// * `billing_cycle_end` - ISO 8601 date when the current cycle ends
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BillingUsage {
|
||||
pub seats_used: u32,
|
||||
pub seats_total: u32,
|
||||
pub tokens_used: u64,
|
||||
pub tokens_limit: u64,
|
||||
pub billing_cycle_end: String,
|
||||
}
|
||||
74
src/models/provider.rs
Normal file
74
src/models/provider.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Supported LLM provider backends.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum LlmProvider {
|
||||
/// Self-hosted models via Ollama
|
||||
Ollama,
|
||||
/// Hugging Face Inference API
|
||||
HuggingFace,
|
||||
/// OpenAI-compatible endpoints
|
||||
OpenAi,
|
||||
/// Anthropic Claude API
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
impl LlmProvider {
|
||||
/// Returns the display name for a provider.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ollama => "Ollama",
|
||||
Self::HuggingFace => "Hugging Face",
|
||||
Self::OpenAi => "OpenAI",
|
||||
Self::Anthropic => "Anthropic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A model available from a provider.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique model identifier (e.g. "llama3.1:8b")
|
||||
/// * `name` - Human-readable display name
|
||||
/// * `provider` - Which provider hosts this model
|
||||
/// * `context_window` - Maximum context length in tokens
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ModelEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub provider: LlmProvider,
|
||||
pub context_window: u32,
|
||||
}
|
||||
|
||||
/// An embedding model available from a provider.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique embedding model identifier
|
||||
/// * `name` - Human-readable display name
|
||||
/// * `provider` - Which provider hosts this model
|
||||
/// * `dimensions` - Output embedding dimensions
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbeddingEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub provider: LlmProvider,
|
||||
pub dimensions: u32,
|
||||
}
|
||||
|
||||
/// Active provider configuration state.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `provider` - Currently selected provider
|
||||
/// * `selected_model` - ID of the active chat model
|
||||
/// * `selected_embedding` - ID of the active embedding model
|
||||
/// * `api_key_set` - Whether an API key has been configured
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub provider: LlmProvider,
|
||||
pub selected_model: String,
|
||||
pub selected_embedding: String,
|
||||
pub api_key_set: bool,
|
||||
}
|
||||
73
src/models/tool.rs
Normal file
73
src/models/tool.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Category grouping for MCP tools.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ToolCategory {
|
||||
/// Web search and browsing tools
|
||||
Search,
|
||||
/// File and document processing tools
|
||||
FileSystem,
|
||||
/// Computation and math tools
|
||||
Compute,
|
||||
/// Code execution and analysis tools
|
||||
Code,
|
||||
/// Communication and notification tools
|
||||
Communication,
|
||||
}
|
||||
|
||||
impl ToolCategory {
|
||||
/// Returns the display label for a tool category.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Search => "Search",
|
||||
Self::FileSystem => "File System",
|
||||
Self::Compute => "Compute",
|
||||
Self::Code => "Code",
|
||||
Self::Communication => "Communication",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of an MCP tool instance.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ToolStatus {
|
||||
/// Tool is running and available
|
||||
Active,
|
||||
/// Tool is installed but not running
|
||||
Inactive,
|
||||
/// Tool encountered an error
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ToolStatus {
|
||||
/// Returns the CSS class suffix for status styling.
|
||||
pub fn css_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::Inactive => "inactive",
|
||||
Self::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An MCP (Model Context Protocol) tool entry.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique tool identifier
|
||||
/// * `name` - Human-readable display name
|
||||
/// * `description` - Brief description of what the tool does
|
||||
/// * `category` - Classification category
|
||||
/// * `status` - Current running status
|
||||
/// * `enabled` - Whether the tool is toggled on by the user
|
||||
/// * `icon` - Icon identifier for rendering
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct McpTool {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub category: ToolCategory,
|
||||
pub status: ToolStatus,
|
||||
pub enabled: bool,
|
||||
pub icon: String,
|
||||
}
|
||||
21
src/models/user.rs
Normal file
21
src/models/user.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
145
src/pages/chat.rs
Normal file
145
src/pages/chat.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::ChatBubble;
|
||||
use crate::models::{ChatMessage, ChatRole, ChatSession};
|
||||
|
||||
/// ChatGPT-style chat interface with session list and message area.
|
||||
///
|
||||
/// Full-height layout: left panel shows session history,
|
||||
/// right panel shows messages and input bar.
|
||||
#[component]
|
||||
pub fn ChatPage() -> Element {
|
||||
let sessions = use_signal(mock_sessions);
|
||||
let mut active_session_id = use_signal(|| "session-1".to_string());
|
||||
let mut input_text = use_signal(String::new);
|
||||
|
||||
// Clone data out of signals before entering the rsx! block to avoid
|
||||
// holding a `Signal::read()` borrow across potential await points.
|
||||
let sessions_list = sessions.read().clone();
|
||||
let current_id = active_session_id.read().clone();
|
||||
let active_session = sessions_list.iter().find(|s| s.id == current_id).cloned();
|
||||
|
||||
rsx! {
|
||||
section { class: "chat-page",
|
||||
div { class: "chat-sidebar-panel",
|
||||
div { class: "chat-sidebar-header",
|
||||
h3 { "Conversations" }
|
||||
button { class: "btn-icon", "+" }
|
||||
}
|
||||
div { class: "chat-session-list",
|
||||
for session in &sessions_list {
|
||||
{
|
||||
let is_active = session.id == current_id;
|
||||
let class = if is_active {
|
||||
"chat-session-item chat-session-item--active"
|
||||
} else {
|
||||
"chat-session-item"
|
||||
};
|
||||
let id = session.id.clone();
|
||||
rsx! {
|
||||
button { class: "{class}", onclick: move |_| active_session_id.set(id.clone()),
|
||||
div { class: "chat-session-title", "{session.title}" }
|
||||
div { class: "chat-session-date", "{session.created_at}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "chat-main-panel",
|
||||
if let Some(session) = &active_session {
|
||||
div { class: "chat-messages",
|
||||
for msg in &session.messages {
|
||||
ChatBubble { key: "{msg.id}", message: msg.clone() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
div { class: "chat-empty",
|
||||
p { "Select a conversation or start a new one." }
|
||||
}
|
||||
}
|
||||
div { class: "chat-input-bar",
|
||||
button { class: "btn-icon chat-attach-btn", "+" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "text",
|
||||
placeholder: "Type a message...",
|
||||
value: "{input_text}",
|
||||
oninput: move |evt: Event<FormData>| {
|
||||
input_text.set(evt.value());
|
||||
},
|
||||
}
|
||||
button { class: "btn-primary chat-send-btn", "Send" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock chat sessions with sample messages.
|
||||
fn mock_sessions() -> Vec<ChatSession> {
|
||||
vec![
|
||||
ChatSession {
|
||||
id: "session-1".into(),
|
||||
title: "RAG Pipeline Setup".into(),
|
||||
messages: vec![
|
||||
ChatMessage {
|
||||
id: "msg-1".into(),
|
||||
role: ChatRole::User,
|
||||
content: "How do I set up a RAG pipeline with Ollama?".into(),
|
||||
attachments: vec![],
|
||||
timestamp: "10:30".into(),
|
||||
},
|
||||
ChatMessage {
|
||||
id: "msg-2".into(),
|
||||
role: ChatRole::Assistant,
|
||||
content: "To set up a RAG pipeline with Ollama, you'll need to: \
|
||||
1) Install Ollama and pull your preferred model, \
|
||||
2) Set up a vector database (e.g. ChromaDB), \
|
||||
3) Create an embedding pipeline for your documents, \
|
||||
4) Wire the retrieval step into your prompt chain."
|
||||
.into(),
|
||||
attachments: vec![],
|
||||
timestamp: "10:31".into(),
|
||||
},
|
||||
],
|
||||
created_at: "2026-02-18".into(),
|
||||
},
|
||||
ChatSession {
|
||||
id: "session-2".into(),
|
||||
title: "GDPR Compliance Check".into(),
|
||||
messages: vec![
|
||||
ChatMessage {
|
||||
id: "msg-3".into(),
|
||||
role: ChatRole::User,
|
||||
content: "What data does CERTifAI store about users?".into(),
|
||||
attachments: vec![],
|
||||
timestamp: "09:15".into(),
|
||||
},
|
||||
ChatMessage {
|
||||
id: "msg-4".into(),
|
||||
role: ChatRole::Assistant,
|
||||
content: "CERTifAI stores only the minimum data required: \
|
||||
email address, session tokens, and usage metrics. \
|
||||
All data stays on your infrastructure."
|
||||
.into(),
|
||||
attachments: vec![],
|
||||
timestamp: "09:16".into(),
|
||||
},
|
||||
],
|
||||
created_at: "2026-02-17".into(),
|
||||
},
|
||||
ChatSession {
|
||||
id: "session-3".into(),
|
||||
title: "MCP Server Configuration".into(),
|
||||
messages: vec![ChatMessage {
|
||||
id: "msg-5".into(),
|
||||
role: ChatRole::User,
|
||||
content: "How do I add a new MCP server?".into(),
|
||||
attachments: vec![],
|
||||
timestamp: "14:00".into(),
|
||||
}],
|
||||
created_at: "2026-02-16".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
67
src/pages/dashboard.rs
Normal file
67
src/pages/dashboard.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{NewsCardView, PageHeader};
|
||||
use crate::models::NewsCategory;
|
||||
|
||||
/// Dashboard page displaying an AI news feed grid with category filters.
|
||||
///
|
||||
/// Replaces the previous `OverviewPage`. Shows mock news items
|
||||
/// that will eventually be sourced from the SearXNG instance.
|
||||
#[component]
|
||||
pub fn DashboardPage() -> Element {
|
||||
let news = use_signal(crate::components::news_card::mock_news);
|
||||
let mut active_filter = use_signal(|| Option::<NewsCategory>::None);
|
||||
|
||||
// Collect filtered news items based on active category filter
|
||||
let filtered: Vec<_> = {
|
||||
let items = news.read();
|
||||
let filter = active_filter.read();
|
||||
match &*filter {
|
||||
Some(cat) => items
|
||||
.iter()
|
||||
.filter(|n| n.category == *cat)
|
||||
.cloned()
|
||||
.collect(),
|
||||
None => items.clone(),
|
||||
}
|
||||
};
|
||||
|
||||
// All available filter categories
|
||||
let categories = [
|
||||
("All", None),
|
||||
("LLM", Some(NewsCategory::Llm)),
|
||||
("Agents", Some(NewsCategory::Agents)),
|
||||
("Privacy", Some(NewsCategory::Privacy)),
|
||||
("Infrastructure", Some(NewsCategory::Infrastructure)),
|
||||
("Open Source", Some(NewsCategory::OpenSource)),
|
||||
];
|
||||
|
||||
rsx! {
|
||||
section { class: "dashboard-page",
|
||||
PageHeader {
|
||||
title: "Dashboard".to_string(),
|
||||
subtitle: "AI news and updates".to_string(),
|
||||
}
|
||||
div { class: "dashboard-filters",
|
||||
for (label , cat) in categories {
|
||||
{
|
||||
let is_active = *active_filter.read() == cat;
|
||||
let class = if is_active {
|
||||
"filter-tab filter-tab--active"
|
||||
} else {
|
||||
"filter-tab"
|
||||
};
|
||||
rsx! {
|
||||
button { class: "{class}", onclick: move |_| active_filter.set(cat.clone()), "{label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "news-grid",
|
||||
for card in filtered {
|
||||
NewsCardView { key: "{card.title}", card }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/pages/developer/agents.rs
Normal file
24
src/pages/developer/agents.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Agents page placeholder for the LangGraph agent builder.
|
||||
///
|
||||
/// Shows a "Coming Soon" card with a disabled launch button.
|
||||
/// Will eventually integrate with the LangGraph framework.
|
||||
#[component]
|
||||
pub fn AgentsPage() -> Element {
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "A" }
|
||||
h2 { "Agent Builder" }
|
||||
p { class: "placeholder-desc",
|
||||
"Build and manage AI agents with LangGraph. \
|
||||
Create multi-step reasoning pipelines, tool-using agents, \
|
||||
and autonomous workflows."
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "Launch Agent Builder" }
|
||||
span { class: "placeholder-badge", "Coming Soon" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/pages/developer/analytics.rs
Normal file
65
src/pages/developer/analytics.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::models::AnalyticsMetric;
|
||||
|
||||
/// Analytics page placeholder for LangFuse integration.
|
||||
///
|
||||
/// Shows a "Coming Soon" card with a disabled launch button,
|
||||
/// plus a mock stats bar showing sample metrics.
|
||||
#[component]
|
||||
pub fn AnalyticsPage() -> Element {
|
||||
let metrics = mock_metrics();
|
||||
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "analytics-stats-bar",
|
||||
for metric in &metrics {
|
||||
div { class: "analytics-stat",
|
||||
span { class: "analytics-stat-value", "{metric.value}" }
|
||||
span { class: "analytics-stat-label", "{metric.label}" }
|
||||
span { class: if metric.change_pct >= 0.0 { "analytics-stat-change analytics-stat-change--up" } else { "analytics-stat-change analytics-stat-change--down" },
|
||||
"{metric.change_pct:+.1}%"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "L" }
|
||||
h2 { "Analytics & Observability" }
|
||||
p { class: "placeholder-desc",
|
||||
"Monitor and analyze your AI pipelines with LangFuse. \
|
||||
Track token usage, latency, costs, and quality metrics \
|
||||
across all your deployments."
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "Launch LangFuse" }
|
||||
span { class: "placeholder-badge", "Coming Soon" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock analytics metrics for the stats bar.
|
||||
fn mock_metrics() -> Vec<AnalyticsMetric> {
|
||||
vec![
|
||||
AnalyticsMetric {
|
||||
label: "Total Requests".into(),
|
||||
value: "12,847".into(),
|
||||
change_pct: 14.2,
|
||||
},
|
||||
AnalyticsMetric {
|
||||
label: "Avg Latency".into(),
|
||||
value: "245ms".into(),
|
||||
change_pct: -8.5,
|
||||
},
|
||||
AnalyticsMetric {
|
||||
label: "Tokens Used".into(),
|
||||
value: "2.4M".into(),
|
||||
change_pct: 22.1,
|
||||
},
|
||||
AnalyticsMetric {
|
||||
label: "Error Rate".into(),
|
||||
value: "0.3%".into(),
|
||||
change_pct: -12.0,
|
||||
},
|
||||
]
|
||||
}
|
||||
24
src/pages/developer/flow.rs
Normal file
24
src/pages/developer/flow.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Flow page placeholder for the LangFlow visual workflow builder.
|
||||
///
|
||||
/// Shows a "Coming Soon" card with a disabled launch button.
|
||||
/// Will eventually integrate with LangFlow for visual flow design.
|
||||
#[component]
|
||||
pub fn FlowPage() -> Element {
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "F" }
|
||||
h2 { "Flow Builder" }
|
||||
p { class: "placeholder-desc",
|
||||
"Design visual AI workflows with LangFlow. \
|
||||
Drag-and-drop nodes to create data processing pipelines, \
|
||||
prompt chains, and integration flows."
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "Launch Flow Builder" }
|
||||
span { class: "placeholder-badge", "Coming Soon" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/pages/developer/mod.rs
Normal file
41
src/pages/developer/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
mod agents;
|
||||
mod analytics;
|
||||
mod flow;
|
||||
|
||||
pub use agents::*;
|
||||
pub use analytics::*;
|
||||
pub use flow::*;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::sub_nav::{SubNav, SubNavItem};
|
||||
|
||||
/// Shell layout for the Developer section.
|
||||
///
|
||||
/// Renders a horizontal tab bar (Agents, Flow, Analytics) above
|
||||
/// the child route outlet. Sits inside the main `AppShell` layout.
|
||||
#[component]
|
||||
pub fn DeveloperShell() -> Element {
|
||||
let tabs = vec![
|
||||
SubNavItem {
|
||||
label: "Agents",
|
||||
route: Route::AgentsPage {},
|
||||
},
|
||||
SubNavItem {
|
||||
label: "Flow",
|
||||
route: Route::FlowPage {},
|
||||
},
|
||||
SubNavItem {
|
||||
label: "Analytics",
|
||||
route: Route::AnalyticsPage {},
|
||||
},
|
||||
];
|
||||
|
||||
rsx! {
|
||||
div { class: "developer-shell",
|
||||
SubNav { items: tabs }
|
||||
div { class: "shell-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
74
src/pages/impressum.rs
Normal file
74
src/pages/impressum.rs
Normal 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/pages/knowledge.rs
Normal file
124
src/pages/knowledge.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{FileRow, PageHeader};
|
||||
use crate::models::{FileKind, KnowledgeFile};
|
||||
|
||||
/// Knowledge Base page with file explorer table and upload controls.
|
||||
///
|
||||
/// Displays uploaded documents used for RAG retrieval with their
|
||||
/// metadata, chunk counts, and management actions.
|
||||
#[component]
|
||||
pub fn KnowledgePage() -> Element {
|
||||
let mut files = use_signal(mock_files);
|
||||
let mut search_query = use_signal(String::new);
|
||||
|
||||
// Filter files by search query (case-insensitive name match)
|
||||
let query = search_query.read().to_lowercase();
|
||||
let filtered: Vec<_> = files
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Remove a file by ID
|
||||
let on_delete = move |id: String| {
|
||||
files.write().retain(|f| f.id != id);
|
||||
};
|
||||
|
||||
rsx! {
|
||||
section { class: "knowledge-page",
|
||||
PageHeader {
|
||||
title: "Knowledge Base".to_string(),
|
||||
subtitle: "Manage documents for RAG retrieval".to_string(),
|
||||
actions: rsx! {
|
||||
button { class: "btn-primary", "Upload File" }
|
||||
},
|
||||
}
|
||||
div { class: "knowledge-toolbar",
|
||||
input {
|
||||
class: "form-input knowledge-search",
|
||||
r#type: "text",
|
||||
placeholder: "Search files...",
|
||||
value: "{search_query}",
|
||||
oninput: move |evt: Event<FormData>| {
|
||||
search_query.set(evt.value());
|
||||
},
|
||||
}
|
||||
}
|
||||
div { class: "knowledge-table-wrapper",
|
||||
table { class: "knowledge-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Type" }
|
||||
th { "Size" }
|
||||
th { "Chunks" }
|
||||
th { "Uploaded" }
|
||||
th { "Actions" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for file in filtered {
|
||||
FileRow { key: "{file.id}", file, on_delete }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock knowledge base files.
|
||||
fn mock_files() -> Vec<KnowledgeFile> {
|
||||
vec![
|
||||
KnowledgeFile {
|
||||
id: "f1".into(),
|
||||
name: "company-handbook.pdf".into(),
|
||||
kind: FileKind::Pdf,
|
||||
size_bytes: 2_450_000,
|
||||
uploaded_at: "2026-02-15".into(),
|
||||
chunk_count: 142,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f2".into(),
|
||||
name: "api-reference.md".into(),
|
||||
kind: FileKind::Text,
|
||||
size_bytes: 89_000,
|
||||
uploaded_at: "2026-02-14".into(),
|
||||
chunk_count: 34,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f3".into(),
|
||||
name: "sales-data-q4.csv".into(),
|
||||
kind: FileKind::Spreadsheet,
|
||||
size_bytes: 1_200_000,
|
||||
uploaded_at: "2026-02-12".into(),
|
||||
chunk_count: 67,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f4".into(),
|
||||
name: "deployment-guide.pdf".into(),
|
||||
kind: FileKind::Pdf,
|
||||
size_bytes: 540_000,
|
||||
uploaded_at: "2026-02-10".into(),
|
||||
chunk_count: 28,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f5".into(),
|
||||
name: "onboarding-checklist.md".into(),
|
||||
kind: FileKind::Text,
|
||||
size_bytes: 12_000,
|
||||
uploaded_at: "2026-02-08".into(),
|
||||
chunk_count: 8,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f6".into(),
|
||||
name: "architecture-diagram.png".into(),
|
||||
kind: FileKind::Image,
|
||||
size_bytes: 3_800_000,
|
||||
uploaded_at: "2026-02-05".into(),
|
||||
chunk_count: 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
508
src/pages/landing.rs
Normal file
508
src/pages/landing.rs
Normal file
@@ -0,0 +1,508 @@
|
||||
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." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,21 @@
|
||||
mod overview;
|
||||
pub use overview::*;
|
||||
mod chat;
|
||||
mod dashboard;
|
||||
pub mod developer;
|
||||
mod impressum;
|
||||
mod knowledge;
|
||||
mod landing;
|
||||
pub mod organization;
|
||||
mod privacy;
|
||||
mod providers;
|
||||
mod tools;
|
||||
|
||||
pub use chat::*;
|
||||
pub use dashboard::*;
|
||||
pub use developer::*;
|
||||
pub use impressum::*;
|
||||
pub use knowledge::*;
|
||||
pub use landing::*;
|
||||
pub use organization::*;
|
||||
pub use privacy::*;
|
||||
pub use providers::*;
|
||||
pub use tools::*;
|
||||
|
||||
170
src/pages/organization/dashboard.rs
Normal file
170
src/pages/organization/dashboard.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{MemberRow, PageHeader};
|
||||
use crate::models::{BillingUsage, MemberRole, OrgMember};
|
||||
|
||||
/// Organization dashboard with billing stats, member table, and invite modal.
|
||||
///
|
||||
/// Shows current billing usage, a table of organization members
|
||||
/// with role management, and a button to invite new members.
|
||||
#[component]
|
||||
pub fn OrgDashboardPage() -> Element {
|
||||
let members = use_signal(mock_members);
|
||||
let usage = mock_usage();
|
||||
let mut show_invite = use_signal(|| false);
|
||||
let mut invite_email = use_signal(String::new);
|
||||
|
||||
let members_list = members.read().clone();
|
||||
|
||||
// Format token counts for display
|
||||
let tokens_display = format_tokens(usage.tokens_used);
|
||||
let tokens_limit_display = format_tokens(usage.tokens_limit);
|
||||
|
||||
rsx! {
|
||||
section { class: "org-dashboard-page",
|
||||
PageHeader {
|
||||
title: "Organization".to_string(),
|
||||
subtitle: "Manage members and billing".to_string(),
|
||||
actions: rsx! {
|
||||
button { class: "btn-primary", onclick: move |_| show_invite.set(true), "Invite Member" }
|
||||
},
|
||||
}
|
||||
|
||||
// Stats bar
|
||||
div { class: "org-stats-bar",
|
||||
div { class: "org-stat",
|
||||
span { class: "org-stat-value", "{usage.seats_used}/{usage.seats_total}" }
|
||||
span { class: "org-stat-label", "Seats Used" }
|
||||
}
|
||||
div { class: "org-stat",
|
||||
span { class: "org-stat-value", "{tokens_display}" }
|
||||
span { class: "org-stat-label", "of {tokens_limit_display} tokens" }
|
||||
}
|
||||
div { class: "org-stat",
|
||||
span { class: "org-stat-value", "{usage.billing_cycle_end}" }
|
||||
span { class: "org-stat-label", "Cycle Ends" }
|
||||
}
|
||||
}
|
||||
|
||||
// Members table
|
||||
div { class: "org-table-wrapper",
|
||||
table { class: "org-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Email" }
|
||||
th { "Role" }
|
||||
th { "Joined" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for member in members_list {
|
||||
MemberRow {
|
||||
key: "{member.id}",
|
||||
member,
|
||||
on_role_change: move |_| {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invite modal
|
||||
if *show_invite.read() {
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| show_invite.set(false),
|
||||
div {
|
||||
class: "modal-content",
|
||||
// Prevent clicks inside modal from closing it
|
||||
onclick: move |evt: Event<MouseData>| evt.stop_propagation(),
|
||||
h3 { "Invite New Member" }
|
||||
div { class: "form-group",
|
||||
label { "Email Address" }
|
||||
input {
|
||||
class: "form-input",
|
||||
r#type: "email",
|
||||
placeholder: "colleague@company.com",
|
||||
value: "{invite_email}",
|
||||
oninput: move |evt: Event<FormData>| {
|
||||
invite_email.set(evt.value());
|
||||
},
|
||||
}
|
||||
}
|
||||
div { class: "modal-actions",
|
||||
button {
|
||||
class: "btn-secondary",
|
||||
onclick: move |_| show_invite.set(false),
|
||||
"Cancel"
|
||||
}
|
||||
button {
|
||||
class: "btn-primary",
|
||||
onclick: move |_| show_invite.set(false),
|
||||
"Send Invite"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a token count into a human-readable string (e.g. "1.2M").
|
||||
fn format_tokens(count: u64) -> String {
|
||||
const M: u64 = 1_000_000;
|
||||
const K: u64 = 1_000;
|
||||
|
||||
if count >= M {
|
||||
format!("{:.1}M", count as f64 / M as f64)
|
||||
} else if count >= K {
|
||||
format!("{:.0}K", count as f64 / K as f64)
|
||||
} else {
|
||||
count.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock organization members.
|
||||
fn mock_members() -> Vec<OrgMember> {
|
||||
vec![
|
||||
OrgMember {
|
||||
id: "m1".into(),
|
||||
name: "Max Mustermann".into(),
|
||||
email: "max@example.com".into(),
|
||||
role: MemberRole::Admin,
|
||||
joined_at: "2026-01-10".into(),
|
||||
},
|
||||
OrgMember {
|
||||
id: "m2".into(),
|
||||
name: "Erika Musterfrau".into(),
|
||||
email: "erika@example.com".into(),
|
||||
role: MemberRole::Member,
|
||||
joined_at: "2026-01-15".into(),
|
||||
},
|
||||
OrgMember {
|
||||
id: "m3".into(),
|
||||
name: "Johann Schmidt".into(),
|
||||
email: "johann@example.com".into(),
|
||||
role: MemberRole::Member,
|
||||
joined_at: "2026-02-01".into(),
|
||||
},
|
||||
OrgMember {
|
||||
id: "m4".into(),
|
||||
name: "Anna Weber".into(),
|
||||
email: "anna@example.com".into(),
|
||||
role: MemberRole::Viewer,
|
||||
joined_at: "2026-02-10".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns mock billing usage data.
|
||||
fn mock_usage() -> BillingUsage {
|
||||
BillingUsage {
|
||||
seats_used: 4,
|
||||
seats_total: 25,
|
||||
tokens_used: 847_000,
|
||||
tokens_limit: 1_000_000,
|
||||
billing_cycle_end: "2026-03-01".into(),
|
||||
}
|
||||
}
|
||||
35
src/pages/organization/mod.rs
Normal file
35
src/pages/organization/mod.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
mod dashboard;
|
||||
mod pricing;
|
||||
|
||||
pub use dashboard::*;
|
||||
pub use pricing::*;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::sub_nav::{SubNav, SubNavItem};
|
||||
|
||||
/// Shell layout for the Organization section.
|
||||
///
|
||||
/// Renders a horizontal tab bar (Pricing, Dashboard) above
|
||||
/// the child route outlet. Sits inside the main `AppShell` layout.
|
||||
#[component]
|
||||
pub fn OrgShell() -> Element {
|
||||
let tabs = vec![
|
||||
SubNavItem {
|
||||
label: "Pricing",
|
||||
route: Route::OrgPricingPage {},
|
||||
},
|
||||
SubNavItem {
|
||||
label: "Dashboard",
|
||||
route: Route::OrgDashboardPage {},
|
||||
},
|
||||
];
|
||||
|
||||
rsx! {
|
||||
div { class: "org-shell",
|
||||
SubNav { items: tabs }
|
||||
div { class: "shell-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/pages/organization/pricing.rs
Normal file
88
src/pages/organization/pricing.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::{PageHeader, PricingCard};
|
||||
use crate::models::PricingPlan;
|
||||
|
||||
/// Organization pricing page displaying three plan tiers.
|
||||
///
|
||||
/// Clicking "Get Started" on any plan navigates to the
|
||||
/// organization dashboard.
|
||||
#[component]
|
||||
pub fn OrgPricingPage() -> Element {
|
||||
let navigator = use_navigator();
|
||||
let plans = mock_plans();
|
||||
|
||||
rsx! {
|
||||
section { class: "pricing-page",
|
||||
PageHeader {
|
||||
title: "Pricing".to_string(),
|
||||
subtitle: "Choose the plan that fits your organization".to_string(),
|
||||
}
|
||||
div { class: "pricing-grid",
|
||||
for plan in plans {
|
||||
PricingCard {
|
||||
key: "{plan.id}",
|
||||
plan,
|
||||
on_select: move |_| {
|
||||
navigator.push(Route::OrgDashboardPage {});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock pricing plans.
|
||||
fn mock_plans() -> Vec<PricingPlan> {
|
||||
vec![
|
||||
PricingPlan {
|
||||
id: "starter".into(),
|
||||
name: "Starter".into(),
|
||||
price_eur: 49,
|
||||
features: vec![
|
||||
"Up to 5 users".into(),
|
||||
"1 LLM provider".into(),
|
||||
"100K tokens/month".into(),
|
||||
"Community support".into(),
|
||||
"Basic analytics".into(),
|
||||
],
|
||||
highlighted: false,
|
||||
max_seats: Some(5),
|
||||
},
|
||||
PricingPlan {
|
||||
id: "team".into(),
|
||||
name: "Team".into(),
|
||||
price_eur: 199,
|
||||
features: vec![
|
||||
"Up to 25 users".into(),
|
||||
"All LLM providers".into(),
|
||||
"1M tokens/month".into(),
|
||||
"Priority support".into(),
|
||||
"Advanced analytics".into(),
|
||||
"Custom MCP tools".into(),
|
||||
"SSO integration".into(),
|
||||
],
|
||||
highlighted: true,
|
||||
max_seats: Some(25),
|
||||
},
|
||||
PricingPlan {
|
||||
id: "enterprise".into(),
|
||||
name: "Enterprise".into(),
|
||||
price_eur: 499,
|
||||
features: vec![
|
||||
"Unlimited users".into(),
|
||||
"All LLM providers".into(),
|
||||
"Unlimited tokens".into(),
|
||||
"Dedicated support".into(),
|
||||
"Full observability".into(),
|
||||
"Custom integrations".into(),
|
||||
"SLA guarantee".into(),
|
||||
"On-premise deployment".into(),
|
||||
],
|
||||
highlighted: false,
|
||||
max_seats: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn OverviewPage() -> Element {
|
||||
rsx! {
|
||||
h1 { "Hello" }
|
||||
}
|
||||
}
|
||||
106
src/pages/privacy.rs
Normal file
106
src/pages/privacy.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
221
src/pages/providers.rs
Normal file
221
src/pages/providers.rs
Normal file
@@ -0,0 +1,221 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::PageHeader;
|
||||
use crate::models::{EmbeddingEntry, LlmProvider, ModelEntry, ProviderConfig};
|
||||
|
||||
/// Providers page for configuring LLM and embedding model backends.
|
||||
///
|
||||
/// Two-column layout: left side has a configuration form, right side
|
||||
/// shows the currently active provider status.
|
||||
#[component]
|
||||
pub fn ProvidersPage() -> Element {
|
||||
let mut selected_provider = use_signal(|| LlmProvider::Ollama);
|
||||
let mut selected_model = use_signal(|| "llama3.1:8b".to_string());
|
||||
let mut selected_embedding = use_signal(|| "nomic-embed-text".to_string());
|
||||
let mut api_key = use_signal(String::new);
|
||||
let mut saved = use_signal(|| false);
|
||||
|
||||
let models = mock_models();
|
||||
let embeddings = mock_embeddings();
|
||||
|
||||
// Filter models/embeddings by selected provider
|
||||
let provider_val = selected_provider.read().clone();
|
||||
let available_models: Vec<_> = models
|
||||
.iter()
|
||||
.filter(|m| m.provider == provider_val)
|
||||
.collect();
|
||||
let available_embeddings: Vec<_> = embeddings
|
||||
.iter()
|
||||
.filter(|e| e.provider == provider_val)
|
||||
.collect();
|
||||
|
||||
let active_config = ProviderConfig {
|
||||
provider: provider_val.clone(),
|
||||
selected_model: selected_model.read().clone(),
|
||||
selected_embedding: selected_embedding.read().clone(),
|
||||
api_key_set: !api_key.read().is_empty(),
|
||||
};
|
||||
|
||||
rsx! {
|
||||
section { class: "providers-page",
|
||||
PageHeader {
|
||||
title: "Providers".to_string(),
|
||||
subtitle: "Configure your LLM and embedding backends".to_string(),
|
||||
}
|
||||
div { class: "providers-layout",
|
||||
div { class: "providers-form",
|
||||
div { class: "form-group",
|
||||
label { "Provider" }
|
||||
select {
|
||||
class: "form-select",
|
||||
value: "{provider_val.label()}",
|
||||
onchange: move |evt: Event<FormData>| {
|
||||
let val = evt.value();
|
||||
let prov = match val.as_str() {
|
||||
"Hugging Face" => LlmProvider::HuggingFace,
|
||||
"OpenAI" => LlmProvider::OpenAi,
|
||||
"Anthropic" => LlmProvider::Anthropic,
|
||||
_ => LlmProvider::Ollama,
|
||||
};
|
||||
selected_provider.set(prov);
|
||||
saved.set(false);
|
||||
},
|
||||
option { value: "Ollama", "Ollama" }
|
||||
option { value: "Hugging Face", "Hugging Face" }
|
||||
option { value: "OpenAI", "OpenAI" }
|
||||
option { value: "Anthropic", "Anthropic" }
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Model" }
|
||||
select {
|
||||
class: "form-select",
|
||||
value: "{selected_model}",
|
||||
onchange: move |evt: Event<FormData>| {
|
||||
selected_model.set(evt.value());
|
||||
saved.set(false);
|
||||
},
|
||||
for m in &available_models {
|
||||
option { value: "{m.id}", "{m.name} ({m.context_window}k ctx)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Embedding Model" }
|
||||
select {
|
||||
class: "form-select",
|
||||
value: "{selected_embedding}",
|
||||
onchange: move |evt: Event<FormData>| {
|
||||
selected_embedding.set(evt.value());
|
||||
saved.set(false);
|
||||
},
|
||||
for e in &available_embeddings {
|
||||
option { value: "{e.id}", "{e.name} ({e.dimensions}d)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "API Key" }
|
||||
input {
|
||||
class: "form-input",
|
||||
r#type: "password",
|
||||
placeholder: "Enter API key...",
|
||||
value: "{api_key}",
|
||||
oninput: move |evt: Event<FormData>| {
|
||||
api_key.set(evt.value());
|
||||
saved.set(false);
|
||||
},
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn-primary",
|
||||
onclick: move |_| saved.set(true),
|
||||
"Save Configuration"
|
||||
}
|
||||
if *saved.read() {
|
||||
p { class: "form-success", "Configuration saved." }
|
||||
}
|
||||
}
|
||||
div { class: "providers-status",
|
||||
h3 { "Active Configuration" }
|
||||
div { class: "status-card",
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "Provider" }
|
||||
span { class: "status-value", "{active_config.provider.label()}" }
|
||||
}
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "Model" }
|
||||
span { class: "status-value", "{active_config.selected_model}" }
|
||||
}
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "Embedding" }
|
||||
span { class: "status-value", "{active_config.selected_embedding}" }
|
||||
}
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "API Key" }
|
||||
span { class: "status-value",
|
||||
if active_config.api_key_set {
|
||||
"Set"
|
||||
} else {
|
||||
"Not set"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock model entries for all providers.
|
||||
fn mock_models() -> Vec<ModelEntry> {
|
||||
vec![
|
||||
ModelEntry {
|
||||
id: "llama3.1:8b".into(),
|
||||
name: "Llama 3.1 8B".into(),
|
||||
provider: LlmProvider::Ollama,
|
||||
context_window: 128,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "llama3.1:70b".into(),
|
||||
name: "Llama 3.1 70B".into(),
|
||||
provider: LlmProvider::Ollama,
|
||||
context_window: 128,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "mistral:7b".into(),
|
||||
name: "Mistral 7B".into(),
|
||||
provider: LlmProvider::Ollama,
|
||||
context_window: 32,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "meta-llama/Llama-3.1-8B".into(),
|
||||
name: "Llama 3.1 8B".into(),
|
||||
provider: LlmProvider::HuggingFace,
|
||||
context_window: 128,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "gpt-4o".into(),
|
||||
name: "GPT-4o".into(),
|
||||
provider: LlmProvider::OpenAi,
|
||||
context_window: 128,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "claude-sonnet-4-6".into(),
|
||||
name: "Claude Sonnet 4.6".into(),
|
||||
provider: LlmProvider::Anthropic,
|
||||
context_window: 200,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns mock embedding entries for all providers.
|
||||
fn mock_embeddings() -> Vec<EmbeddingEntry> {
|
||||
vec![
|
||||
EmbeddingEntry {
|
||||
id: "nomic-embed-text".into(),
|
||||
name: "Nomic Embed Text".into(),
|
||||
provider: LlmProvider::Ollama,
|
||||
dimensions: 768,
|
||||
},
|
||||
EmbeddingEntry {
|
||||
id: "sentence-transformers/all-MiniLM-L6-v2".into(),
|
||||
name: "MiniLM-L6-v2".into(),
|
||||
provider: LlmProvider::HuggingFace,
|
||||
dimensions: 384,
|
||||
},
|
||||
EmbeddingEntry {
|
||||
id: "text-embedding-3-small".into(),
|
||||
name: "Embedding 3 Small".into(),
|
||||
provider: LlmProvider::OpenAi,
|
||||
dimensions: 1536,
|
||||
},
|
||||
EmbeddingEntry {
|
||||
id: "voyage-3".into(),
|
||||
name: "Voyage 3".into(),
|
||||
provider: LlmProvider::Anthropic,
|
||||
dimensions: 1024,
|
||||
},
|
||||
]
|
||||
}
|
||||
116
src/pages/tools.rs
Normal file
116
src/pages/tools.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{PageHeader, ToolCard};
|
||||
use crate::models::{McpTool, ToolCategory, ToolStatus};
|
||||
|
||||
/// Tools page displaying a grid of MCP tool cards with toggle switches.
|
||||
///
|
||||
/// Shows all available MCP tools with their status and allows
|
||||
/// enabling/disabling them via toggle buttons.
|
||||
#[component]
|
||||
pub fn ToolsPage() -> Element {
|
||||
let mut tools = use_signal(mock_tools);
|
||||
|
||||
// Toggle a tool's enabled state by its ID
|
||||
let on_toggle = move |id: String| {
|
||||
tools.write().iter_mut().for_each(|t| {
|
||||
if t.id == id {
|
||||
t.enabled = !t.enabled;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let tool_list = tools.read().clone();
|
||||
|
||||
rsx! {
|
||||
section { class: "tools-page",
|
||||
PageHeader {
|
||||
title: "Tools".to_string(),
|
||||
subtitle: "Manage MCP servers and tool integrations".to_string(),
|
||||
}
|
||||
div { class: "tools-grid",
|
||||
for tool in tool_list {
|
||||
ToolCard { key: "{tool.id}", tool, on_toggle }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock MCP tools for the tools grid.
|
||||
fn mock_tools() -> Vec<McpTool> {
|
||||
vec![
|
||||
McpTool {
|
||||
id: "calculator".into(),
|
||||
name: "Calculator".into(),
|
||||
description: "Mathematical computation and unit conversion".into(),
|
||||
category: ToolCategory::Compute,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "calculator".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "tavily".into(),
|
||||
name: "Tavily Search".into(),
|
||||
description: "AI-optimized web search API for real-time information".into(),
|
||||
category: ToolCategory::Search,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "search".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "searxng".into(),
|
||||
name: "SearXNG".into(),
|
||||
description: "Privacy-respecting metasearch engine".into(),
|
||||
category: ToolCategory::Search,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "globe".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "file-reader".into(),
|
||||
name: "File Reader".into(),
|
||||
description: "Read and parse local files in various formats".into(),
|
||||
category: ToolCategory::FileSystem,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "file".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "code-exec".into(),
|
||||
name: "Code Executor".into(),
|
||||
description: "Sandboxed code execution for Python and JavaScript".into(),
|
||||
category: ToolCategory::Code,
|
||||
status: ToolStatus::Inactive,
|
||||
enabled: false,
|
||||
icon: "terminal".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "web-scraper".into(),
|
||||
name: "Web Scraper".into(),
|
||||
description: "Extract structured data from web pages".into(),
|
||||
category: ToolCategory::Search,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "download".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "email".into(),
|
||||
name: "Email Sender".into(),
|
||||
description: "Send emails via configured SMTP server".into(),
|
||||
category: ToolCategory::Communication,
|
||||
status: ToolStatus::Inactive,
|
||||
enabled: false,
|
||||
icon: "mail".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "git".into(),
|
||||
name: "Git Operations".into(),
|
||||
description: "Interact with Git repositories for version control".into(),
|
||||
category: ToolCategory::Code,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "git".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
112
styles/input.css
Normal file
112
styles/input.css
Normal 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;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
Reference in New Issue
Block a user