Compare commits
2 Commits
ece48287b9
...
feat/CAI-4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80faa4fa86 | ||
|
|
e0a4d2d888 |
@@ -1,5 +0,0 @@
|
||||
[build]
|
||||
# Use sccache as the rustc wrapper for compile caching.
|
||||
# Falls back gracefully: if sccache is not installed, cargo will warn but
|
||||
# still compile. Install with: cargo install sccache
|
||||
rustc-wrapper = "sccache"
|
||||
80
.env.example
80
.env.example
@@ -1,85 +1,9 @@
|
||||
# ============================================================================
|
||||
# CERTifAI Dashboard - Environment Variables
|
||||
# ============================================================================
|
||||
# Copy this file to .env and fill in the values.
|
||||
# Variables marked [REQUIRED] must be set; others have sensible defaults.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Keycloak Configuration (frontend public client) [REQUIRED]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Keycloak Configuration (frontend public client)
|
||||
KEYCLOAK_URL=http://localhost:8080
|
||||
KEYCLOAK_REALM=certifai
|
||||
KEYCLOAK_CLIENT_ID=certifai-dashboard
|
||||
|
||||
# Keycloak admin / service-account client (server-to-server calls) [OPTIONAL]
|
||||
KEYCLOAK_ADMIN_CLIENT_ID=
|
||||
KEYCLOAK_ADMIN_CLIENT_SECRET=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application Configuration [REQUIRED]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application Configuration
|
||||
APP_URL=http://localhost:8000
|
||||
REDIRECT_URI=http://localhost:8000/auth/callback
|
||||
ALLOWED_ORIGINS=http://localhost:8000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MongoDB [OPTIONAL - defaults shown]
|
||||
# ---------------------------------------------------------------------------
|
||||
MONGODB_URI=mongodb://localhost:27017
|
||||
MONGODB_DATABASE=certifai
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SearXNG meta-search engine [OPTIONAL - default: http://localhost:8888]
|
||||
# ---------------------------------------------------------------------------
|
||||
SEARXNG_URL=http://localhost:8888
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ollama LLM instance [OPTIONAL - defaults shown]
|
||||
# ---------------------------------------------------------------------------
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=llama3.1:8b
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LibreChat (external chat via SSO) [OPTIONAL - default: http://localhost:3080]
|
||||
# ---------------------------------------------------------------------------
|
||||
LIBRECHAT_URL=http://localhost:3080
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM Providers (comma-separated list) [OPTIONAL]
|
||||
# ---------------------------------------------------------------------------
|
||||
LLM_PROVIDERS=ollama
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SMTP (transactional email) [OPTIONAL]
|
||||
# ---------------------------------------------------------------------------
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_ADDRESS=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stripe billing [OPTIONAL]
|
||||
# ---------------------------------------------------------------------------
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LangChain / LangGraph / Langfuse [OPTIONAL]
|
||||
# ---------------------------------------------------------------------------
|
||||
LANGCHAIN_URL=
|
||||
LANGGRAPH_URL=
|
||||
LANGFUSE_URL=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vector database [OPTIONAL]
|
||||
# ---------------------------------------------------------------------------
|
||||
VECTORDB_URL=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S3-compatible object storage [OPTIONAL]
|
||||
# ---------------------------------------------------------------------------
|
||||
S3_URL=
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
|
||||
@@ -11,10 +11,6 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-D warnings"
|
||||
# sccache caches compilation artifacts within a job so that compiling
|
||||
# both --features server and --features web shares common crate work.
|
||||
RUSTC_WRAPPER: /usr/local/bin/sccache
|
||||
SCCACHE_DIR: /tmp/sccache
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR
|
||||
concurrency:
|
||||
@@ -38,10 +34,11 @@ jobs:
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
- run: rustup component add rustfmt
|
||||
# Format check does not compile, so sccache is not needed here.
|
||||
- run: cargo fmt --check
|
||||
env:
|
||||
RUSTC_WRAPPER: ""
|
||||
- name: Install dx CLI
|
||||
run: cargo install dioxus-cli@0.7.3 --locked
|
||||
- name: RSX format check
|
||||
run: dx fmt --check
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
@@ -55,26 +52,16 @@ jobs:
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
- name: Install sccache
|
||||
run: |
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
|
||||
chmod +x /usr/local/bin/sccache
|
||||
- run: rustup component add clippy
|
||||
# Lint both feature sets independently.
|
||||
# sccache deduplicates shared crates between the two compilations.
|
||||
# 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
|
||||
- name: Show sccache stats
|
||||
run: sccache --show-stats
|
||||
if: always()
|
||||
|
||||
audit:
|
||||
name: Security Audit
|
||||
runs-on: docker
|
||||
if: github.ref == 'refs/heads/main'
|
||||
container:
|
||||
image: rust:1.89-bookworm
|
||||
steps:
|
||||
@@ -85,11 +72,7 @@ jobs:
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
- run: cargo install cargo-audit
|
||||
env:
|
||||
RUSTC_WRAPPER: ""
|
||||
- run: cargo audit
|
||||
env:
|
||||
RUSTC_WRAPPER: ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: Tests (only after all quality checks pass)
|
||||
@@ -107,53 +90,22 @@ jobs:
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
- name: Install sccache
|
||||
run: |
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
|
||||
chmod +x /usr/local/bin/sccache
|
||||
- name: Run tests (server)
|
||||
run: cargo test --features server --no-default-features
|
||||
- name: Run tests (web)
|
||||
run: cargo test --features web --no-default-features
|
||||
- name: Show sccache stats
|
||||
run: sccache --show-stats
|
||||
if: always()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2b: E2E tests (only on main / PRs to main, after quality checks)
|
||||
# Stage 3: Build Docker image and push to registry
|
||||
# Only on main and release/* branches
|
||||
# ---------------------------------------------------------------------------
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
build-and-push:
|
||||
name: Build & Push Image
|
||||
runs-on: docker
|
||||
needs: [fmt, clippy, audit]
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
|
||||
container:
|
||||
image: rust:1.89-bookworm
|
||||
# MongoDB and SearXNG can start immediately (no repo files needed).
|
||||
# Keycloak requires realm-export.json from the repo, so it is started
|
||||
# manually after checkout via docker CLI.
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:latest
|
||||
env:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
MONGO_INITDB_ROOT_PASSWORD: example
|
||||
ports:
|
||||
- 27017:27017
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
env:
|
||||
SEARXNG_BASE_URL: http://localhost:8888
|
||||
ports:
|
||||
- 8888:8080
|
||||
env:
|
||||
KEYCLOAK_URL: http://localhost:8080
|
||||
KEYCLOAK_REALM: certifai
|
||||
KEYCLOAK_CLIENT_ID: certifai-dashboard
|
||||
MONGODB_URI: mongodb://root:example@mongo:27017
|
||||
MONGODB_DATABASE: certifai
|
||||
SEARXNG_URL: http://searxng:8080
|
||||
needs: [test]
|
||||
if: >-
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -161,108 +113,59 @@ jobs:
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends \
|
||||
unzip curl docker.io \
|
||||
libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 \
|
||||
libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 \
|
||||
libxdamage1 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \
|
||||
libcairo2 libasound2 libatspi2.0-0 libxshmfence1
|
||||
- name: Start Keycloak
|
||||
run: |
|
||||
docker run -d --name ci-keycloak --network host \
|
||||
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
|
||||
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
||||
-e KC_DB=dev-mem \
|
||||
-e KC_HEALTH_ENABLED=true \
|
||||
-v "$PWD/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro" \
|
||||
-v "$PWD/keycloak/themes/certifai:/opt/keycloak/themes/certifai:ro" \
|
||||
quay.io/keycloak/keycloak:26.0 start-dev --import-realm
|
||||
|
||||
echo "Waiting for Keycloak..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:8080/realms/certifai > /dev/null 2>&1; then
|
||||
echo "Keycloak is ready"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "Keycloak failed to start within 60s"
|
||||
docker logs ci-keycloak
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
- name: Install sccache
|
||||
- name: Determine image tag
|
||||
id: tag
|
||||
run: |
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
|
||||
chmod +x /usr/local/bin/sccache
|
||||
- name: Install dioxus-cli
|
||||
run: cargo install dioxus-cli --locked
|
||||
- name: Install bun
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
bun install
|
||||
bunx playwright install chromium
|
||||
- name: Build app
|
||||
run: dx build --release
|
||||
- name: Start app and run E2E tests
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
# Start the app in the background
|
||||
dx serve --release --port 8000 &
|
||||
APP_PID=$!
|
||||
BRANCH="${GITHUB_REF#refs/heads/}"
|
||||
# Replace / with - for valid Docker tags (e.g. release/1.0 -> release-1.0)
|
||||
BRANCH_SAFE=$(echo "$BRANCH" | tr '/' '-')
|
||||
SHA=$(echo "$GITHUB_SHA" | head -c 8)
|
||||
echo "tag=${BRANCH_SAFE}-${SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Wait for the app to be ready
|
||||
echo "Waiting for app to start..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:8000 > /dev/null 2>&1; then
|
||||
echo "App is ready"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "App failed to start within 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
- name: Log in to container registry
|
||||
run: >-
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}"
|
||||
| docker login https://registry.meghsakha.com
|
||||
-u "${{ secrets.REGISTRY_USERNAME }}"
|
||||
--password-stdin
|
||||
|
||||
BASE_URL=http://localhost:8000 bunx playwright test --reporter=list
|
||||
- name: Build Docker image
|
||||
run: >-
|
||||
docker build
|
||||
-t registry.meghsakha.com/certifai/dashboard:${{ steps.tag.outputs.tag }}
|
||||
-t registry.meghsakha.com/certifai/dashboard:latest
|
||||
.
|
||||
|
||||
kill "$APP_PID" 2>/dev/null || true
|
||||
- name: Upload test report
|
||||
if: always()
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push registry.meghsakha.com/certifai/dashboard:${{ steps.tag.outputs.tag }}
|
||||
docker push registry.meghsakha.com/certifai/dashboard:latest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3b: Generate changelog from conventional commits
|
||||
# Only on main and release/* branches
|
||||
# ---------------------------------------------------------------------------
|
||||
changelog:
|
||||
name: Changelog
|
||||
runs-on: docker
|
||||
needs: [test]
|
||||
if: >-
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))
|
||||
container:
|
||||
image: rust:1.89-bookworm
|
||||
steps:
|
||||
- name: Checkout (full history)
|
||||
run: |
|
||||
git clone "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" .
|
||||
git checkout "${GITHUB_SHA}"
|
||||
- name: Install git-cliff
|
||||
run: cargo install git-cliff --locked
|
||||
- name: Generate changelog
|
||||
run: git cliff --output CHANGELOG.md
|
||||
- name: Upload changelog artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
- name: Cleanup Keycloak
|
||||
if: always()
|
||||
run: docker rm -f ci-keycloak 2>/dev/null || true
|
||||
- name: Show sccache stats
|
||||
run: sccache --show-stats
|
||||
if: always()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3: Deploy (only after tests pass, only on main)
|
||||
# ---------------------------------------------------------------------------
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: docker
|
||||
needs: [test, e2e]
|
||||
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 }}"
|
||||
name: changelog
|
||||
path: CHANGELOG.md
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -12,18 +12,9 @@
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Keycloak runtime data (but keep config and theme)
|
||||
# Keycloak runtime data (but keep realm-export.json)
|
||||
keycloak/*
|
||||
!keycloak/realm-export.json
|
||||
!keycloak/themes/
|
||||
!keycloak/themes/**
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
searxng/
|
||||
|
||||
# Playwright
|
||||
e2e/.auth/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -237,6 +237,10 @@ 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.
|
||||
|
||||
## 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.
|
||||
|
||||
380
Cargo.lock
generated
380
Cargo.lock
generated
@@ -698,29 +698,6 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser-macros"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
@@ -760,11 +737,9 @@ dependencies = [
|
||||
name = "dashboard"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-stripe",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dioxus",
|
||||
"dioxus-cli-config",
|
||||
@@ -776,25 +751,19 @@ dependencies = [
|
||||
"maud",
|
||||
"mongodb",
|
||||
"petname",
|
||||
"pretty_assertions",
|
||||
"pulldown-cmark",
|
||||
"rand 0.10.0",
|
||||
"reqwest 0.13.2",
|
||||
"scraper",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower-http",
|
||||
"tower-sessions",
|
||||
"tracing",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -850,17 +819,6 @@ dependencies = [
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -884,12 +842,6 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diff"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1098,7 +1050,7 @@ dependencies = [
|
||||
"const-str",
|
||||
"const_format",
|
||||
"content_disposition",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"dioxus-asset-resolver",
|
||||
"dioxus-cli-config",
|
||||
"dioxus-core",
|
||||
@@ -1140,7 +1092,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"xxhash-rust",
|
||||
]
|
||||
@@ -1544,7 +1496,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -1594,33 +1546,12 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa-short"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||
dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "ego-tree"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -1743,16 +1674,6 @@ version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||
dependencies = [
|
||||
"mac",
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
@@ -1856,15 +1777,6 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxhash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generational-box"
|
||||
version = "0.7.3"
|
||||
@@ -2103,18 +2015,6 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"mac",
|
||||
"markup5ever",
|
||||
"match_token",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.12"
|
||||
@@ -2679,12 +2579,6 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.1.4"
|
||||
@@ -2784,31 +2678,6 @@ dependencies = [
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
|
||||
dependencies = [
|
||||
"log",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "match_token"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -2935,7 +2804,7 @@ dependencies = [
|
||||
"bitflags",
|
||||
"bson",
|
||||
"derive-where",
|
||||
"derive_more 2.1.1",
|
||||
"derive_more",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
@@ -3028,12 +2897,6 @@ dependencies = [
|
||||
"jni-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
@@ -3140,58 +3003,6 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.10"
|
||||
@@ -3248,22 +3059,6 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "pretty_assertions"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
|
||||
dependencies = [
|
||||
"diff",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@@ -3320,24 +3115,6 @@ dependencies = [
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -3614,7 +3391,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
@@ -3629,7 +3406,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.4.13",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
@@ -3652,14 +3428,12 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3841,15 +3615,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scc"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
|
||||
dependencies = [
|
||||
"sdd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
@@ -3865,20 +3630,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scraper"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"ego-tree",
|
||||
"html5ever",
|
||||
"precomputed-hash",
|
||||
"selectors",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sct"
|
||||
version = "0.7.1"
|
||||
@@ -3889,12 +3640,6 @@ dependencies = [
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sdd"
|
||||
version = "3.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
||||
|
||||
[[package]]
|
||||
name = "secrecy"
|
||||
version = "0.10.3"
|
||||
@@ -3927,25 +3672,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cssparser",
|
||||
"derive_more 0.99.20",
|
||||
"fxhash",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"servo_arc",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@@ -4115,41 +3841,6 @@ dependencies = [
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
|
||||
dependencies = [
|
||||
"futures-executor",
|
||||
"futures-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"scc",
|
||||
"serial_test_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test_derive"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -4197,12 +3888,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -4306,31 +3991,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
@@ -4457,17 +4117,6 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
||||
dependencies = [
|
||||
"futf",
|
||||
"mac",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -5232,19 +4881,6 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
@@ -5742,12 +5378,6 @@ version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "yazi"
|
||||
version = "0.1.6"
|
||||
|
||||
27
Cargo.toml
27
Cargo.toml
@@ -36,7 +36,7 @@ mongodb = { version = "3.2", default-features = false, features = [
|
||||
"compat-3-0-0",
|
||||
], optional = true }
|
||||
futures = { version = "0.3.31", default-features = false }
|
||||
reqwest = { version = "0.13", optional = true, features = ["json", "form", "stream"] }
|
||||
reqwest = { version = "0.13", optional = true, features = ["json", "form"] }
|
||||
tower-sessions = { version = "0.15", default-features = false, features = [
|
||||
"axum-core",
|
||||
"memory-store",
|
||||
@@ -61,17 +61,9 @@ secrecy = { version = "0.10", default-features = false, optional = true }
|
||||
serde_json = { version = "1.0.133", default-features = false }
|
||||
maud = { version = "0.27", default-features = false }
|
||||
url = { version = "2.5.4", default-features = false, optional = true }
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
web-sys = { version = "0.3", optional = true, features = [
|
||||
"Clipboard",
|
||||
"Document",
|
||||
"Element",
|
||||
"EventSource",
|
||||
"HtmlElement",
|
||||
"MessageEvent",
|
||||
"Navigator",
|
||||
"Storage",
|
||||
"Window",
|
||||
] }
|
||||
tracing = "0.1.40"
|
||||
# Debug
|
||||
@@ -83,15 +75,10 @@ dioxus-free-icons = { version = "0.10", features = [
|
||||
] }
|
||||
sha2 = { version = "0.10.9", default-features = false, optional = true }
|
||||
base64 = { version = "0.22.1", default-features = false, optional = true }
|
||||
scraper = { version = "0.22", default-features = false, optional = true }
|
||||
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
|
||||
tokio-stream = { version = "0.1", optional = true, features = ["sync"] }
|
||||
async-stream = { version = "0.3", optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
|
||||
[features]
|
||||
# default = ["web"]
|
||||
web = ["dioxus/web", "dep:reqwest", "dep:web-sys", "dep:wasm-bindgen"]
|
||||
web = ["dioxus/web", "dep:reqwest", "dep:web-sys"]
|
||||
server = [
|
||||
"dioxus/server",
|
||||
"dep:axum",
|
||||
@@ -104,18 +91,8 @@ server = [
|
||||
"dep:url",
|
||||
"dep:sha2",
|
||||
"dep:base64",
|
||||
"dep:scraper",
|
||||
"dep:secrecy",
|
||||
"dep:petname",
|
||||
"dep:tokio-stream",
|
||||
"dep:async-stream",
|
||||
"dep:bytes",
|
||||
]
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4"
|
||||
serial_test = "3.2"
|
||||
|
||||
[[bin]]
|
||||
name = "dashboard"
|
||||
path = "bin/main.rs"
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -1,4 +1,3 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Stage 1: Generate dependency recipe for caching
|
||||
FROM rust:1.89-bookworm AS chef
|
||||
RUN cargo install cargo-chef
|
||||
@@ -16,26 +15,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev curl unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install sccache for compile caching across Docker builds
|
||||
RUN curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache \
|
||||
&& chmod +x /usr/local/bin/sccache
|
||||
|
||||
ENV RUSTC_WRAPPER=/usr/local/bin/sccache
|
||||
ENV SCCACHE_DIR=/tmp/sccache
|
||||
|
||||
# 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 --mount=type=cache,target=/tmp/sccache \
|
||||
cargo install dioxus-cli@0.7.3 --locked
|
||||
RUN cargo install dioxus-cli@0.7.3 --locked
|
||||
|
||||
# Cook dependencies from recipe (cached layer)
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
RUN --mount=type=cache,target=/tmp/sccache \
|
||||
cargo chef cook --release --recipe-path recipe.json
|
||||
RUN cargo chef cook --release --recipe-path recipe.json
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
@@ -44,8 +33,7 @@ COPY . .
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Bundle the fullstack application
|
||||
RUN --mount=type=cache,target=/tmp/sccache \
|
||||
dx bundle --release --fullstack
|
||||
RUN dx bundle --release --fullstack
|
||||
|
||||
# Stage 3: Minimal runtime image
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
145
README.md
145
README.md
@@ -1,132 +1,41 @@
|
||||
<p align="center">
|
||||
<img src="assets/favicon.svg" width="96" height="96" alt="CERTifAI Logo" />
|
||||
</p>
|
||||
# CERTifAI
|
||||
|
||||
<h1 align="center">CERTifAI</h1>
|
||||
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.
|
||||
|
||||
<p align="center">
|
||||
<strong>Self-hosted, GDPR-compliant GenAI infrastructure dashboard</strong>
|
||||
</p>
|
||||
## Overview
|
||||
|
||||
<p align="center">
|
||||
<a href="https://gitea.meghsakha.com/sharang/certifai/actions?workflow=ci.yml"><img src="https://gitea.meghsakha.com/sharang/certifai/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI" /></a>
|
||||
<a href="https://www.rust-lang.org/"><img src="https://img.shields.io/badge/Rust-1.89-orange?logo=rust&logoColor=white" alt="Rust" /></a>
|
||||
<a href="https://dioxuslabs.com/"><img src="https://img.shields.io/badge/Dioxus-0.7-blue?logo=webassembly&logoColor=white" alt="Dioxus" /></a>
|
||||
<a href="https://www.mongodb.com/"><img src="https://img.shields.io/badge/MongoDB-8.0-47A248?logo=mongodb&logoColor=white" alt="MongoDB" /></a>
|
||||
<a href="https://www.keycloak.org/"><img src="https://img.shields.io/badge/Keycloak-26-4D4D4D?logo=keycloak&logoColor=white" alt="Keycloak" /></a>
|
||||
<a href="https://tailwindcss.com/"><img src="https://img.shields.io/badge/Tailwind_CSS-4-06B6D4?logo=tailwindcss&logoColor=white" alt="Tailwind CSS" /></a>
|
||||
<a href="https://daisyui.com/"><img src="https://img.shields.io/badge/DaisyUI-5-5A0EF8?logo=daisyui&logoColor=white" alt="DaisyUI" /></a>
|
||||
</p>
|
||||
The SaaS application dashboard is the landing page for the company admin to view, edit and manage the company internal GenAI tools. The following tasks can be performed by the administrator:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://gdpr.eu/"><img src="https://img.shields.io/badge/GDPR-Compliant-green" alt="GDPR" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Proprietary-red" alt="License" /></a>
|
||||
<img src="https://img.shields.io/badge/Platform-Linux%20%7C%20Docker-lightgrey?logo=linux&logoColor=white" alt="Platform" />
|
||||
<img src="https://img.shields.io/badge/PRs-Welcome-brightgreen" alt="PRs Welcome" />
|
||||
</p>
|
||||
- User management: Can add, remove, set roles, permissions and add restrictions for other users.
|
||||
- SSO/Oauth/LDAP: Can connect to company internal SSO/LDAP or other identity provider to load users and their respective permissions.
|
||||
- Turn features on/off: Turn off/on different GenAI features
|
||||
- Billing: View the current seats being used and token usage per seat for any given billing cycle
|
||||
- Request support: Request support or new features using feedback form
|
||||
- GenAI: View currently running LLMs, Agents, MCP Servers. Modify or add more resources, switch to a different model, launch tools like Langchain + Langfuse for creating new agents,tavily for internet search or more complex tools for use with GenAI. View endpoints and generate API Keys for integrations in other applications.
|
||||
|
||||
---
|
||||
## Development environment
|
||||
|
||||
## About
|
||||
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.
|
||||
|
||||
CERTifAI is a SaaS dashboard for administering self-hosted private GenAI infrastructure. It gives companies and individuals a single pane of glass to manage LLMs, Agents, MCP Servers, and other GenAI-related services -- without sending data to non-EU cloud providers.
|
||||
## Features management
|
||||
|
||||
> **Why?** Protect your intellectual property from being used as training data. Stay fully GDPR-compliant with infrastructure you own.
|
||||
|
||||
## Features
|
||||
|
||||
| Area | Capabilities |
|
||||
|------|-------------|
|
||||
| **User Management** | Add, remove, set roles, permissions, and restrictions |
|
||||
| **SSO / OAuth / LDAP** | Connect to company identity providers and sync users |
|
||||
| **Feature Flags** | Toggle GenAI features on or off per-org |
|
||||
| **Billing** | View seat usage and token consumption per billing cycle |
|
||||
| **Support** | Request support or new features via feedback form |
|
||||
| **GenAI Tools** | Manage LLMs, Agents, MCP Servers; launch Langchain, Langfuse, Tavily; view endpoints and generate API keys |
|
||||
|
||||
## Dashboard
|
||||
|
||||
The main dashboard provides a news feed powered by **SearXNG** and **Ollama**:
|
||||
|
||||
- **Topic-based search** -- Browse AI, Technology, Science, Finance, and custom topics. Add or remove topics on the fly; selections persist in localStorage.
|
||||
- **Article detail + AI summary** -- Click any card to open a split-view panel. The full article is fetched, summarized by Ollama, and a follow-up chat lets you ask questions.
|
||||
- **Sidebar** (visible when no article is selected):
|
||||
- **Ollama Status** -- green/red indicator with the list of loaded models
|
||||
- **Trending** -- keywords extracted from recent news headlines via SearXNG
|
||||
- **Recent Searches** -- last 10 topics you searched, persisted in localStorage
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Frontend | [Dioxus 0.7](https://dioxuslabs.com/) (fullstack + router), Tailwind CSS 4, DaisyUI 5 |
|
||||
| Backend | Axum, tower-sessions, Dioxus server functions |
|
||||
| Database | MongoDB |
|
||||
| Auth | Keycloak 26+ (OAuth2 + PKCE, Organizations) |
|
||||
| Search | SearXNG (meta-search) |
|
||||
| LLM | Ollama (local inference) |
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Rust 1.89+
|
||||
- [Dioxus CLI](https://dioxuslabs.com/learn/0.7/getting_started) (`dx`)
|
||||
- MongoDB
|
||||
- Keycloak
|
||||
- SearXNG (optional)
|
||||
- Ollama (optional)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://gitea.meghsakha.com/sharang/certifai.git
|
||||
cd certifai
|
||||
|
||||
# Configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your Keycloak, MongoDB, and service URLs
|
||||
|
||||
# Run the dev server
|
||||
dx serve
|
||||
```
|
||||
|
||||
### External Services
|
||||
|
||||
| Service | Purpose | Default URL |
|
||||
|---------|---------|-------------|
|
||||
| Keycloak | Identity provider / SSO | `http://localhost:8080` |
|
||||
| MongoDB | User data and preferences | `mongodb://localhost:27017` |
|
||||
| SearXNG | Meta-search engine for news | `http://localhost:8888` |
|
||||
| Ollama | Local LLM for summarization | `http://localhost:11434` |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
components/ Frontend-only reusable UI components
|
||||
infrastructure/ Server-side: auth, config, DB, server functions
|
||||
models/ Shared data models (web + server)
|
||||
pages/ Full page views composing components + models
|
||||
assets/ Static assets (CSS, icons, manifest)
|
||||
styles/ Tailwind/DaisyUI input stylesheet
|
||||
bin/ Binary entrypoint
|
||||
```
|
||||
All features are detailed and described under the features folder in clear markdown instructions which are valid for both human and AI code developers.
|
||||
|
||||
## 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
|
||||
|
||||
We follow the **feature branch workflow**. The `main` branch is the default and protected branch.
|
||||
|
||||
- [Conventional Commits](https://www.conventionalcommits.org/) are required for all commit messages
|
||||
- We follow [SemVer](https://semver.org/) for versioning
|
||||
We follow feature branch workflow for Git and bringing in new features. The `main` branch is the default and protected branch.
|
||||
Conventional commits MUST be used for writing commit messages. We follow semantic versioning as per [SemVer](https://semver.org)
|
||||
|
||||
## CI
|
||||
|
||||
CI runs on Gitea Actions with runner tag `docker`.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>Built with Rust, Dioxus, and a commitment to data sovereignty.</sub>
|
||||
</p>
|
||||
The CI is run on gitea actions with runner tags `docker`.
|
||||
|
||||
BIN
assets/favicon.ico
Normal file
BIN
assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -1,25 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,247 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Wird geladen...",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"delete": "Loeschen",
|
||||
"send": "Senden",
|
||||
"close": "Schliessen",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"on": "EIN",
|
||||
"off": "AUS",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"settings": "Einstellungen",
|
||||
"search": "Suche",
|
||||
"rename": "Umbenennen",
|
||||
"copy": "Kopieren",
|
||||
"share": "Teilen",
|
||||
"edit": "Bearbeiten",
|
||||
"get_started": "Jetzt starten",
|
||||
"coming_soon": "Demnachst verfuegbar",
|
||||
"back_to_home": "Zurueck zur Startseite",
|
||||
"privacy_policy": "Datenschutzerklaerung",
|
||||
"impressum": "Impressum",
|
||||
"chunks": "Abschnitte",
|
||||
"upload_file": "Datei hochladen",
|
||||
"eur_per_month": "EUR / Monat",
|
||||
"up_to_seats": "Bis zu {n} Plaetze",
|
||||
"unlimited_seats": "Unbegrenzte Plaetze",
|
||||
"set": "Gesetzt",
|
||||
"not_set": "Nicht gesetzt",
|
||||
"log_in": "Anmelden",
|
||||
"features": "Funktionen",
|
||||
"how_it_works": "So funktioniert es"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Provider",
|
||||
"chat": "Chat",
|
||||
"developer": "Entwickler",
|
||||
"organization": "Organisation",
|
||||
"switch_light": "Zum hellen Modus wechseln",
|
||||
"switch_dark": "Zum dunklen Modus wechseln",
|
||||
"github": "GitHub",
|
||||
"agents": "Agenten",
|
||||
"flow": "Flow",
|
||||
"analytics": "Analytics",
|
||||
"pricing": "Preise"
|
||||
},
|
||||
"auth": {
|
||||
"redirecting_login": "Weiterleitung zur Anmeldung...",
|
||||
"redirecting_secure": "Weiterleitung zur sicheren Anmeldeseite...",
|
||||
"auth_error": "Authentifizierungsfehler: {msg}",
|
||||
"log_in": "Anmelden"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"subtitle": "KI-Nachrichten und Neuigkeiten",
|
||||
"topic_placeholder": "Themenname...",
|
||||
"ollama_settings": "Ollama-Einstellungen",
|
||||
"settings_hint": "Leer lassen, um OLLAMA_URL / OLLAMA_MODEL aus .env zu verwenden",
|
||||
"ollama_url": "Ollama-URL",
|
||||
"ollama_url_placeholder": "Verwendet OLLAMA_URL aus .env",
|
||||
"model": "Modell",
|
||||
"model_placeholder": "Verwendet OLLAMA_MODEL aus .env",
|
||||
"searching": "Suche laeuft...",
|
||||
"search_failed": "Suche fehlgeschlagen: {e}",
|
||||
"ollama_status": "Ollama-Status",
|
||||
"trending": "Im Trend",
|
||||
"recent_searches": "Letzte Suchen"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Provider",
|
||||
"subtitle": "Konfigurieren Sie Ihre LLM- und Embedding-Backends",
|
||||
"provider": "Provider",
|
||||
"model": "Modell",
|
||||
"embedding_model": "Embedding-Modell",
|
||||
"api_key": "API-Schluessel",
|
||||
"api_key_placeholder": "API-Schluessel eingeben...",
|
||||
"save_config": "Konfiguration speichern",
|
||||
"config_saved": "Konfiguration gespeichert.",
|
||||
"active_config": "Aktive Konfiguration",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"developer": {
|
||||
"agents_title": "Agent Builder",
|
||||
"agents_desc": "Erstellen und verwalten Sie KI-Agenten mit LangGraph. Erstellen Sie mehrstufige Schlussfolgerungspipelines, werkzeugnutzende Agenten und autonome Workflows.",
|
||||
"launch_agents": "Agent Builder starten",
|
||||
"flow_title": "Flow Builder",
|
||||
"flow_desc": "Entwerfen Sie visuelle KI-Workflows mit LangFlow. Ziehen Sie Knoten per Drag-and-Drop, um Datenverarbeitungspipelines, Prompt-Ketten und Integrationsflows zu erstellen.",
|
||||
"launch_flow": "Flow Builder starten",
|
||||
"analytics_title": "Analytics und Observability",
|
||||
"analytics_desc": "Ueberwachen und analysieren Sie Ihre KI-Pipelines mit LangFuse. Verfolgen Sie Token-Verbrauch, Latenz, Kosten und Qualitaetsmetriken ueber alle Ihre Deployments hinweg.",
|
||||
"launch_analytics": "LangFuse starten",
|
||||
"total_requests": "Anfragen gesamt",
|
||||
"avg_latency": "Durchschn. Latenz",
|
||||
"tokens_used": "Verbrauchte Token",
|
||||
"error_rate": "Fehlerrate"
|
||||
},
|
||||
"org": {
|
||||
"title": "Organisation",
|
||||
"subtitle": "Mitglieder und Abrechnung verwalten",
|
||||
"invite_member": "Mitglied einladen",
|
||||
"seats_used": "Belegte Plaetze",
|
||||
"of_tokens": "von {limit} Token",
|
||||
"cycle_ends": "Zyklusende",
|
||||
"name": "Name",
|
||||
"email": "E-Mail",
|
||||
"role": "Rolle",
|
||||
"joined": "Beigetreten",
|
||||
"invite_title": "Neues Mitglied einladen",
|
||||
"email_address": "E-Mail-Adresse",
|
||||
"email_placeholder": "kollege@firma.de",
|
||||
"send_invite": "Einladung senden",
|
||||
"pricing_title": "Preise",
|
||||
"pricing_subtitle": "Waehlen Sie den passenden Plan fuer Ihre Organisation"
|
||||
},
|
||||
"pricing": {
|
||||
"starter": "Starter",
|
||||
"team": "Team",
|
||||
"enterprise": "Enterprise",
|
||||
"up_to_users": "Bis zu {n} Benutzer",
|
||||
"unlimited_users": "Unbegrenzte Benutzer",
|
||||
"llm_provider_1": "1 LLM-Provider",
|
||||
"all_providers": "Alle LLM-Provider",
|
||||
"tokens_100k": "100K Token/Monat",
|
||||
"tokens_1m": "1M Token/Monat",
|
||||
"unlimited_tokens": "Unbegrenzte Token",
|
||||
"community_support": "Community-Support",
|
||||
"priority_support": "Priorisierter Support",
|
||||
"dedicated_support": "Dedizierter Support",
|
||||
"basic_analytics": "Basis-Analytics",
|
||||
"advanced_analytics": "Erweiterte Analytics",
|
||||
"full_observability": "Volle Observability",
|
||||
"custom_mcp": "Benutzerdefinierte MCP-Werkzeuge",
|
||||
"sso": "SSO-Integration",
|
||||
"custom_integrations": "Benutzerdefinierte Integrationen",
|
||||
"sla": "SLA-Garantie",
|
||||
"on_premise": "On-Premise-Bereitstellung"
|
||||
},
|
||||
"landing": {
|
||||
"badge": "Datenschutzorientierte GenAI-Infrastruktur",
|
||||
"hero_title_1": "Ihre KI. Ihre Daten.",
|
||||
"hero_title_2": "Ihre Infrastruktur.",
|
||||
"hero_subtitle": "Selbst gehostete, GDPR-konforme Plattform fuer generative KI fuer Unternehmen, die bei der Datensouveraenitaet keine Kompromisse eingehen. Betreiben Sie LLMs, Agenten und MCP-Server nach Ihren eigenen Regeln.",
|
||||
"learn_more": "Mehr erfahren",
|
||||
"social_proof": "Entwickelt fuer Unternehmen, die ",
|
||||
"data_sovereignty": "Datensouveraenitaet",
|
||||
"on_premise": "On-Premise",
|
||||
"compliant": "Konform",
|
||||
"data_residency": "Datenresidenz",
|
||||
"third_party": "Weitergabe an Dritte",
|
||||
"features_title": "Alles, was Sie brauchen",
|
||||
"features_subtitle": "Ein vollstaendiger, selbst gehosteter GenAI-Stack unter Ihrer vollen Kontrolle.",
|
||||
"feat_infra_title": "Selbst gehostete Infrastruktur",
|
||||
"feat_infra_desc": "Betreiben Sie die Plattform auf Ihrer eigenen Hardware oder in Ihrer privaten Cloud. Volle Kontrolle ueber Ihren KI-Stack ohne externe Abhaengigkeiten.",
|
||||
"feat_gdpr_title": "GDPR-konform",
|
||||
"feat_gdpr_desc": "EU-Datenresidenz garantiert. Ihre Daten verlassen niemals Ihre Infrastruktur und werden nicht an Dritte weitergegeben.",
|
||||
"feat_llm_title": "LLM-Verwaltung",
|
||||
"feat_llm_desc": "Stellen Sie mehrere Sprachmodelle bereit, ueberwachen und verwalten Sie diese. Wechseln Sie zwischen Modellen ohne Ausfallzeit.",
|
||||
"feat_agent_title": "Agent Builder",
|
||||
"feat_agent_desc": "Erstellen Sie benutzerdefinierte KI-Agenten mit integriertem Langchain und Langfuse fuer volle Observability und Kontrolle.",
|
||||
"feat_mcp_title": "MCP-Server-Verwaltung",
|
||||
"feat_mcp_desc": "Verwalten Sie Model Context Protocol-Server, um Ihre KI-Faehigkeiten mit externen Werkzeugintegrationen zu erweitern.",
|
||||
"feat_api_title": "API-Schluessel-Verwaltung",
|
||||
"feat_api_desc": "Generieren Sie API-Schluessel, verfolgen Sie die Nutzung pro Platz und setzen Sie feingranulare Berechtigungen fuer jede Integration.",
|
||||
"how_title": "In wenigen Minuten einsatzbereit",
|
||||
"how_subtitle": "Drei Schritte zur souveraenen KI-Infrastruktur.",
|
||||
"step_deploy": "Bereitstellen",
|
||||
"step_deploy_desc": "Installieren Sie CERTifAI auf Ihrer Infrastruktur mit einem einzigen Befehl. Unterstuetzt Docker, Kubernetes und Bare-Metal.",
|
||||
"step_configure": "Konfigurieren",
|
||||
"step_configure_desc": "Verbinden Sie Ihren Identitaets-Provider, waehlen Sie Ihre Modelle und richten Sie Teamberechtigungen ueber das Admin-Dashboard ein.",
|
||||
"step_scale": "Skalieren",
|
||||
"step_scale_desc": "Fuegen Sie Benutzer hinzu, stellen Sie weitere Modelle bereit und integrieren Sie Ihre bestehenden Werkzeuge ueber API-Schluessel und MCP-Server.",
|
||||
"cta_title": "Bereit, die Kontrolle ueber Ihre KI-Infrastruktur zu uebernehmen?",
|
||||
"cta_subtitle": "Beginnen Sie noch heute mit dem Betrieb souveraener GenAI. Keine Kreditkarte erforderlich.",
|
||||
"get_started_free": "Kostenlos starten",
|
||||
"footer_tagline": "Souveraene GenAI-Infrastruktur fuer Unternehmen.",
|
||||
"product": "Produkt",
|
||||
"legal": "Rechtliches",
|
||||
"resources": "Ressourcen",
|
||||
"documentation": "Dokumentation",
|
||||
"api_reference": "API-Referenz",
|
||||
"support": "Support",
|
||||
"copyright": "2026 CERTifAI. Alle Rechte vorbehalten."
|
||||
},
|
||||
"article": {
|
||||
"read_original": "Originalartikel lesen",
|
||||
"summarizing": "Wird zusammengefasst...",
|
||||
"summarized_with_ai": "Mit KI zusammengefasst",
|
||||
"ask_followup": "Stellen Sie eine Anschlussfrage..."
|
||||
},
|
||||
"impressum": {
|
||||
"title": "Impressum",
|
||||
"info_tmg": "Angaben gemaess 5 TMG",
|
||||
"company": "CERTifAI GmbH",
|
||||
"address_street": "Musterstrasse 1",
|
||||
"address_city": "10115 Berlin",
|
||||
"address_country": "Deutschland",
|
||||
"represented_by": "Vertreten durch",
|
||||
"managing_director": "Geschaeftsfuehrer: [Name]",
|
||||
"contact": "Kontakt",
|
||||
"email": "E-Mail: info@certifai.example",
|
||||
"phone": "Telefon: +49 (0) 30 1234567",
|
||||
"commercial_register": "Handelsregister",
|
||||
"registered_at": "Eingetragen beim: Amtsgericht Berlin-Charlottenburg",
|
||||
"registration_number": "Registernummer: HRB XXXXXX",
|
||||
"vat_id": "Umsatzsteuer-ID",
|
||||
"vat_number": "Umsatzsteuer-Identifikationsnummer gemaess 27a UStG: DE XXXXXXXXX",
|
||||
"responsible_content": "Verantwortlich fuer den Inhalt nach 55 Abs. 2 RStV"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Datenschutzerklaerung",
|
||||
"last_updated": "Zuletzt aktualisiert: Februar 2026",
|
||||
"intro_title": "1. Einleitung",
|
||||
"intro_text": "Die CERTifAI GmbH (\"wir\", \"unser\", \"uns\") verpflichtet sich zum Schutz Ihrer personenbezogenen Daten. Diese Datenschutzerklaerung erlaeutert, wie wir Ihre Informationen erheben, verwenden und schuetzen, wenn Sie unsere Plattform nutzen.",
|
||||
"controller_title": "2. Verantwortlicher",
|
||||
"controller_address": "Musterstrasse 1, 10115 Berlin, Deutschland",
|
||||
"controller_email": "E-Mail: privacy@certifai.example",
|
||||
"data_title": "3. Erhobene Daten",
|
||||
"data_intro": "Wir erheben nur die fuer die Erbringung unserer Dienste mindestens erforderlichen Daten:",
|
||||
"data_account_label": "Kontodaten: ",
|
||||
"data_account_text": "Name, E-Mail-Adresse und Organisationsangaben, die bei der Registrierung angegeben werden.",
|
||||
"data_usage_label": "Nutzungsdaten: ",
|
||||
"data_usage_text": "API-Aufrufprotokolle, Token-Zaehler und Funktionsnutzungsmetriken fuer Abrechnung und Analyse.",
|
||||
"data_technical_label": "Technische Daten: ",
|
||||
"data_technical_text": "IP-Adressen, Browsertyp und Sitzungskennungen fuer Sicherheit und Plattformstabilitaet.",
|
||||
"use_title": "4. Verwendung Ihrer Daten",
|
||||
"use_1": "Zur Bereitstellung und Wartung der CERTifAI-Plattform",
|
||||
"use_2": "Zur Verwaltung Ihres Kontos und Abonnements",
|
||||
"use_3": "Zur Mitteilung von Dienstaktualisierungen und Sicherheitshinweisen",
|
||||
"use_4": "Zur Erfuellung gesetzlicher Verpflichtungen",
|
||||
"storage_title": "5. Datenspeicherung und Datensouveraenitaet",
|
||||
"storage_text": "CERTifAI ist eine selbst gehostete Plattform. Alle KI-Workloads, Modelldaten und Inferenzergebnisse verbleiben vollstaendig innerhalb Ihrer eigenen Infrastruktur. Wir greifen nicht auf Ihre KI-Daten zu, speichern oder verarbeiten diese nicht auf unseren Servern.",
|
||||
"rights_title": "6. Ihre Rechte (GDPR)",
|
||||
"rights_intro": "Gemaess der GDPR haben Sie das Recht auf:",
|
||||
"rights_access": "Auskunft ueber Ihre personenbezogenen Daten",
|
||||
"rights_rectify": "Berichtigung unrichtiger Daten",
|
||||
"rights_erasure": "Loeschung Ihrer Daten",
|
||||
"rights_restrict": "Einschraenkung oder Widerspruch gegen die Verarbeitung",
|
||||
"rights_portability": "Datenuebertragbarkeit",
|
||||
"rights_complaint": "Beschwerde bei einer Aufsichtsbehoerde",
|
||||
"contact_title": "7. Kontakt",
|
||||
"contact_text": "Fuer datenschutzbezogene Anfragen kontaktieren Sie uns unter privacy@certifai.example."
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"send": "Send",
|
||||
"close": "Close",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"on": "ON",
|
||||
"off": "OFF",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"settings": "Settings",
|
||||
"search": "Search",
|
||||
"rename": "Rename",
|
||||
"copy": "Copy",
|
||||
"share": "Share",
|
||||
"edit": "Edit",
|
||||
"get_started": "Get Started",
|
||||
"coming_soon": "Coming Soon",
|
||||
"back_to_home": "Back to Home",
|
||||
"privacy_policy": "Privacy Policy",
|
||||
"impressum": "Impressum",
|
||||
"chunks": "chunks",
|
||||
"upload_file": "Upload File",
|
||||
"eur_per_month": "EUR / month",
|
||||
"up_to_seats": "Up to {n} seats",
|
||||
"unlimited_seats": "Unlimited seats",
|
||||
"set": "Set",
|
||||
"not_set": "Not set",
|
||||
"log_in": "Log In",
|
||||
"features": "Features",
|
||||
"how_it_works": "How It Works"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Providers",
|
||||
"chat": "Chat",
|
||||
"developer": "Developer",
|
||||
"organization": "Organization",
|
||||
"switch_light": "Switch to light mode",
|
||||
"switch_dark": "Switch to dark mode",
|
||||
"github": "GitHub",
|
||||
"agents": "Agents",
|
||||
"flow": "Flow",
|
||||
"analytics": "Analytics",
|
||||
"pricing": "Pricing"
|
||||
},
|
||||
"auth": {
|
||||
"redirecting_login": "Redirecting to login...",
|
||||
"redirecting_secure": "Redirecting to secure login page...",
|
||||
"auth_error": "Authentication error: {msg}",
|
||||
"log_in": "Login"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"subtitle": "AI news and updates",
|
||||
"topic_placeholder": "Topic name...",
|
||||
"ollama_settings": "Ollama Settings",
|
||||
"settings_hint": "Leave empty to use OLLAMA_URL / OLLAMA_MODEL from .env",
|
||||
"ollama_url": "Ollama URL",
|
||||
"ollama_url_placeholder": "Uses OLLAMA_URL from .env",
|
||||
"model": "Model",
|
||||
"model_placeholder": "Uses OLLAMA_MODEL from .env",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed: {e}",
|
||||
"ollama_status": "Ollama Status",
|
||||
"trending": "Trending",
|
||||
"recent_searches": "Recent Searches"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Providers",
|
||||
"subtitle": "Configure your LLM and embedding backends",
|
||||
"provider": "Provider",
|
||||
"model": "Model",
|
||||
"embedding_model": "Embedding Model",
|
||||
"api_key": "API Key",
|
||||
"api_key_placeholder": "Enter API key...",
|
||||
"save_config": "Save Configuration",
|
||||
"config_saved": "Configuration saved.",
|
||||
"active_config": "Active Configuration",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"developer": {
|
||||
"agents_title": "Agent Builder",
|
||||
"agents_desc": "Build and manage AI agents with LangGraph. Create multi-step reasoning pipelines, tool-using agents, and autonomous workflows.",
|
||||
"launch_agents": "Launch Agent Builder",
|
||||
"flow_title": "Flow Builder",
|
||||
"flow_desc": "Design visual AI workflows with LangFlow. Drag-and-drop nodes to create data processing pipelines, prompt chains, and integration flows.",
|
||||
"launch_flow": "Launch Flow Builder",
|
||||
"analytics_title": "Analytics & Observability",
|
||||
"analytics_desc": "Monitor and analyze your AI pipelines with LangFuse. Track token usage, latency, costs, and quality metrics across all your deployments.",
|
||||
"launch_analytics": "Launch LangFuse",
|
||||
"total_requests": "Total Requests",
|
||||
"avg_latency": "Avg Latency",
|
||||
"tokens_used": "Tokens Used",
|
||||
"error_rate": "Error Rate"
|
||||
},
|
||||
"org": {
|
||||
"title": "Organization",
|
||||
"subtitle": "Manage members and billing",
|
||||
"invite_member": "Invite Member",
|
||||
"seats_used": "Seats Used",
|
||||
"of_tokens": "of {limit} tokens",
|
||||
"cycle_ends": "Cycle Ends",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"role": "Role",
|
||||
"joined": "Joined",
|
||||
"invite_title": "Invite New Member",
|
||||
"email_address": "Email Address",
|
||||
"email_placeholder": "colleague@company.com",
|
||||
"send_invite": "Send Invite",
|
||||
"pricing_title": "Pricing",
|
||||
"pricing_subtitle": "Choose the plan that fits your organization"
|
||||
},
|
||||
"pricing": {
|
||||
"starter": "Starter",
|
||||
"team": "Team",
|
||||
"enterprise": "Enterprise",
|
||||
"up_to_users": "Up to {n} users",
|
||||
"unlimited_users": "Unlimited users",
|
||||
"llm_provider_1": "1 LLM provider",
|
||||
"all_providers": "All LLM providers",
|
||||
"tokens_100k": "100K tokens/month",
|
||||
"tokens_1m": "1M tokens/month",
|
||||
"unlimited_tokens": "Unlimited tokens",
|
||||
"community_support": "Community support",
|
||||
"priority_support": "Priority support",
|
||||
"dedicated_support": "Dedicated support",
|
||||
"basic_analytics": "Basic analytics",
|
||||
"advanced_analytics": "Advanced analytics",
|
||||
"full_observability": "Full observability",
|
||||
"custom_mcp": "Custom MCP tools",
|
||||
"sso": "SSO integration",
|
||||
"custom_integrations": "Custom integrations",
|
||||
"sla": "SLA guarantee",
|
||||
"on_premise": "On-premise deployment"
|
||||
},
|
||||
"landing": {
|
||||
"badge": "Privacy-First GenAI Infrastructure",
|
||||
"hero_title_1": "Your AI. Your Data.",
|
||||
"hero_title_2": "Your Infrastructure.",
|
||||
"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.",
|
||||
"learn_more": "Learn More",
|
||||
"social_proof": "Built for enterprises that value ",
|
||||
"data_sovereignty": "data sovereignty",
|
||||
"on_premise": "On-Premise",
|
||||
"compliant": "Compliant",
|
||||
"data_residency": "Data Residency",
|
||||
"third_party": "Third-Party Sharing",
|
||||
"features_title": "Everything You Need",
|
||||
"features_subtitle": "A complete, self-hosted GenAI stack under your full control.",
|
||||
"feat_infra_title": "Self-Hosted Infrastructure",
|
||||
"feat_infra_desc": "Deploy on your own hardware or private cloud. Full control over your AI stack with no external dependencies.",
|
||||
"feat_gdpr_title": "GDPR Compliant",
|
||||
"feat_gdpr_desc": "EU data residency guaranteed. Your data never leaves your infrastructure or gets shared with third parties.",
|
||||
"feat_llm_title": "LLM Management",
|
||||
"feat_llm_desc": "Deploy, monitor, and manage multiple language models. Switch between models with zero downtime.",
|
||||
"feat_agent_title": "Agent Builder",
|
||||
"feat_agent_desc": "Create custom AI agents with integrated Langchain and Langfuse for full observability and control.",
|
||||
"feat_mcp_title": "MCP Server Management",
|
||||
"feat_mcp_desc": "Manage Model Context Protocol servers to extend your AI capabilities with external tool integrations.",
|
||||
"feat_api_title": "API Key Management",
|
||||
"feat_api_desc": "Generate API keys, track usage per seat, and set fine-grained permissions for every integration.",
|
||||
"how_title": "Up and Running in Minutes",
|
||||
"how_subtitle": "Three steps to sovereign AI infrastructure.",
|
||||
"step_deploy": "Deploy",
|
||||
"step_deploy_desc": "Install CERTifAI on your infrastructure with a single command. Supports Docker, Kubernetes, and bare metal.",
|
||||
"step_configure": "Configure",
|
||||
"step_configure_desc": "Connect your identity provider, select your models, and set up team permissions through the admin dashboard.",
|
||||
"step_scale": "Scale",
|
||||
"step_scale_desc": "Add users, deploy more models, and integrate with your existing tools via API keys and MCP servers.",
|
||||
"cta_title": "Ready to take control of your AI infrastructure?",
|
||||
"cta_subtitle": "Start deploying sovereign GenAI today. No credit card required.",
|
||||
"get_started_free": "Get Started Free",
|
||||
"footer_tagline": "Sovereign GenAI infrastructure for enterprises.",
|
||||
"product": "Product",
|
||||
"legal": "Legal",
|
||||
"resources": "Resources",
|
||||
"documentation": "Documentation",
|
||||
"api_reference": "API Reference",
|
||||
"support": "Support",
|
||||
"copyright": "2026 CERTifAI. All rights reserved."
|
||||
},
|
||||
"article": {
|
||||
"read_original": "Read original article",
|
||||
"summarizing": "Summarizing...",
|
||||
"summarized_with_ai": "Summarized with AI",
|
||||
"ask_followup": "Ask a follow-up question..."
|
||||
},
|
||||
"impressum": {
|
||||
"title": "Impressum",
|
||||
"info_tmg": "Information according to 5 TMG",
|
||||
"company": "CERTifAI GmbH",
|
||||
"address_street": "Musterstrasse 1",
|
||||
"address_city": "10115 Berlin",
|
||||
"address_country": "Germany",
|
||||
"represented_by": "Represented by",
|
||||
"managing_director": "Managing Director: [Name]",
|
||||
"contact": "Contact",
|
||||
"email": "Email: info@certifai.example",
|
||||
"phone": "Phone: +49 (0) 30 1234567",
|
||||
"commercial_register": "Commercial Register",
|
||||
"registered_at": "Registered at: Amtsgericht Berlin-Charlottenburg",
|
||||
"registration_number": "Registration number: HRB XXXXXX",
|
||||
"vat_id": "VAT ID",
|
||||
"vat_number": "VAT identification number according to 27a UStG: DE XXXXXXXXX",
|
||||
"responsible_content": "Responsible for content according to 55 Abs. 2 RStV"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy Policy",
|
||||
"last_updated": "Last updated: February 2026",
|
||||
"intro_title": "1. Introduction",
|
||||
"intro_text": "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.",
|
||||
"controller_title": "2. Data Controller",
|
||||
"controller_address": "Musterstrasse 1, 10115 Berlin, Germany",
|
||||
"controller_email": "Email: privacy@certifai.example",
|
||||
"data_title": "3. Data We Collect",
|
||||
"data_intro": "We collect only the minimum data necessary to provide our services:",
|
||||
"data_account_label": "Account data: ",
|
||||
"data_account_text": "Name, email address, and organization details provided during registration.",
|
||||
"data_usage_label": "Usage data: ",
|
||||
"data_usage_text": "API call logs, token counts, and feature usage metrics for billing and analytics.",
|
||||
"data_technical_label": "Technical data: ",
|
||||
"data_technical_text": "IP addresses, browser type, and session identifiers for security and platform stability.",
|
||||
"use_title": "4. How We Use Your Data",
|
||||
"use_1": "To provide and maintain the CERTifAI platform",
|
||||
"use_2": "To manage your account and subscription",
|
||||
"use_3": "To communicate service updates and security notices",
|
||||
"use_4": "To comply with legal obligations",
|
||||
"storage_title": "5. Data Storage and Sovereignty",
|
||||
"storage_text": "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.",
|
||||
"rights_title": "6. Your Rights (GDPR)",
|
||||
"rights_intro": "Under the GDPR, you have the right to:",
|
||||
"rights_access": "Access your personal data",
|
||||
"rights_rectify": "Rectify inaccurate data",
|
||||
"rights_erasure": "Request erasure of your data",
|
||||
"rights_restrict": "Restrict or object to processing",
|
||||
"rights_portability": "Data portability",
|
||||
"rights_complaint": "Lodge a complaint with a supervisory authority",
|
||||
"contact_title": "7. Contact",
|
||||
"contact_text": "For privacy-related inquiries, contact us at privacy@certifai.example."
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Cargando...",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"delete": "Eliminar",
|
||||
"send": "Enviar",
|
||||
"close": "Cerrar",
|
||||
"login": "Iniciar sesion",
|
||||
"logout": "Cerrar sesion",
|
||||
"on": "ACTIVADO",
|
||||
"off": "DESACTIVADO",
|
||||
"online": "En linea",
|
||||
"offline": "Sin conexion",
|
||||
"settings": "Configuracion",
|
||||
"search": "Buscar",
|
||||
"rename": "Renombrar",
|
||||
"copy": "Copiar",
|
||||
"share": "Compartir",
|
||||
"edit": "Editar",
|
||||
"get_started": "Comenzar",
|
||||
"coming_soon": "Proximamente",
|
||||
"back_to_home": "Volver al inicio",
|
||||
"privacy_policy": "Politica de privacidad",
|
||||
"impressum": "Aviso legal",
|
||||
"chunks": "fragmentos",
|
||||
"upload_file": "Subir archivo",
|
||||
"eur_per_month": "EUR / mes",
|
||||
"up_to_seats": "Hasta {n} puestos",
|
||||
"unlimited_seats": "Puestos ilimitados",
|
||||
"set": "Configurado",
|
||||
"not_set": "No configurado",
|
||||
"log_in": "Iniciar sesion",
|
||||
"features": "Funcionalidades",
|
||||
"how_it_works": "Como funciona"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Panel de control",
|
||||
"providers": "Proveedores",
|
||||
"chat": "Chat",
|
||||
"developer": "Desarrollador",
|
||||
"organization": "Organizacion",
|
||||
"switch_light": "Cambiar a modo claro",
|
||||
"switch_dark": "Cambiar a modo oscuro",
|
||||
"github": "GitHub",
|
||||
"agents": "Agentes",
|
||||
"flow": "Flujo",
|
||||
"analytics": "Estadisticas",
|
||||
"pricing": "Precios"
|
||||
},
|
||||
"auth": {
|
||||
"redirecting_login": "Redirigiendo al inicio de sesion...",
|
||||
"redirecting_secure": "Redirigiendo a la pagina de inicio de sesion segura...",
|
||||
"auth_error": "Error de autenticacion: {msg}",
|
||||
"log_in": "Iniciar sesion"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel de control",
|
||||
"subtitle": "Noticias y actualizaciones de IA",
|
||||
"topic_placeholder": "Nombre del tema...",
|
||||
"ollama_settings": "Configuracion de Ollama",
|
||||
"settings_hint": "Dejar vacio para usar OLLAMA_URL / OLLAMA_MODEL del archivo .env",
|
||||
"ollama_url": "URL de Ollama",
|
||||
"ollama_url_placeholder": "Usa OLLAMA_URL del archivo .env",
|
||||
"model": "Modelo",
|
||||
"model_placeholder": "Usa OLLAMA_MODEL del archivo .env",
|
||||
"searching": "Buscando...",
|
||||
"search_failed": "La busqueda fallo: {e}",
|
||||
"ollama_status": "Estado de Ollama",
|
||||
"trending": "Tendencias",
|
||||
"recent_searches": "Busquedas recientes"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Proveedores",
|
||||
"subtitle": "Configure sus backends de LLM y embeddings",
|
||||
"provider": "Proveedor",
|
||||
"model": "Modelo",
|
||||
"embedding_model": "Modelo de embedding",
|
||||
"api_key": "Clave API",
|
||||
"api_key_placeholder": "Introduzca la clave API...",
|
||||
"save_config": "Guardar configuracion",
|
||||
"config_saved": "Configuracion guardada.",
|
||||
"active_config": "Configuracion activa",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"developer": {
|
||||
"agents_title": "Constructor de agentes",
|
||||
"agents_desc": "Construya y gestione agentes de IA con LangGraph. Cree pipelines de razonamiento de varios pasos, agentes que utilizan herramientas y flujos de trabajo autonomos.",
|
||||
"launch_agents": "Abrir constructor de agentes",
|
||||
"flow_title": "Constructor de flujos",
|
||||
"flow_desc": "Disene flujos de trabajo de IA visuales con LangFlow. Arrastre y suelte nodos para crear pipelines de procesamiento de datos, cadenas de prompts y flujos de integracion.",
|
||||
"launch_flow": "Abrir constructor de flujos",
|
||||
"analytics_title": "Estadisticas y observabilidad",
|
||||
"analytics_desc": "Monitoree y analice sus pipelines de IA con LangFuse. Realice seguimiento del uso de tokens, latencia, costos y metricas de calidad en todos sus despliegues.",
|
||||
"launch_analytics": "Abrir LangFuse",
|
||||
"total_requests": "Total de solicitudes",
|
||||
"avg_latency": "Latencia promedio",
|
||||
"tokens_used": "Tokens utilizados",
|
||||
"error_rate": "Tasa de errores"
|
||||
},
|
||||
"org": {
|
||||
"title": "Organizacion",
|
||||
"subtitle": "Gestione miembros y facturacion",
|
||||
"invite_member": "Invitar miembro",
|
||||
"seats_used": "Puestos utilizados",
|
||||
"of_tokens": "de {limit} tokens",
|
||||
"cycle_ends": "Fin del ciclo",
|
||||
"name": "Nombre",
|
||||
"email": "Correo electronico",
|
||||
"role": "Rol",
|
||||
"joined": "Fecha de ingreso",
|
||||
"invite_title": "Invitar nuevo miembro",
|
||||
"email_address": "Direccion de correo electronico",
|
||||
"email_placeholder": "colega@empresa.com",
|
||||
"send_invite": "Enviar invitacion",
|
||||
"pricing_title": "Precios",
|
||||
"pricing_subtitle": "Elija el plan que se adapte a su organizacion"
|
||||
},
|
||||
"pricing": {
|
||||
"starter": "Starter",
|
||||
"team": "Team",
|
||||
"enterprise": "Enterprise",
|
||||
"up_to_users": "Hasta {n} usuarios",
|
||||
"unlimited_users": "Usuarios ilimitados",
|
||||
"llm_provider_1": "1 proveedor de LLM",
|
||||
"all_providers": "Todos los proveedores de LLM",
|
||||
"tokens_100k": "100K tokens/mes",
|
||||
"tokens_1m": "1M tokens/mes",
|
||||
"unlimited_tokens": "Tokens ilimitados",
|
||||
"community_support": "Soporte comunitario",
|
||||
"priority_support": "Soporte prioritario",
|
||||
"dedicated_support": "Soporte dedicado",
|
||||
"basic_analytics": "Estadisticas basicas",
|
||||
"advanced_analytics": "Estadisticas avanzadas",
|
||||
"full_observability": "Observabilidad completa",
|
||||
"custom_mcp": "Herramientas MCP personalizadas",
|
||||
"sso": "Integracion SSO",
|
||||
"custom_integrations": "Integraciones personalizadas",
|
||||
"sla": "Garantia de SLA",
|
||||
"on_premise": "Despliegue en infraestructura propia"
|
||||
},
|
||||
"landing": {
|
||||
"badge": "Infraestructura GenAI con privacidad ante todo",
|
||||
"hero_title_1": "Su IA. Sus datos.",
|
||||
"hero_title_2": "Su infraestructura.",
|
||||
"hero_subtitle": "Plataforma de IA generativa autoalojada y conforme al RGPD para empresas que no comprometen la soberania de sus datos. Despliegue LLMs, agentes y servidores MCP bajo sus propias condiciones.",
|
||||
"learn_more": "Mas informacion",
|
||||
"social_proof": "Creado para empresas que valoran la ",
|
||||
"data_sovereignty": "soberania de datos",
|
||||
"on_premise": "En infraestructura propia",
|
||||
"compliant": "Conforme",
|
||||
"data_residency": "Residencia de datos",
|
||||
"third_party": "Comparticion con terceros",
|
||||
"features_title": "Todo lo que necesita",
|
||||
"features_subtitle": "Una pila GenAI completa y autoalojada bajo su total control.",
|
||||
"feat_infra_title": "Infraestructura autoalojada",
|
||||
"feat_infra_desc": "Despliegue en su propio hardware o nube privada. Control total sobre su pila de IA sin dependencias externas.",
|
||||
"feat_gdpr_title": "Conforme al RGPD",
|
||||
"feat_gdpr_desc": "Residencia de datos en la UE garantizada. Sus datos nunca abandonan su infraestructura ni se comparten con terceros.",
|
||||
"feat_llm_title": "Gestion de LLM",
|
||||
"feat_llm_desc": "Despliegue, monitoree y gestione multiples modelos de lenguaje. Cambie entre modelos sin tiempo de inactividad.",
|
||||
"feat_agent_title": "Constructor de agentes",
|
||||
"feat_agent_desc": "Cree agentes de IA personalizados con Langchain y Langfuse integrados para observabilidad y control total.",
|
||||
"feat_mcp_title": "Gestion de servidores MCP",
|
||||
"feat_mcp_desc": "Gestione servidores de Model Context Protocol para ampliar sus capacidades de IA con integraciones de herramientas externas.",
|
||||
"feat_api_title": "Gestion de claves API",
|
||||
"feat_api_desc": "Genere claves API, realice seguimiento del uso por puesto y establezca permisos detallados para cada integracion.",
|
||||
"how_title": "En funcionamiento en minutos",
|
||||
"how_subtitle": "Tres pasos hacia una infraestructura de IA soberana.",
|
||||
"step_deploy": "Desplegar",
|
||||
"step_deploy_desc": "Instale CERTifAI en su infraestructura con un solo comando. Compatible con Docker, Kubernetes e instalacion directa.",
|
||||
"step_configure": "Configurar",
|
||||
"step_configure_desc": "Conecte su proveedor de identidad, seleccione sus modelos y configure los permisos del equipo a traves del panel de administracion.",
|
||||
"step_scale": "Escalar",
|
||||
"step_scale_desc": "Anada usuarios, despliegue mas modelos e integre con sus herramientas existentes mediante claves API y servidores MCP.",
|
||||
"cta_title": "Listo para tomar el control de su infraestructura de IA?",
|
||||
"cta_subtitle": "Comience a desplegar IA generativa soberana hoy. No se requiere tarjeta de credito.",
|
||||
"get_started_free": "Comenzar gratis",
|
||||
"footer_tagline": "Infraestructura GenAI soberana para empresas.",
|
||||
"product": "Producto",
|
||||
"legal": "Legal",
|
||||
"resources": "Recursos",
|
||||
"documentation": "Documentacion",
|
||||
"api_reference": "Referencia API",
|
||||
"support": "Soporte",
|
||||
"copyright": "2026 CERTifAI. Todos los derechos reservados."
|
||||
},
|
||||
"article": {
|
||||
"read_original": "Leer articulo original",
|
||||
"summarizing": "Resumiendo...",
|
||||
"summarized_with_ai": "Resumido con IA",
|
||||
"ask_followup": "Haga una pregunta de seguimiento..."
|
||||
},
|
||||
"impressum": {
|
||||
"title": "Aviso legal",
|
||||
"info_tmg": "Informacion segun el 5 TMG",
|
||||
"company": "CERTifAI GmbH",
|
||||
"address_street": "Musterstrasse 1",
|
||||
"address_city": "10115 Berlin",
|
||||
"address_country": "Alemania",
|
||||
"represented_by": "Representado por",
|
||||
"managing_director": "Director general: [Name]",
|
||||
"contact": "Contacto",
|
||||
"email": "Correo electronico: info@certifai.example",
|
||||
"phone": "Telefono: +49 (0) 30 1234567",
|
||||
"commercial_register": "Registro mercantil",
|
||||
"registered_at": "Registrado en: Amtsgericht Berlin-Charlottenburg",
|
||||
"registration_number": "Numero de registro: HRB XXXXXX",
|
||||
"vat_id": "Numero de IVA",
|
||||
"vat_number": "Numero de identificacion fiscal segun 27a UStG: DE XXXXXXXXX",
|
||||
"responsible_content": "Responsable del contenido segun 55 Abs. 2 RStV"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Politica de privacidad",
|
||||
"last_updated": "Ultima actualizacion: febrero de 2026",
|
||||
"intro_title": "1. Introduccion",
|
||||
"intro_text": "CERTifAI GmbH (\"nosotros\", \"nuestro/a\") se compromete a proteger sus datos personales. Esta politica de privacidad explica como recopilamos, utilizamos y protegemos su informacion cuando utiliza nuestra plataforma.",
|
||||
"controller_title": "2. Responsable del tratamiento",
|
||||
"controller_address": "Musterstrasse 1, 10115 Berlin, Alemania",
|
||||
"controller_email": "Correo electronico: privacy@certifai.example",
|
||||
"data_title": "3. Datos que recopilamos",
|
||||
"data_intro": "Recopilamos unicamente los datos minimos necesarios para prestar nuestros servicios:",
|
||||
"data_account_label": "Datos de cuenta: ",
|
||||
"data_account_text": "Nombre, direccion de correo electronico y datos de la organizacion proporcionados durante el registro.",
|
||||
"data_usage_label": "Datos de uso: ",
|
||||
"data_usage_text": "Registros de llamadas API, recuento de tokens y metricas de uso de funcionalidades para facturacion y estadisticas.",
|
||||
"data_technical_label": "Datos tecnicos: ",
|
||||
"data_technical_text": "Direcciones IP, tipo de navegador e identificadores de sesion para la seguridad y estabilidad de la plataforma.",
|
||||
"use_title": "4. Como utilizamos sus datos",
|
||||
"use_1": "Para proporcionar y mantener la plataforma CERTifAI",
|
||||
"use_2": "Para gestionar su cuenta y suscripcion",
|
||||
"use_3": "Para comunicar actualizaciones del servicio y avisos de seguridad",
|
||||
"use_4": "Para cumplir con las obligaciones legales",
|
||||
"storage_title": "5. Almacenamiento y soberania de datos",
|
||||
"storage_text": "CERTifAI es una plataforma autoalojada. Todas las cargas de trabajo de IA, datos de modelos y resultados de inferencia permanecen completamente dentro de su propia infraestructura. No accedemos, almacenamos ni procesamos sus datos de IA en nuestros servidores.",
|
||||
"rights_title": "6. Sus derechos (RGPD)",
|
||||
"rights_intro": "Segun el RGPD, usted tiene derecho a:",
|
||||
"rights_access": "Acceder a sus datos personales",
|
||||
"rights_rectify": "Rectificar datos inexactos",
|
||||
"rights_erasure": "Solicitar la supresion de sus datos",
|
||||
"rights_restrict": "Limitar u oponerse al tratamiento",
|
||||
"rights_portability": "Portabilidad de datos",
|
||||
"rights_complaint": "Presentar una reclamacion ante una autoridad de control",
|
||||
"contact_title": "7. Contacto",
|
||||
"contact_text": "Para consultas relacionadas con la privacidad, contactenos en privacy@certifai.example."
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Chargement...",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"delete": "Supprimer",
|
||||
"send": "Envoyer",
|
||||
"close": "Fermer",
|
||||
"login": "Connexion",
|
||||
"logout": "Deconnexion",
|
||||
"on": "ON",
|
||||
"off": "OFF",
|
||||
"online": "En ligne",
|
||||
"offline": "Hors ligne",
|
||||
"settings": "Parametres",
|
||||
"search": "Rechercher",
|
||||
"rename": "Renommer",
|
||||
"copy": "Copier",
|
||||
"share": "Partager",
|
||||
"edit": "Modifier",
|
||||
"get_started": "Commencer",
|
||||
"coming_soon": "Bientot disponible",
|
||||
"back_to_home": "Retour a l'accueil",
|
||||
"privacy_policy": "Politique de confidentialite",
|
||||
"impressum": "Mentions legales",
|
||||
"chunks": "segments",
|
||||
"upload_file": "Importer un fichier",
|
||||
"eur_per_month": "EUR / mois",
|
||||
"up_to_seats": "Jusqu'a {n} postes",
|
||||
"unlimited_seats": "Postes illimites",
|
||||
"set": "Defini",
|
||||
"not_set": "Non defini",
|
||||
"log_in": "Se connecter",
|
||||
"features": "Fonctionnalites",
|
||||
"how_it_works": "Comment ca marche"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"providers": "Fournisseurs",
|
||||
"chat": "Chat",
|
||||
"developer": "Developpeur",
|
||||
"organization": "Organisation",
|
||||
"switch_light": "Passer en mode clair",
|
||||
"switch_dark": "Passer en mode sombre",
|
||||
"github": "GitHub",
|
||||
"agents": "Agents",
|
||||
"flow": "Flux",
|
||||
"analytics": "Analytique",
|
||||
"pricing": "Tarifs"
|
||||
},
|
||||
"auth": {
|
||||
"redirecting_login": "Redirection vers la connexion...",
|
||||
"redirecting_secure": "Redirection vers la page de connexion securisee...",
|
||||
"auth_error": "Erreur d'authentification : {msg}",
|
||||
"log_in": "Connexion"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"subtitle": "Actualites et mises a jour IA",
|
||||
"topic_placeholder": "Nom du sujet...",
|
||||
"ollama_settings": "Parametres Ollama",
|
||||
"settings_hint": "Laissez vide pour utiliser OLLAMA_URL / OLLAMA_MODEL du fichier .env",
|
||||
"ollama_url": "URL Ollama",
|
||||
"ollama_url_placeholder": "Utilise OLLAMA_URL du fichier .env",
|
||||
"model": "Modele",
|
||||
"model_placeholder": "Utilise OLLAMA_MODEL du fichier .env",
|
||||
"searching": "Recherche en cours...",
|
||||
"search_failed": "Echec de la recherche : {e}",
|
||||
"ollama_status": "Statut Ollama",
|
||||
"trending": "Tendances",
|
||||
"recent_searches": "Recherches recentes"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Fournisseurs",
|
||||
"subtitle": "Configurez vos backends LLM et d'embeddings",
|
||||
"provider": "Fournisseur",
|
||||
"model": "Modele",
|
||||
"embedding_model": "Modele d'embedding",
|
||||
"api_key": "Cle API",
|
||||
"api_key_placeholder": "Saisissez la cle API...",
|
||||
"save_config": "Enregistrer la configuration",
|
||||
"config_saved": "Configuration enregistree.",
|
||||
"active_config": "Configuration active",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"developer": {
|
||||
"agents_title": "Constructeur d'agents",
|
||||
"agents_desc": "Construisez et gerez des agents IA avec LangGraph. Creez des pipelines de raisonnement multi-etapes, des agents utilisant des outils et des flux de travail autonomes.",
|
||||
"launch_agents": "Lancer le constructeur d'agents",
|
||||
"flow_title": "Constructeur de flux",
|
||||
"flow_desc": "Concevez des flux de travail IA visuels avec LangFlow. Glissez-deposez des noeuds pour creer des pipelines de traitement de donnees, des chaines de prompts et des flux d'integration.",
|
||||
"launch_flow": "Lancer le constructeur de flux",
|
||||
"analytics_title": "Analytique et observabilite",
|
||||
"analytics_desc": "Surveillez et analysez vos pipelines IA avec LangFuse. Suivez l'utilisation des tokens, la latence, les couts et les metriques de qualite sur tous vos deployments.",
|
||||
"launch_analytics": "Lancer LangFuse",
|
||||
"total_requests": "Requetes totales",
|
||||
"avg_latency": "Latence moyenne",
|
||||
"tokens_used": "Tokens utilises",
|
||||
"error_rate": "Taux d'erreur"
|
||||
},
|
||||
"org": {
|
||||
"title": "Organisation",
|
||||
"subtitle": "Gerez les membres et la facturation",
|
||||
"invite_member": "Inviter un membre",
|
||||
"seats_used": "Postes utilises",
|
||||
"of_tokens": "sur {limit} tokens",
|
||||
"cycle_ends": "Fin du cycle",
|
||||
"name": "Nom",
|
||||
"email": "E-mail",
|
||||
"role": "Role",
|
||||
"joined": "Inscrit le",
|
||||
"invite_title": "Inviter un nouveau membre",
|
||||
"email_address": "Adresse e-mail",
|
||||
"email_placeholder": "collegue@entreprise.com",
|
||||
"send_invite": "Envoyer l'invitation",
|
||||
"pricing_title": "Tarifs",
|
||||
"pricing_subtitle": "Choisissez le plan adapte a votre organisation"
|
||||
},
|
||||
"pricing": {
|
||||
"starter": "Starter",
|
||||
"team": "Team",
|
||||
"enterprise": "Enterprise",
|
||||
"up_to_users": "Jusqu'a {n} utilisateurs",
|
||||
"unlimited_users": "Utilisateurs illimites",
|
||||
"llm_provider_1": "1 fournisseur LLM",
|
||||
"all_providers": "Tous les fournisseurs LLM",
|
||||
"tokens_100k": "100K tokens/mois",
|
||||
"tokens_1m": "1M tokens/mois",
|
||||
"unlimited_tokens": "Tokens illimites",
|
||||
"community_support": "Support communautaire",
|
||||
"priority_support": "Support prioritaire",
|
||||
"dedicated_support": "Support dedie",
|
||||
"basic_analytics": "Analytique de base",
|
||||
"advanced_analytics": "Analytique avancee",
|
||||
"full_observability": "Observabilite complete",
|
||||
"custom_mcp": "Outils MCP personnalises",
|
||||
"sso": "Integration SSO",
|
||||
"custom_integrations": "Integrations personnalisees",
|
||||
"sla": "Garantie SLA",
|
||||
"on_premise": "Deploiement sur site"
|
||||
},
|
||||
"landing": {
|
||||
"badge": "Infrastructure GenAI axee sur la confidentialite",
|
||||
"hero_title_1": "Votre IA. Vos donnees.",
|
||||
"hero_title_2": "Votre infrastructure.",
|
||||
"hero_subtitle": "Plateforme d'IA generative auto-hebergee et conforme au RGPD pour les entreprises qui refusent de compromettre leur souverainete des donnees. Deployez des LLM, des agents et des serveurs MCP selon vos propres conditions.",
|
||||
"learn_more": "En savoir plus",
|
||||
"social_proof": "Concu pour les entreprises qui valorisent la ",
|
||||
"data_sovereignty": "souverainete des donnees",
|
||||
"on_premise": "Sur site",
|
||||
"compliant": "Conforme",
|
||||
"data_residency": "Residence des donnees",
|
||||
"third_party": "Partage avec des tiers",
|
||||
"features_title": "Tout ce dont vous avez besoin",
|
||||
"features_subtitle": "Une pile GenAI complete et auto-hebergee sous votre controle total.",
|
||||
"feat_infra_title": "Infrastructure auto-hebergee",
|
||||
"feat_infra_desc": "Deployez sur votre propre materiel ou cloud prive. Controle total de votre pile IA sans dependances externes.",
|
||||
"feat_gdpr_title": "Conforme au RGPD",
|
||||
"feat_gdpr_desc": "Residence des donnees dans l'UE garantie. Vos donnees ne quittent jamais votre infrastructure et ne sont jamais partagees avec des tiers.",
|
||||
"feat_llm_title": "Gestion des LLM",
|
||||
"feat_llm_desc": "Deployez, surveillez et gerez plusieurs modeles de langage. Basculez entre les modeles sans interruption de service.",
|
||||
"feat_agent_title": "Constructeur d'agents",
|
||||
"feat_agent_desc": "Creez des agents IA personnalises avec Langchain et Langfuse integres pour une observabilite et un controle complets.",
|
||||
"feat_mcp_title": "Gestion des serveurs MCP",
|
||||
"feat_mcp_desc": "Gerez les serveurs Model Context Protocol pour etendre vos capacites IA avec des integrations d'outils externes.",
|
||||
"feat_api_title": "Gestion des cles API",
|
||||
"feat_api_desc": "Generez des cles API, suivez l'utilisation par poste et definissez des permissions granulaires pour chaque integration.",
|
||||
"how_title": "Operationnel en quelques minutes",
|
||||
"how_subtitle": "Trois etapes vers une infrastructure IA souveraine.",
|
||||
"step_deploy": "Deployer",
|
||||
"step_deploy_desc": "Installez CERTifAI sur votre infrastructure avec une seule commande. Compatible Docker, Kubernetes et bare metal.",
|
||||
"step_configure": "Configurer",
|
||||
"step_configure_desc": "Connectez votre fournisseur d'identite, selectionnez vos modeles et configurez les permissions d'equipe via le tableau de bord d'administration.",
|
||||
"step_scale": "Evoluer",
|
||||
"step_scale_desc": "Ajoutez des utilisateurs, deployez plus de modeles et integrez vos outils existants via des cles API et des serveurs MCP.",
|
||||
"cta_title": "Pret a prendre le controle de votre infrastructure IA ?",
|
||||
"cta_subtitle": "Commencez a deployer une IA generative souveraine des aujourd'hui. Aucune carte de credit requise.",
|
||||
"get_started_free": "Commencer gratuitement",
|
||||
"footer_tagline": "Infrastructure GenAI souveraine pour les entreprises.",
|
||||
"product": "Produit",
|
||||
"legal": "Mentions legales",
|
||||
"resources": "Ressources",
|
||||
"documentation": "Documentation",
|
||||
"api_reference": "Reference API",
|
||||
"support": "Support",
|
||||
"copyright": "2026 CERTifAI. Tous droits reserves."
|
||||
},
|
||||
"article": {
|
||||
"read_original": "Lire l'article original",
|
||||
"summarizing": "Resume en cours...",
|
||||
"summarized_with_ai": "Resume par IA",
|
||||
"ask_followup": "Posez une question complementaire..."
|
||||
},
|
||||
"impressum": {
|
||||
"title": "Mentions legales",
|
||||
"info_tmg": "Informations conformement au 5 TMG",
|
||||
"company": "CERTifAI GmbH",
|
||||
"address_street": "Musterstrasse 1",
|
||||
"address_city": "10115 Berlin",
|
||||
"address_country": "Allemagne",
|
||||
"represented_by": "Represente par",
|
||||
"managing_director": "Directeur general : [Nom]",
|
||||
"contact": "Contact",
|
||||
"email": "E-mail : info@certifai.example",
|
||||
"phone": "Telephone : +49 (0) 30 1234567",
|
||||
"commercial_register": "Registre du commerce",
|
||||
"registered_at": "Enregistre aupres de : Amtsgericht Berlin-Charlottenburg",
|
||||
"registration_number": "Numero d'immatriculation : HRB XXXXXX",
|
||||
"vat_id": "Numero de TVA",
|
||||
"vat_number": "Numero d'identification TVA conformement au 27a UStG : DE XXXXXXXXX",
|
||||
"responsible_content": "Responsable du contenu conformement au 55 al. 2 RStV"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Politique de confidentialite",
|
||||
"last_updated": "Derniere mise a jour : fevrier 2026",
|
||||
"intro_title": "1. Introduction",
|
||||
"intro_text": "CERTifAI GmbH (\"nous\", \"notre\", \"nos\") s'engage a proteger vos donnees personnelles. Cette politique de confidentialite explique comment nous collectons, utilisons et protegeons vos informations lorsque vous utilisez notre plateforme.",
|
||||
"controller_title": "2. Responsable du traitement",
|
||||
"controller_address": "Musterstrasse 1, 10115 Berlin, Allemagne",
|
||||
"controller_email": "E-mail : privacy@certifai.example",
|
||||
"data_title": "3. Donnees collectees",
|
||||
"data_intro": "Nous ne collectons que les donnees strictement necessaires a la fourniture de nos services :",
|
||||
"data_account_label": "Donnees de compte : ",
|
||||
"data_account_text": "Nom, adresse e-mail et informations sur l'organisation fournis lors de l'inscription.",
|
||||
"data_usage_label": "Donnees d'utilisation : ",
|
||||
"data_usage_text": "Journaux d'appels API, compteurs de tokens et metriques d'utilisation des fonctionnalites pour la facturation et l'analytique.",
|
||||
"data_technical_label": "Donnees techniques : ",
|
||||
"data_technical_text": "Adresses IP, type de navigateur et identifiants de session pour la securite et la stabilite de la plateforme.",
|
||||
"use_title": "4. Utilisation de vos donnees",
|
||||
"use_1": "Pour fournir et maintenir la plateforme CERTifAI",
|
||||
"use_2": "Pour gerer votre compte et votre abonnement",
|
||||
"use_3": "Pour communiquer les mises a jour du service et les avis de securite",
|
||||
"use_4": "Pour respecter les obligations legales",
|
||||
"storage_title": "5. Stockage des donnees et souverainete",
|
||||
"storage_text": "CERTifAI est une plateforme auto-hebergee. Toutes les charges de travail IA, les donnees de modeles et les resultats d'inference restent entierement au sein de votre propre infrastructure. Nous n'accedon pas, ne stockons pas et ne traitons pas vos donnees IA sur nos serveurs.",
|
||||
"rights_title": "6. Vos droits (RGPD)",
|
||||
"rights_intro": "En vertu du RGPD, vous avez le droit de :",
|
||||
"rights_access": "Acceder a vos donnees personnelles",
|
||||
"rights_rectify": "Rectifier des donnees inexactes",
|
||||
"rights_erasure": "Demander l'effacement de vos donnees",
|
||||
"rights_restrict": "Limiter ou vous opposer au traitement",
|
||||
"rights_portability": "Portabilite des donnees",
|
||||
"rights_complaint": "Deposer une plainte aupres d'une autorite de controle",
|
||||
"contact_title": "7. Contact",
|
||||
"contact_text": "Pour toute question relative a la confidentialite, contactez-nous a privacy@certifai.example."
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "A carregar...",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"delete": "Eliminar",
|
||||
"send": "Enviar",
|
||||
"close": "Fechar",
|
||||
"login": "Iniciar sessao",
|
||||
"logout": "Terminar sessao",
|
||||
"on": "LIGADO",
|
||||
"off": "DESLIGADO",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"settings": "Definicoes",
|
||||
"search": "Pesquisar",
|
||||
"rename": "Renomear",
|
||||
"copy": "Copiar",
|
||||
"share": "Partilhar",
|
||||
"edit": "Editar",
|
||||
"get_started": "Comecar",
|
||||
"coming_soon": "Em breve",
|
||||
"back_to_home": "Voltar ao inicio",
|
||||
"privacy_policy": "Politica de Privacidade",
|
||||
"impressum": "Impressum",
|
||||
"chunks": "fragmentos",
|
||||
"upload_file": "Carregar ficheiro",
|
||||
"eur_per_month": "EUR / mes",
|
||||
"up_to_seats": "Ate {n} lugares",
|
||||
"unlimited_seats": "Lugares ilimitados",
|
||||
"set": "Definido",
|
||||
"not_set": "Nao definido",
|
||||
"log_in": "Iniciar Sessao",
|
||||
"features": "Funcionalidades",
|
||||
"how_it_works": "Como Funciona"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Painel",
|
||||
"providers": "Fornecedores",
|
||||
"chat": "Chat",
|
||||
"developer": "Programador",
|
||||
"organization": "Organizacao",
|
||||
"switch_light": "Mudar para modo claro",
|
||||
"switch_dark": "Mudar para modo escuro",
|
||||
"github": "GitHub",
|
||||
"agents": "Agentes",
|
||||
"flow": "Fluxo",
|
||||
"analytics": "Analise",
|
||||
"pricing": "Precos"
|
||||
},
|
||||
"auth": {
|
||||
"redirecting_login": "A redirecionar para o inicio de sessao...",
|
||||
"redirecting_secure": "A redirecionar para a pagina de inicio de sessao segura...",
|
||||
"auth_error": "Erro de autenticacao: {msg}",
|
||||
"log_in": "Iniciar sessao"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Painel",
|
||||
"subtitle": "Noticias e atualizacoes de IA",
|
||||
"topic_placeholder": "Nome do topico...",
|
||||
"ollama_settings": "Definicoes do Ollama",
|
||||
"settings_hint": "Deixe vazio para usar OLLAMA_URL / OLLAMA_MODEL do .env",
|
||||
"ollama_url": "URL do Ollama",
|
||||
"ollama_url_placeholder": "Utiliza OLLAMA_URL do .env",
|
||||
"model": "Modelo",
|
||||
"model_placeholder": "Utiliza OLLAMA_MODEL do .env",
|
||||
"searching": "A pesquisar...",
|
||||
"search_failed": "A pesquisa falhou: {e}",
|
||||
"ollama_status": "Estado do Ollama",
|
||||
"trending": "Em destaque",
|
||||
"recent_searches": "Pesquisas recentes"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Fornecedores",
|
||||
"subtitle": "Configure os seus backends de LLM e embeddings",
|
||||
"provider": "Fornecedor",
|
||||
"model": "Modelo",
|
||||
"embedding_model": "Modelo de Embedding",
|
||||
"api_key": "Chave API",
|
||||
"api_key_placeholder": "Introduza a chave API...",
|
||||
"save_config": "Guardar Configuracao",
|
||||
"config_saved": "Configuracao guardada.",
|
||||
"active_config": "Configuracao Ativa",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"developer": {
|
||||
"agents_title": "Construtor de Agentes",
|
||||
"agents_desc": "Construa e gira agentes de IA com LangGraph. Crie pipelines de raciocinio multi-etapa, agentes com ferramentas e fluxos de trabalho autonomos.",
|
||||
"launch_agents": "Abrir Construtor de Agentes",
|
||||
"flow_title": "Construtor de Fluxos",
|
||||
"flow_desc": "Desenhe fluxos de trabalho de IA visuais com LangFlow. Arraste e solte nos para criar pipelines de processamento de dados, cadeias de prompts e fluxos de integracao.",
|
||||
"launch_flow": "Abrir Construtor de Fluxos",
|
||||
"analytics_title": "Analise e Observabilidade",
|
||||
"analytics_desc": "Monitorize e analise os seus pipelines de IA com LangFuse. Acompanhe o uso de tokens, latencia, custos e metricas de qualidade em todas as suas implementacoes.",
|
||||
"launch_analytics": "Abrir LangFuse",
|
||||
"total_requests": "Total de Pedidos",
|
||||
"avg_latency": "Latencia Media",
|
||||
"tokens_used": "Tokens Utilizados",
|
||||
"error_rate": "Taxa de Erros"
|
||||
},
|
||||
"org": {
|
||||
"title": "Organizacao",
|
||||
"subtitle": "Gerir membros e faturacao",
|
||||
"invite_member": "Convidar Membro",
|
||||
"seats_used": "Lugares Utilizados",
|
||||
"of_tokens": "de {limit} tokens",
|
||||
"cycle_ends": "Fim do Ciclo",
|
||||
"name": "Nome",
|
||||
"email": "Email",
|
||||
"role": "Funcao",
|
||||
"joined": "Aderiu",
|
||||
"invite_title": "Convidar Novo Membro",
|
||||
"email_address": "Endereco de Email",
|
||||
"email_placeholder": "colleague@company.com",
|
||||
"send_invite": "Enviar Convite",
|
||||
"pricing_title": "Precos",
|
||||
"pricing_subtitle": "Escolha o plano adequado a sua organizacao"
|
||||
},
|
||||
"pricing": {
|
||||
"starter": "Inicial",
|
||||
"team": "Equipa",
|
||||
"enterprise": "Empresarial",
|
||||
"up_to_users": "Ate {n} utilizadores",
|
||||
"unlimited_users": "Utilizadores ilimitados",
|
||||
"llm_provider_1": "1 fornecedor LLM",
|
||||
"all_providers": "Todos os fornecedores LLM",
|
||||
"tokens_100k": "100K tokens/mes",
|
||||
"tokens_1m": "1M tokens/mes",
|
||||
"unlimited_tokens": "Tokens ilimitados",
|
||||
"community_support": "Suporte comunitario",
|
||||
"priority_support": "Suporte prioritario",
|
||||
"dedicated_support": "Suporte dedicado",
|
||||
"basic_analytics": "Analise basica",
|
||||
"advanced_analytics": "Analise avancada",
|
||||
"full_observability": "Observabilidade completa",
|
||||
"custom_mcp": "Ferramentas MCP personalizadas",
|
||||
"sso": "Integracao SSO",
|
||||
"custom_integrations": "Integracoes personalizadas",
|
||||
"sla": "Garantia de SLA",
|
||||
"on_premise": "Implementacao on-premise"
|
||||
},
|
||||
"landing": {
|
||||
"badge": "Infraestrutura GenAI com Privacidade em Primeiro Lugar",
|
||||
"hero_title_1": "A Sua IA. Os Seus Dados.",
|
||||
"hero_title_2": "A Sua Infraestrutura.",
|
||||
"hero_subtitle": "Plataforma de IA generativa auto-alojada e em conformidade com o RGPD para empresas que nao comprometem a soberania dos dados. Implemente LLMs, agentes e servidores MCP nos seus proprios termos.",
|
||||
"learn_more": "Saber Mais",
|
||||
"social_proof": "Criado para empresas que valorizam a ",
|
||||
"data_sovereignty": "soberania dos dados",
|
||||
"on_premise": "On-Premise",
|
||||
"compliant": "Em Conformidade",
|
||||
"data_residency": "Residencia dos Dados",
|
||||
"third_party": "Partilha com Terceiros",
|
||||
"features_title": "Tudo o que Precisa",
|
||||
"features_subtitle": "Uma stack GenAI completa e auto-alojada sob o seu total controlo.",
|
||||
"feat_infra_title": "Infraestrutura Auto-Alojada",
|
||||
"feat_infra_desc": "Implemente no seu proprio hardware ou cloud privada. Controlo total sobre a sua stack de IA sem dependencias externas.",
|
||||
"feat_gdpr_title": "Em Conformidade com o RGPD",
|
||||
"feat_gdpr_desc": "Residencia de dados na UE garantida. Os seus dados nunca saem da sua infraestrutura nem sao partilhados com terceiros.",
|
||||
"feat_llm_title": "Gestao de LLMs",
|
||||
"feat_llm_desc": "Implemente, monitorize e gira multiplos modelos de linguagem. Alterne entre modelos sem tempo de inatividade.",
|
||||
"feat_agent_title": "Construtor de Agentes",
|
||||
"feat_agent_desc": "Crie agentes de IA personalizados com Langchain e Langfuse integrados para total observabilidade e controlo.",
|
||||
"feat_mcp_title": "Gestao de Servidores MCP",
|
||||
"feat_mcp_desc": "Gira servidores Model Context Protocol para expandir as capacidades da sua IA com integracoes de ferramentas externas.",
|
||||
"feat_api_title": "Gestao de Chaves API",
|
||||
"feat_api_desc": "Gere chaves API, acompanhe o uso por lugar e defina permissoes granulares para cada integracao.",
|
||||
"how_title": "Operacional em Minutos",
|
||||
"how_subtitle": "Tres passos para uma infraestrutura de IA soberana.",
|
||||
"step_deploy": "Implementar",
|
||||
"step_deploy_desc": "Instale o CERTifAI na sua infraestrutura com um unico comando. Suporte para Docker, Kubernetes e bare metal.",
|
||||
"step_configure": "Configurar",
|
||||
"step_configure_desc": "Ligue o seu fornecedor de identidade, selecione os seus modelos e configure as permissoes da equipa atraves do painel de administracao.",
|
||||
"step_scale": "Escalar",
|
||||
"step_scale_desc": "Adicione utilizadores, implemente mais modelos e integre com as suas ferramentas existentes atraves de chaves API e servidores MCP.",
|
||||
"cta_title": "Pronto para assumir o controlo da sua infraestrutura de IA?",
|
||||
"cta_subtitle": "Comece a implementar GenAI soberana hoje. Sem necessidade de cartao de credito.",
|
||||
"get_started_free": "Comecar Gratuitamente",
|
||||
"footer_tagline": "Infraestrutura GenAI soberana para empresas.",
|
||||
"product": "Produto",
|
||||
"legal": "Legal",
|
||||
"resources": "Recursos",
|
||||
"documentation": "Documentacao",
|
||||
"api_reference": "Referencia API",
|
||||
"support": "Suporte",
|
||||
"copyright": "2026 CERTifAI. Todos os direitos reservados."
|
||||
},
|
||||
"article": {
|
||||
"read_original": "Ler artigo original",
|
||||
"summarizing": "A resumir...",
|
||||
"summarized_with_ai": "Resumido com IA",
|
||||
"ask_followup": "Faca uma pergunta de seguimento..."
|
||||
},
|
||||
"impressum": {
|
||||
"title": "Impressum",
|
||||
"info_tmg": "Informacao de acordo com o 5 TMG",
|
||||
"company": "CERTifAI GmbH",
|
||||
"address_street": "Musterstrasse 1",
|
||||
"address_city": "10115 Berlim",
|
||||
"address_country": "Alemanha",
|
||||
"represented_by": "Representado por",
|
||||
"managing_director": "Diretor Geral: [Name]",
|
||||
"contact": "Contacto",
|
||||
"email": "Email: info@certifai.example",
|
||||
"phone": "Telefone: +49 (0) 30 1234567",
|
||||
"commercial_register": "Registo Comercial",
|
||||
"registered_at": "Registado em: Amtsgericht Berlin-Charlottenburg",
|
||||
"registration_number": "Numero de registo: HRB XXXXXX",
|
||||
"vat_id": "NIF",
|
||||
"vat_number": "Numero de identificacao fiscal de acordo com o 27a UStG: DE XXXXXXXXX",
|
||||
"responsible_content": "Responsavel pelo conteudo de acordo com o 55 Abs. 2 RStV"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Politica de Privacidade",
|
||||
"last_updated": "Ultima atualizacao: fevereiro de 2026",
|
||||
"intro_title": "1. Introducao",
|
||||
"intro_text": "A CERTifAI GmbH (\"nos\", \"nosso\", \"nossa\") esta empenhada em proteger os seus dados pessoais. Esta politica de privacidade explica como recolhemos, utilizamos e protegemos as suas informacoes quando utiliza a nossa plataforma.",
|
||||
"controller_title": "2. Responsavel pelo Tratamento de Dados",
|
||||
"controller_address": "Musterstrasse 1, 10115 Berlim, Alemanha",
|
||||
"controller_email": "Email: privacy@certifai.example",
|
||||
"data_title": "3. Dados que Recolhemos",
|
||||
"data_intro": "Recolhemos apenas os dados minimos necessarios para prestar os nossos servicos:",
|
||||
"data_account_label": "Dados da conta: ",
|
||||
"data_account_text": "Nome, endereco de email e detalhes da organizacao fornecidos durante o registo.",
|
||||
"data_usage_label": "Dados de utilizacao: ",
|
||||
"data_usage_text": "Registos de chamadas API, contagem de tokens e metricas de utilizacao de funcionalidades para faturacao e analise.",
|
||||
"data_technical_label": "Dados tecnicos: ",
|
||||
"data_technical_text": "Enderecos IP, tipo de navegador e identificadores de sessao para seguranca e estabilidade da plataforma.",
|
||||
"use_title": "4. Como Utilizamos os Seus Dados",
|
||||
"use_1": "Para fornecer e manter a plataforma CERTifAI",
|
||||
"use_2": "Para gerir a sua conta e subscricao",
|
||||
"use_3": "Para comunicar atualizacoes do servico e avisos de seguranca",
|
||||
"use_4": "Para cumprir obrigacoes legais",
|
||||
"storage_title": "5. Armazenamento e Soberania dos Dados",
|
||||
"storage_text": "O CERTifAI e uma plataforma auto-alojada. Todas as cargas de trabalho de IA, dados de modelos e resultados de inferencia permanecem inteiramente dentro da sua propria infraestrutura. Nao acedemos, armazenamos nem processamos os seus dados de IA nos nossos servidores.",
|
||||
"rights_title": "6. Os Seus Direitos (RGPD)",
|
||||
"rights_intro": "Ao abrigo do RGPD, tem o direito de:",
|
||||
"rights_access": "Aceder aos seus dados pessoais",
|
||||
"rights_rectify": "Retificar dados incorretos",
|
||||
"rights_erasure": "Solicitar a eliminacao dos seus dados",
|
||||
"rights_restrict": "Restringir ou opor-se ao tratamento",
|
||||
"rights_portability": "Portabilidade dos dados",
|
||||
"rights_complaint": "Apresentar uma reclamacao junto de uma autoridade de supervisao",
|
||||
"contact_title": "7. Contacto",
|
||||
"contact_text": "Para questoes relacionadas com privacidade, contacte-nos em privacy@certifai.example."
|
||||
}
|
||||
}
|
||||
2728
assets/main.css
2728
assets/main.css
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "CERTifAI Dashboard",
|
||||
"short_name": "CERTifAI",
|
||||
"description": "Self-hosted GenAI infrastructure management dashboard",
|
||||
"start_url": "/dashboard",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f1117",
|
||||
"theme_color": "#4B3FE0",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/logo.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
67
assets/sw.js
67
assets/sw.js
@@ -1,67 +0,0 @@
|
||||
// CERTifAI Service Worker — network-first with offline fallback cache.
|
||||
// Static assets (CSS, JS, WASM, fonts) use cache-first for speed.
|
||||
// API and navigation requests always try the network first.
|
||||
|
||||
const CACHE_NAME = "certifai-v1";
|
||||
|
||||
// Pre-cache the app shell on install
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) =>
|
||||
cache.addAll([
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/assets/logo.svg",
|
||||
"/assets/favicon.svg",
|
||||
])
|
||||
)
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Clean up old caches on activate
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Skip non-GET and API requests (let them go straight to network)
|
||||
if (event.request.method !== "GET" || url.pathname.startsWith("/api/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for static assets (hashed filenames make this safe)
|
||||
const isStatic = /\.(js|wasm|css|ico|svg|png|jpg|woff2?)(\?|$)/.test(url.pathname);
|
||||
if (isStatic) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) =>
|
||||
cached || fetch(event.request).then((resp) => {
|
||||
const clone = resp.clone();
|
||||
caches.open(CACHE_NAME).then((c) => c.put(event.request, clone));
|
||||
return resp;
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for navigation / HTML
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((resp) => {
|
||||
const clone = resp.clone();
|
||||
caches.open(CACHE_NAME).then((c) => c.put(event.request, clone));
|
||||
return resp;
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
);
|
||||
});
|
||||
@@ -162,289 +162,6 @@
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.diff {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
webkit-user-select: none;
|
||||
user-select: none;
|
||||
grid-template-rows: 1fr 1.8rem 1fr;
|
||||
direction: ltr;
|
||||
container-type: inline-size;
|
||||
grid-template-columns: auto 1fr;
|
||||
&:focus-visible, &:has(.diff-item-1:focus-visible) {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 2px;
|
||||
outline-offset: 1px;
|
||||
outline-color: var(--color-base-content);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 2px;
|
||||
outline-offset: 1px;
|
||||
outline-color: var(--color-base-content);
|
||||
.diff-resizer {
|
||||
min-width: 95cqi;
|
||||
max-width: 95cqi;
|
||||
}
|
||||
}
|
||||
&:has(.diff-item-1:focus-visible) {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 2px;
|
||||
outline-offset: 1px;
|
||||
.diff-resizer {
|
||||
min-width: 5cqi;
|
||||
max-width: 5cqi;
|
||||
}
|
||||
}
|
||||
@supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
|
||||
&:focus {
|
||||
.diff-resizer {
|
||||
min-width: 5cqi;
|
||||
max-width: 5cqi;
|
||||
}
|
||||
}
|
||||
&:has(.diff-item-1:focus) {
|
||||
.diff-resizer {
|
||||
min-width: 95cqi;
|
||||
max-width: 95cqi;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.modal {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
inset: calc(0.25rem * 0);
|
||||
margin: calc(0.25rem * 0);
|
||||
display: grid;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
background-color: transparent;
|
||||
padding: calc(0.25rem * 0);
|
||||
color: inherit;
|
||||
transition: visibility 0.3s allow-discrete, background-color 0.3s ease-out, opacity 0.1s ease-out;
|
||||
overflow: clip;
|
||||
overscroll-behavior: contain;
|
||||
z-index: 999;
|
||||
scrollbar-gutter: auto;
|
||||
&::backdrop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@layer daisyui.l1.l2 {
|
||||
&.modal-open, &[open], &:target, .modal-toggle:checked + & {
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
opacity: 100%;
|
||||
transition: visibility 0s allow-discrete, background-color 0.3s ease-out, opacity 0.1s ease-out;
|
||||
background-color: oklch(0% 0 0/ 0.4);
|
||||
.modal-box {
|
||||
translate: 0 0;
|
||||
scale: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
:root:has(&) {
|
||||
--page-has-backdrop: 1;
|
||||
--page-overflow: hidden;
|
||||
--page-scroll-bg: var(--page-scroll-bg-on);
|
||||
--page-scroll-gutter: stable;
|
||||
--page-scroll-transition: var(--page-scroll-transition-on);
|
||||
animation: set-page-has-scroll forwards;
|
||||
animation-timeline: scroll();
|
||||
}
|
||||
}
|
||||
@starting-style {
|
||||
&.modal-open, &[open], &:target, .modal-toggle:checked + & {
|
||||
opacity: 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.tab {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
webkit-user-select: none;
|
||||
user-select: none;
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
}
|
||||
--tab-p: 0.75rem;
|
||||
--tab-bg: var(--color-base-100);
|
||||
--tab-border-color: var(--color-base-300);
|
||||
--tab-radius-ss: 0;
|
||||
--tab-radius-se: 0;
|
||||
--tab-radius-es: 0;
|
||||
--tab-radius-ee: 0;
|
||||
--tab-order: 0;
|
||||
--tab-radius-min: calc(0.75rem - var(--border));
|
||||
--tab-radius-limit: min(var(--radius-field), var(--tab-radius-min));
|
||||
--tab-radius-grad: #0000 calc(69% - var(--border)),
|
||||
var(--tab-border-color) calc(69% - var(--border) + 0.25px),
|
||||
var(--tab-border-color) 69%,
|
||||
var(--tab-bg) calc(69% + 0.25px);
|
||||
border-color: #0000;
|
||||
order: var(--tab-order);
|
||||
height: var(--tab-height);
|
||||
font-size: 0.875rem;
|
||||
padding-inline: var(--tab-p);
|
||||
&:is(input[type="radio"]) {
|
||||
min-width: fit-content;
|
||||
&:after {
|
||||
--tw-content: attr(aria-label);
|
||||
content: var(--tw-content);
|
||||
}
|
||||
}
|
||||
&:is(label) {
|
||||
position: relative;
|
||||
input {
|
||||
position: absolute;
|
||||
inset: calc(0.25rem * 0);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
opacity: 0%;
|
||||
}
|
||||
}
|
||||
&:checked, &:is(label:has(:checked)), &:is(.tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"]) {
|
||||
& + .tab-content {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
&:not( :checked, label:has(:checked), :hover, .tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"] ) {
|
||||
color: var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
}
|
||||
}
|
||||
&:not(input):empty {
|
||||
flex-grow: 1;
|
||||
cursor: default;
|
||||
}
|
||||
&:focus {
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
@media (forced-colors: active) {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
&:focus-visible, &:is(label:has(:checked:focus-visible)) {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: -5px;
|
||||
}
|
||||
&[disabled] {
|
||||
pointer-events: none;
|
||||
opacity: 40%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.dropdown {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
position-area: var(--anchor-v, bottom) var(--anchor-h, span-right);
|
||||
& > *:not(:has(~ [class*="dropdown-content"])):focus {
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
@media (forced-colors: active) {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
.dropdown-content {
|
||||
position: absolute;
|
||||
}
|
||||
&.dropdown-close .dropdown-content, &:not(details, .dropdown-open, .dropdown-hover:hover, :focus-within) .dropdown-content, &.dropdown-hover:not(:hover) [tabindex]:first-child:focus:not(:focus-visible) ~ .dropdown-content {
|
||||
display: none;
|
||||
transform-origin: top;
|
||||
opacity: 0%;
|
||||
scale: 95%;
|
||||
}
|
||||
&[popover], .dropdown-content {
|
||||
z-index: 999;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: dropdown 0.2s;
|
||||
transition-property: opacity, scale, display;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-duration: 0.2s;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
@starting-style {
|
||||
&[popover], .dropdown-content {
|
||||
scale: 95%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
&:not(.dropdown-close) {
|
||||
&.dropdown-open, &:not(.dropdown-hover):focus, &:focus-within {
|
||||
> [tabindex]:first-child {
|
||||
pointer-events: none;
|
||||
}
|
||||
.dropdown-content {
|
||||
opacity: 100%;
|
||||
scale: 100%;
|
||||
}
|
||||
}
|
||||
&.dropdown-hover:hover {
|
||||
.dropdown-content {
|
||||
opacity: 100%;
|
||||
scale: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:is(details) {
|
||||
summary {
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:where([popover]) {
|
||||
background: #0000;
|
||||
}
|
||||
&[popover] {
|
||||
position: fixed;
|
||||
color: inherit;
|
||||
@supports not (position-area: bottom) {
|
||||
margin: auto;
|
||||
&.dropdown-close, &.dropdown-open:not(:popover-open) {
|
||||
display: none;
|
||||
transform-origin: top;
|
||||
opacity: 0%;
|
||||
scale: 95%;
|
||||
}
|
||||
&::backdrop {
|
||||
background-color: color-mix(in oklab, #000 30%, #0000);
|
||||
}
|
||||
}
|
||||
&.dropdown-close, &:not(.dropdown-open, :popover-open) {
|
||||
display: none;
|
||||
transform-origin: top;
|
||||
opacity: 0%;
|
||||
scale: 95%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.btn {
|
||||
:where(&) {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
@@ -597,65 +314,6 @@
|
||||
.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
.list {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.875rem;
|
||||
.list-row {
|
||||
--list-grid-cols: minmax(0, auto) 1fr;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: calc(0.25rem * 4);
|
||||
border-radius: var(--radius-box);
|
||||
padding: calc(0.25rem * 4);
|
||||
word-break: break-word;
|
||||
grid-template-columns: var(--list-grid-cols);
|
||||
}
|
||||
& > :not(:last-child) {
|
||||
&.list-row, .list-row {
|
||||
&:after {
|
||||
content: "";
|
||||
border-bottom: var(--border) solid;
|
||||
inset-inline: var(--radius-box);
|
||||
position: absolute;
|
||||
bottom: calc(0.25rem * 0);
|
||||
border-color: var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
border-color: color-mix(in oklab, var(--color-base-content) 5%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@layer daisyui.l1.l2 {
|
||||
.list-row {
|
||||
&:has(.list-col-grow:nth-child(1)) {
|
||||
--list-grid-cols: 1fr;
|
||||
}
|
||||
&:has(.list-col-grow:nth-child(2)) {
|
||||
--list-grid-cols: minmax(0, auto) 1fr;
|
||||
}
|
||||
&:has(.list-col-grow:nth-child(3)) {
|
||||
--list-grid-cols: minmax(0, auto) minmax(0, auto) 1fr;
|
||||
}
|
||||
&:has(.list-col-grow:nth-child(4)) {
|
||||
--list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr;
|
||||
}
|
||||
&:has(.list-col-grow:nth-child(5)) {
|
||||
--list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr;
|
||||
}
|
||||
&:has(.list-col-grow:nth-child(6)) {
|
||||
--list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto)
|
||||
minmax(0, auto) 1fr;
|
||||
}
|
||||
> * {
|
||||
grid-row-start: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toggle {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
border: var(--border) solid currentColor;
|
||||
@@ -931,75 +589,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.table {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
font-size: 0.875rem;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
--tw-border-spacing-x: calc(0.25rem * 0);
|
||||
--tw-border-spacing-y: calc(0.25rem * 0);
|
||||
border-spacing: var(--tw-border-spacing-x) var(--tw-border-spacing-y);
|
||||
border-radius: var(--radius-box);
|
||||
text-align: left;
|
||||
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
|
||||
text-align: right;
|
||||
}
|
||||
tr.row-hover {
|
||||
&, &:nth-child(even) {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
:where(th, td) {
|
||||
padding-inline: calc(0.25rem * 4);
|
||||
padding-block: calc(0.25rem * 3);
|
||||
vertical-align: middle;
|
||||
}
|
||||
:where(thead, tfoot) {
|
||||
white-space: nowrap;
|
||||
color: var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
|
||||
}
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
:where(tfoot tr:first-child :is(td, th)) {
|
||||
border-top: var(--border) solid var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
border-top: var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000);
|
||||
}
|
||||
}
|
||||
:where(.table-pin-rows thead tr) {
|
||||
position: sticky;
|
||||
top: calc(0.25rem * 0);
|
||||
z-index: 1;
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
:where(.table-pin-rows tfoot tr) {
|
||||
position: sticky;
|
||||
bottom: calc(0.25rem * 0);
|
||||
z-index: 1;
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
:where(.table-pin-cols tr th) {
|
||||
position: sticky;
|
||||
right: calc(0.25rem * 0);
|
||||
left: calc(0.25rem * 0);
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
:where(thead tr :is(td, th), tbody tr:not(:last-child) :is(td, th)) {
|
||||
border-bottom: var(--border) solid var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
border-bottom: var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.steps {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: inline-grid;
|
||||
@@ -1110,34 +699,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.chat-bubble {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-base-300);
|
||||
padding-inline: calc(0.25rem * 4);
|
||||
padding-block: calc(0.25rem * 2);
|
||||
color: var(--color-base-content);
|
||||
grid-row-end: 3;
|
||||
min-height: 2rem;
|
||||
min-width: 2.5rem;
|
||||
max-width: 90%;
|
||||
&:before {
|
||||
position: absolute;
|
||||
bottom: calc(0.25rem * 0);
|
||||
height: calc(0.25rem * 3);
|
||||
width: calc(0.25rem * 3);
|
||||
background-color: inherit;
|
||||
content: "";
|
||||
mask-repeat: no-repeat;
|
||||
mask-image: var(--mask-chat);
|
||||
mask-position: 0px -1px;
|
||||
mask-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
.select {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
border: var(--border) solid #0000;
|
||||
@@ -1373,15 +934,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.stats {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
grid-auto-flow: column;
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
}
|
||||
.progress {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
position: relative;
|
||||
@@ -1432,6 +984,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
@@ -1444,76 +999,6 @@
|
||||
.end {
|
||||
inset-inline-end: var(--spacing);
|
||||
}
|
||||
.join {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
--join-ss: 0;
|
||||
--join-se: 0;
|
||||
--join-es: 0;
|
||||
--join-ee: 0;
|
||||
:where(.join-item) {
|
||||
border-start-start-radius: var(--join-ss, 0);
|
||||
border-start-end-radius: var(--join-se, 0);
|
||||
border-end-start-radius: var(--join-es, 0);
|
||||
border-end-end-radius: var(--join-ee, 0);
|
||||
* {
|
||||
--join-ss: var(--radius-field);
|
||||
--join-se: var(--radius-field);
|
||||
--join-es: var(--radius-field);
|
||||
--join-ee: var(--radius-field);
|
||||
}
|
||||
}
|
||||
> .join-item:where(:first-child) {
|
||||
--join-ss: var(--radius-field);
|
||||
--join-se: 0;
|
||||
--join-es: var(--radius-field);
|
||||
--join-ee: 0;
|
||||
}
|
||||
:first-child:not(:last-child) {
|
||||
:where(.join-item) {
|
||||
--join-ss: var(--radius-field);
|
||||
--join-se: 0;
|
||||
--join-es: var(--radius-field);
|
||||
--join-ee: 0;
|
||||
}
|
||||
}
|
||||
> .join-item:where(:last-child) {
|
||||
--join-ss: 0;
|
||||
--join-se: var(--radius-field);
|
||||
--join-es: 0;
|
||||
--join-ee: var(--radius-field);
|
||||
}
|
||||
:last-child:not(:first-child) {
|
||||
:where(.join-item) {
|
||||
--join-ss: 0;
|
||||
--join-se: var(--radius-field);
|
||||
--join-es: 0;
|
||||
--join-ee: var(--radius-field);
|
||||
}
|
||||
}
|
||||
> .join-item:where(:only-child) {
|
||||
--join-ss: var(--radius-field);
|
||||
--join-se: var(--radius-field);
|
||||
--join-es: var(--radius-field);
|
||||
--join-ee: var(--radius-field);
|
||||
}
|
||||
:only-child {
|
||||
:where(.join-item) {
|
||||
--join-ss: var(--radius-field);
|
||||
--join-se: var(--radius-field);
|
||||
--join-es: var(--radius-field);
|
||||
--join-ee: var(--radius-field);
|
||||
}
|
||||
}
|
||||
> :where(:focus, :has(:focus)) {
|
||||
z-index: 1;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
> :where(.btn:hover, :has(.btn:hover)) {
|
||||
isolation: isolate;
|
||||
}
|
||||
}
|
||||
}
|
||||
.hero-content {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
isolation: isolate;
|
||||
@@ -1525,81 +1010,6 @@
|
||||
padding: calc(0.25rem * 4);
|
||||
}
|
||||
}
|
||||
.textarea {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
border: var(--border) solid #0000;
|
||||
min-height: calc(0.25rem * 20);
|
||||
flex-shrink: 1;
|
||||
appearance: none;
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-base-100);
|
||||
padding-block: calc(0.25rem * 2);
|
||||
vertical-align: middle;
|
||||
width: clamp(3rem, 20rem, 100%);
|
||||
padding-inline-start: 0.75rem;
|
||||
padding-inline-end: 0.75rem;
|
||||
font-size: max(var(--font-size, 0.875rem), 0.875rem);
|
||||
touch-action: manipulation;
|
||||
border-color: var(--input-color);
|
||||
box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
|
||||
}
|
||||
--input-color: var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
--input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
}
|
||||
textarea {
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
&:focus, &:focus-within {
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
@media (forced-colors: active) {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:focus, &:focus-within {
|
||||
--input-color: var(--color-base-content);
|
||||
box-shadow: 0 1px var(--input-color);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
|
||||
}
|
||||
outline: 2px solid var(--input-color);
|
||||
outline-offset: 2px;
|
||||
isolation: isolate;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
&:focus, &:focus-within {
|
||||
--font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:has(> textarea[disabled]), &:is(:disabled, [disabled]) {
|
||||
cursor: not-allowed;
|
||||
border-color: var(--color-base-200);
|
||||
background-color: var(--color-base-200);
|
||||
color: var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
|
||||
}
|
||||
&::placeholder {
|
||||
color: var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
|
||||
}
|
||||
}
|
||||
box-shadow: none;
|
||||
}
|
||||
&:has(> textarea[disabled]) > textarea[disabled] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
.stack {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: inline-grid;
|
||||
@@ -1712,51 +1122,6 @@
|
||||
max-width: 96rem;
|
||||
}
|
||||
}
|
||||
.filter {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
input[type="radio"] {
|
||||
width: auto;
|
||||
}
|
||||
input {
|
||||
overflow: hidden;
|
||||
opacity: 100%;
|
||||
scale: 1;
|
||||
transition: margin 0.1s, opacity 0.3s, padding 0.3s, border-width 0.1s;
|
||||
&:not(:last-child) {
|
||||
margin-inline-end: calc(0.25rem * 1);
|
||||
}
|
||||
&.filter-reset {
|
||||
aspect-ratio: 1 / 1;
|
||||
&::after {
|
||||
--tw-content: "×";
|
||||
content: var(--tw-content);
|
||||
}
|
||||
}
|
||||
}
|
||||
&:not(:has(input:checked:not(.filter-reset))) {
|
||||
.filter-reset, input[type="reset"] {
|
||||
scale: 0;
|
||||
border-width: 0;
|
||||
margin-inline: calc(0.25rem * 0);
|
||||
width: calc(0.25rem * 0);
|
||||
padding-inline: calc(0.25rem * 0);
|
||||
opacity: 0%;
|
||||
}
|
||||
}
|
||||
&:has(input:checked:not(.filter-reset)) {
|
||||
input:not(:checked, .filter-reset, input[type="reset"]) {
|
||||
scale: 0;
|
||||
border-width: 0;
|
||||
margin-inline: calc(0.25rem * 0);
|
||||
width: calc(0.25rem * 0);
|
||||
padding-inline: calc(0.25rem * 0);
|
||||
opacity: 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.label {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: inline-flex;
|
||||
@@ -1843,17 +1208,6 @@
|
||||
padding-inline: calc(var(--size) / 2 - var(--border));
|
||||
}
|
||||
}
|
||||
.tabs {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
--tabs-height: auto;
|
||||
--tabs-direction: row;
|
||||
--tab-height: calc(var(--size-field, 0.25rem) * 10);
|
||||
height: var(--tabs-height);
|
||||
flex-direction: var(--tabs-direction);
|
||||
}
|
||||
}
|
||||
.footer {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: grid;
|
||||
@@ -1879,15 +1233,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.chat {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: grid;
|
||||
grid-auto-rows: min-content;
|
||||
column-gap: calc(0.25rem * 3);
|
||||
padding-block: calc(0.25rem * 1);
|
||||
--mask-chat: url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");
|
||||
}
|
||||
}
|
||||
.card-title {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
display: flex;
|
||||
@@ -1903,12 +1248,6 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.transform {
|
||||
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
||||
}
|
||||
@@ -1938,23 +1277,12 @@
|
||||
border-color: currentColor;
|
||||
}
|
||||
}
|
||||
.glass {
|
||||
border: none;
|
||||
backdrop-filter: blur(var(--glass-blur, 40px));
|
||||
background-color: #0000;
|
||||
background-image: linear-gradient( 135deg, oklch(100% 0 0 / var(--glass-opacity, 30%)) 0%, oklch(0% 0 0 / 0%) 100% ), linear-gradient( var(--glass-reflect-degree, 100deg), oklch(100% 0 0 / var(--glass-reflect-opacity, 5%)) 25%, oklch(0% 0 0 / 0%) 25% );
|
||||
box-shadow: 0 0 0 1px oklch(100% 0 0 / var(--glass-border-opacity, 20%)) inset, 0 0 0 2px oklch(0% 0 0 / 5%);
|
||||
text-shadow: 0 1px oklch(0% 0 0 / var(--glass-text-shadow-opacity, 5%));
|
||||
}
|
||||
.p-6 {
|
||||
padding: calc(var(--spacing) * 6);
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.lowercase {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.outline {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
@@ -1983,9 +1311,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter {
|
||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||
}
|
||||
.btn-outline {
|
||||
@layer daisyui.l1 {
|
||||
&:not( .btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn), :disabled, [disabled], .btn-disabled ) {
|
||||
@@ -2026,12 +1351,6 @@
|
||||
--btn-fg: var(--color-primary-content);
|
||||
}
|
||||
}
|
||||
.btn-secondary {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
--btn-color: var(--color-secondary);
|
||||
--btn-fg: var(--color-secondary-content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
|
||||
@@ -2405,59 +1724,6 @@
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@property --tw-blur {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-brightness {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-contrast {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-grayscale {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-hue-rotate {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-invert {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-opacity {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-saturate {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-sepia {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-drop-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-drop-shadow-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-drop-shadow-alpha {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 100%;
|
||||
}
|
||||
@property --tw-drop-shadow-size {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@layer properties {
|
||||
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
||||
*, ::before, ::after, ::backdrop {
|
||||
@@ -2467,19 +1733,6 @@
|
||||
--tw-skew-x: initial;
|
||||
--tw-skew-y: initial;
|
||||
--tw-outline-style: solid;
|
||||
--tw-blur: initial;
|
||||
--tw-brightness: initial;
|
||||
--tw-contrast: initial;
|
||||
--tw-grayscale: initial;
|
||||
--tw-hue-rotate: initial;
|
||||
--tw-invert: initial;
|
||||
--tw-opacity: initial;
|
||||
--tw-saturate: initial;
|
||||
--tw-sepia: initial;
|
||||
--tw-drop-shadow: initial;
|
||||
--tw-drop-shadow-color: initial;
|
||||
--tw-drop-shadow-alpha: 100%;
|
||||
--tw-drop-shadow-size: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
bun.lock
9
bun.lock
@@ -8,7 +8,6 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -17,8 +16,6 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -27,12 +24,6 @@
|
||||
|
||||
"daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.0
|
||||
container_name: certifai-keycloak
|
||||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_DB: dev-mem
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
command:
|
||||
@@ -14,13 +15,11 @@ services:
|
||||
- --import-realm
|
||||
volumes:
|
||||
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
||||
- ./keycloak/themes/certifai:/opt/keycloak/themes/certifai:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/master HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && head -1 <&3 | grep -q '200 OK'"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
retries: 5
|
||||
|
||||
mongo:
|
||||
image: mongo:latest
|
||||
@@ -29,70 +28,4 @@ services:
|
||||
- 27017:27017
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
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
|
||||
|
||||
librechat:
|
||||
image: ghcr.io/danny-avila/librechat:latest
|
||||
container_name: certifai-librechat
|
||||
restart: unless-stopped
|
||||
# Use host networking so localhost:8080 (Keycloak) is reachable for
|
||||
# OIDC discovery, and the browser redirect URLs match the issuer.
|
||||
network_mode: host
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
mongo:
|
||||
condition: service_started
|
||||
environment:
|
||||
# MongoDB (use localhost since we're on host network)
|
||||
MONGO_URI: mongodb://root:example@localhost:27017/librechat?authSource=admin
|
||||
DOMAIN_CLIENT: http://localhost:3080
|
||||
DOMAIN_SERVER: http://localhost:3080
|
||||
# Allow HTTP for local dev OIDC (Keycloak on localhost without TLS)
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||
NODE_ENV: development
|
||||
# Keycloak OIDC SSO
|
||||
OPENID_ISSUER: http://localhost:8080/realms/certifai
|
||||
OPENID_CLIENT_ID: certifai-librechat
|
||||
OPENID_CLIENT_SECRET: certifai-librechat-secret
|
||||
OPENID_SESSION_SECRET: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6"
|
||||
OPENID_CALLBACK_URL: /oauth/openid/callback
|
||||
OPENID_SCOPE: openid profile email
|
||||
OPENID_BUTTON_LABEL: Login with CERTifAI
|
||||
OPENID_AUTH_EXTRA_PARAMS: prompt=none
|
||||
# Disable local auth (SSO only)
|
||||
ALLOW_EMAIL_LOGIN: "false"
|
||||
ALLOW_REGISTRATION: "false"
|
||||
ALLOW_SOCIAL_LOGIN: "true"
|
||||
ALLOW_SOCIAL_REGISTRATION: "true"
|
||||
# JWT / encryption secrets (required by LibreChat)
|
||||
CREDS_KEY: "97e95d72cdda06774a264f9fb7768097a6815dc1e930898d2e39c9a3a253b157"
|
||||
CREDS_IV: "2ea456ab25279089b0ff9e7aca1df6e6"
|
||||
JWT_SECRET: "767b962176666eab56e180e6f2d3fe95145dc6b978e37d4eb8d1da5421c5fb26"
|
||||
JWT_REFRESH_SECRET: "51a43a1fca4b7b501b37e226a638645d962066e0686b82248921f3160e96501e"
|
||||
# App settings
|
||||
APP_TITLE: CERTifAI Chat
|
||||
CUSTOM_FOOTER: CERTifAI - Sovereign GenAI Infrastructure
|
||||
HOST: 0.0.0.0
|
||||
PORT: "3080"
|
||||
NO_INDEX: "true"
|
||||
volumes:
|
||||
- ./librechat/librechat.yaml:/app/librechat.yaml:ro
|
||||
- ./librechat/logo.svg:/app/client/public/assets/logo.svg:ro
|
||||
# Patch: allow HTTP issuer for local dev (openid-client v6 enforces HTTPS)
|
||||
- ./librechat/openidStrategy.js:/app/api/strategies/openidStrategy.js:ro
|
||||
- librechat-data:/app/data
|
||||
|
||||
volumes:
|
||||
librechat-data:
|
||||
MONGO_INITDB_ROOT_PASSWORD: example
|
||||
@@ -1,24 +0,0 @@
|
||||
import { test as setup, expect } from "@playwright/test";
|
||||
|
||||
const AUTH_FILE = "e2e/.auth/user.json";
|
||||
|
||||
setup("authenticate via Keycloak", async ({ page }) => {
|
||||
// Navigate to a protected route to trigger the auth redirect chain:
|
||||
// /dashboard -> /auth (Axum) -> Keycloak login page
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Wait for Keycloak login form to appear
|
||||
await page.waitForSelector("#username", { timeout: 15_000 });
|
||||
|
||||
// Fill Keycloak credentials
|
||||
await page.fill("#username", process.env.TEST_USER ?? "admin@certifai.local");
|
||||
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
|
||||
await page.click("#kc-login");
|
||||
|
||||
// Wait for redirect back to the app dashboard
|
||||
await page.waitForURL("**/dashboard", { timeout: 15_000 });
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
|
||||
// Persist authenticated state (cookies + localStorage)
|
||||
await page.context().storageState({ path: AUTH_FILE });
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// These tests use a fresh browser context (no saved auth state)
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.describe("Authentication flow", () => {
|
||||
test("unauthenticated visit to /dashboard redirects to Keycloak", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Should end up on Keycloak login page
|
||||
await page.waitForSelector("#username", { timeout: 15_000 });
|
||||
await expect(page.locator("#kc-login")).toBeVisible();
|
||||
});
|
||||
|
||||
test("valid credentials log in and redirect to dashboard", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.waitForSelector("#username", { timeout: 15_000 });
|
||||
|
||||
await page.fill(
|
||||
"#username",
|
||||
process.env.TEST_USER ?? "admin@certifai.local"
|
||||
);
|
||||
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
|
||||
await page.click("#kc-login");
|
||||
|
||||
await page.waitForURL("**/dashboard", { timeout: 15_000 });
|
||||
await expect(page.locator(".dashboard-page")).toBeVisible();
|
||||
});
|
||||
|
||||
test("dashboard shows sidebar with user info after login", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.waitForSelector("#username", { timeout: 15_000 });
|
||||
|
||||
await page.fill(
|
||||
"#username",
|
||||
process.env.TEST_USER ?? "admin@certifai.local"
|
||||
);
|
||||
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
|
||||
await page.click("#kc-login");
|
||||
|
||||
await page.waitForURL("**/dashboard", { timeout: 15_000 });
|
||||
await expect(page.locator(".sidebar-name")).toBeVisible();
|
||||
await expect(page.locator(".sidebar-email")).toBeVisible();
|
||||
});
|
||||
|
||||
test("logout redirects away from dashboard", async ({ page }) => {
|
||||
// First log in
|
||||
await page.goto("/dashboard");
|
||||
await page.waitForSelector("#username", { timeout: 15_000 });
|
||||
|
||||
await page.fill(
|
||||
"#username",
|
||||
process.env.TEST_USER ?? "admin@certifai.local"
|
||||
);
|
||||
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
|
||||
await page.click("#kc-login");
|
||||
|
||||
await page.waitForURL("**/dashboard", { timeout: 15_000 });
|
||||
|
||||
// Click logout
|
||||
await page.locator('a.logout-btn, a[href="/logout"]').click();
|
||||
|
||||
// Should no longer be on the dashboard
|
||||
await expect(page).not.toHaveURL(/\/dashboard/);
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Dashboard", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
// Wait for WASM hydration and auth check to complete
|
||||
await page.waitForSelector(".dashboard-page", { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("dashboard page loads with page header", async ({ page }) => {
|
||||
await expect(page.locator(".page-header")).toContainText("Dashboard");
|
||||
});
|
||||
|
||||
test("default topic chips are visible", async ({ page }) => {
|
||||
const topics = ["AI", "Technology", "Science", "Finance", "Writing", "Research"];
|
||||
|
||||
for (const topic of topics) {
|
||||
await expect(
|
||||
page.locator(".filter-tab", { hasText: topic })
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking a topic chip triggers search", async ({ page }) => {
|
||||
const chip = page.locator(".filter-tab", { hasText: "AI" });
|
||||
await chip.click();
|
||||
|
||||
// Either a loading state or results should appear
|
||||
const searchingOrResults = page
|
||||
.locator(".dashboard-loading, .news-grid, .dashboard-empty");
|
||||
await expect(searchingOrResults.first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("news cards render after search completes", async ({ page }) => {
|
||||
// Click a topic to trigger search
|
||||
await page.locator(".filter-tab", { hasText: "Technology" }).click();
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForSelector(".dashboard-loading", {
|
||||
state: "hidden",
|
||||
timeout: 15_000,
|
||||
}).catch(() => {
|
||||
// Loading may already be done
|
||||
});
|
||||
|
||||
// Either news cards or an empty state message should be visible
|
||||
const content = page.locator(".news-grid .news-card, .dashboard-empty");
|
||||
await expect(content.first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("clicking a news card opens article detail panel", async ({ page }) => {
|
||||
// Trigger a search and wait for results
|
||||
await page.locator(".filter-tab", { hasText: "AI" }).click();
|
||||
|
||||
await page.waitForSelector(".dashboard-loading", {
|
||||
state: "hidden",
|
||||
timeout: 15_000,
|
||||
}).catch(() => {});
|
||||
|
||||
const firstCard = page.locator(".news-card").first();
|
||||
// Only test if cards are present (search results depend on live data)
|
||||
if (await firstCard.isVisible().catch(() => false)) {
|
||||
await firstCard.click();
|
||||
await expect(page.locator(".dashboard-right, .dashboard-split")).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings toggle opens settings panel", async ({ page }) => {
|
||||
const settingsBtn = page.locator(".settings-toggle");
|
||||
await settingsBtn.click();
|
||||
|
||||
await expect(page.locator(".settings-panel")).toBeVisible();
|
||||
await expect(page.locator(".settings-panel-title")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Developer section", () => {
|
||||
test("agents page loads with sub-nav tabs", async ({ page }) => {
|
||||
await page.goto("/developer/agents");
|
||||
await page.waitForSelector(".developer-shell", { timeout: 15_000 });
|
||||
|
||||
const nav = page.locator(".sub-nav");
|
||||
await expect(nav.locator("a", { hasText: "Agents" })).toBeVisible();
|
||||
await expect(nav.locator("a", { hasText: "Flow" })).toBeVisible();
|
||||
await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("agents page shows Coming Soon badge", async ({ page }) => {
|
||||
await page.goto("/developer/agents");
|
||||
await page.waitForSelector(".placeholder-page", { timeout: 15_000 });
|
||||
|
||||
await expect(page.locator(".placeholder-badge")).toContainText(
|
||||
"Coming Soon"
|
||||
);
|
||||
await expect(page.locator("h2")).toContainText("Agent Builder");
|
||||
});
|
||||
|
||||
test("analytics page loads via sub-nav", async ({ page }) => {
|
||||
await page.goto("/developer/analytics");
|
||||
await page.waitForSelector(".placeholder-page", { timeout: 15_000 });
|
||||
|
||||
await expect(page.locator("h2")).toContainText("Analytics");
|
||||
await expect(page.locator(".placeholder-badge")).toContainText(
|
||||
"Coming Soon"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Sidebar navigation", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.waitForSelector(".sidebar", { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("sidebar links route to correct pages", async ({ page }) => {
|
||||
const navTests = [
|
||||
{ label: "Providers", url: /\/providers/ },
|
||||
{ label: "Developer", url: /\/developer\/agents/ },
|
||||
{ label: "Organization", url: /\/organization\/pricing/ },
|
||||
{ label: "Dashboard", url: /\/dashboard/ },
|
||||
];
|
||||
|
||||
for (const { label, url } of navTests) {
|
||||
await page.locator(".sidebar-link", { hasText: label }).click();
|
||||
await expect(page).toHaveURL(url, { timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("browser back/forward navigation works", async ({ page }) => {
|
||||
// Navigate to Providers
|
||||
await page.locator(".sidebar-link", { hasText: "Providers" }).click();
|
||||
await expect(page).toHaveURL(/\/providers/);
|
||||
|
||||
// Navigate to Developer
|
||||
await page.locator(".sidebar-link", { hasText: "Developer" }).click();
|
||||
await expect(page).toHaveURL(/\/developer/);
|
||||
|
||||
// Go back
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/providers/);
|
||||
|
||||
// Go forward
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(/\/developer/);
|
||||
});
|
||||
|
||||
test("logo link navigates to dashboard", async ({ page }) => {
|
||||
// Navigate away first
|
||||
await page.locator(".sidebar-link", { hasText: "Providers" }).click();
|
||||
await expect(page).toHaveURL(/\/providers/);
|
||||
|
||||
// Click the logo/brand in sidebar header
|
||||
const logo = page.locator(".sidebar-brand, .sidebar-logo, .sidebar a").first();
|
||||
await logo.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Organization section", () => {
|
||||
test("pricing page loads with three pricing cards", async ({ page }) => {
|
||||
await page.goto("/organization/pricing");
|
||||
await page.waitForSelector(".org-shell", { timeout: 15_000 });
|
||||
|
||||
const cards = page.locator(".pricing-card");
|
||||
await expect(cards).toHaveCount(3);
|
||||
});
|
||||
|
||||
test("pricing cards show Starter, Team, Enterprise tiers", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/organization/pricing");
|
||||
await page.waitForSelector(".org-shell", { timeout: 15_000 });
|
||||
|
||||
await expect(page.locator(".pricing-card", { hasText: "Starter" })).toBeVisible();
|
||||
await expect(page.locator(".pricing-card", { hasText: "Team" })).toBeVisible();
|
||||
await expect(page.locator(".pricing-card", { hasText: "Enterprise" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("organization dashboard loads with billing stats", async ({ page }) => {
|
||||
await page.goto("/organization/dashboard");
|
||||
await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 });
|
||||
|
||||
await expect(page.locator(".page-header")).toContainText("Organization");
|
||||
await expect(page.locator(".org-stats-bar")).toBeVisible();
|
||||
await expect(page.locator(".org-stat").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("member table is visible on org dashboard", async ({ page }) => {
|
||||
await page.goto("/organization/dashboard");
|
||||
await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 });
|
||||
|
||||
await expect(page.locator(".org-table")).toBeVisible();
|
||||
await expect(page.locator(".org-table thead")).toContainText("Name");
|
||||
await expect(page.locator(".org-table thead")).toContainText("Email");
|
||||
await expect(page.locator(".org-table thead")).toContainText("Role");
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Providers page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/providers");
|
||||
await page.waitForSelector(".providers-page", { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("providers page loads with header", async ({ page }) => {
|
||||
await expect(page.locator(".page-header")).toContainText("Providers");
|
||||
});
|
||||
|
||||
test("provider dropdown has Ollama selected by default", async ({
|
||||
page,
|
||||
}) => {
|
||||
const providerSelect = page
|
||||
.locator(".form-group")
|
||||
.filter({ hasText: "Provider" })
|
||||
.locator("select");
|
||||
|
||||
await expect(providerSelect).toHaveValue(/ollama/i);
|
||||
});
|
||||
|
||||
test("changing provider updates the model dropdown", async ({ page }) => {
|
||||
const providerSelect = page
|
||||
.locator(".form-group")
|
||||
.filter({ hasText: "Provider" })
|
||||
.locator("select");
|
||||
|
||||
// Get current model options
|
||||
const modelSelect = page
|
||||
.locator(".form-group")
|
||||
.filter({ hasText: /^Model/ })
|
||||
.locator("select");
|
||||
const initialOptions = await modelSelect.locator("option").allTextContents();
|
||||
|
||||
// Change to a different provider
|
||||
await providerSelect.selectOption({ label: "OpenAI" });
|
||||
|
||||
// Wait for model list to update
|
||||
await page.waitForTimeout(500);
|
||||
const updatedOptions = await modelSelect.locator("option").allTextContents();
|
||||
|
||||
// Model options should differ between providers
|
||||
expect(updatedOptions).not.toEqual(initialOptions);
|
||||
});
|
||||
|
||||
test("save button shows confirmation feedback", async ({ page }) => {
|
||||
const saveBtn = page.locator("button", { hasText: "Save Configuration" });
|
||||
await saveBtn.click();
|
||||
|
||||
await expect(page.locator(".form-success")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.locator(".form-success")).toContainText("saved");
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Public pages", () => {
|
||||
test("landing page loads with heading and nav links", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.locator(".landing-logo").first()).toHaveText("CERTifAI");
|
||||
await expect(page.locator(".landing-nav-links")).toBeVisible();
|
||||
await expect(page.locator('a[href="#features"]')).toBeVisible();
|
||||
await expect(page.locator('a[href="#how-it-works"]')).toBeVisible();
|
||||
await expect(page.locator('a[href="#pricing"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("landing page Log In link navigates to login route", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
const loginLink = page
|
||||
.locator(".landing-nav-actions a, .landing-nav-actions Link")
|
||||
.filter({ hasText: "Log In" });
|
||||
await loginLink.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test("impressum page loads with legal content", async ({ page }) => {
|
||||
await page.goto("/impressum");
|
||||
|
||||
await expect(page.locator("h1")).toHaveText("Impressum");
|
||||
await expect(
|
||||
page.locator("h2", { hasText: "Information according to" })
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".legal-content")).toContainText(
|
||||
"CERTifAI GmbH"
|
||||
);
|
||||
});
|
||||
|
||||
test("privacy page loads with privacy content", async ({ page }) => {
|
||||
await page.goto("/privacy");
|
||||
|
||||
await expect(page.locator("h1")).toHaveText("Privacy Policy");
|
||||
await expect(
|
||||
page.locator("h2", { hasText: "Introduction" })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator("h2", { hasText: "Your Rights" })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("footer links are present on landing page", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const footer = page.locator(".landing-footer");
|
||||
await expect(footer.locator('a:has-text("Impressum")')).toBeVisible();
|
||||
await expect(
|
||||
footer.locator('a:has-text("Privacy Policy")')
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
9
features/CAI-1.md
Normal file
9
features/CAI-1.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# CAI-1
|
||||
|
||||
This feature creates a new login/registration page for the GenAI admin dashboard. The user management is provided by Keycloak, which also serves the login/registration flow. The dioxus app should detect if a user is already logged-in or not, and if not, redirect the user to the keycloak landing page and after successful login, capture the user's access token in a state and save a session state.
|
||||
|
||||
Steps to follow:
|
||||
- Create a docker-compose file for hosting a local keycloak and create a realm for testing and a client for Oauth.
|
||||
- Setup the environment variables using .env. Fill the environment with keycloak URL, realm, client ID and secret.
|
||||
- Create a user state in Dioxus which manages the session and the access token. Add other user identifying information like email address to the state.
|
||||
- Modify dioxus to check the state and load the correct URL based on the state.
|
||||
3
features/CAI-2.md
Normal file
3
features/CAI-2.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# CERTifAI 2
|
||||
|
||||
This feature defines the types for database as well as the API between the dashboard backend and frontend.
|
||||
@@ -9,7 +9,6 @@
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"resetPasswordAllowed": true,
|
||||
"loginTheme": "certifai",
|
||||
"editUsernameAllowed": false,
|
||||
"bruteForceProtected": true,
|
||||
"permanentLockout": false,
|
||||
@@ -78,39 +77,6 @@
|
||||
"optionalClientScopes": [
|
||||
"offline_access"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "certifai-librechat",
|
||||
"name": "CERTifAI Chat",
|
||||
"description": "LibreChat OIDC client for CERTifAI",
|
||||
"enabled": true,
|
||||
"publicClient": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"protocol": "openid-connect",
|
||||
"secret": "certifai-librechat-secret",
|
||||
"rootUrl": "http://localhost:3080",
|
||||
"baseUrl": "http://localhost:3080",
|
||||
"redirectUris": [
|
||||
"http://localhost:3080/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:3080",
|
||||
"http://localhost:8000"
|
||||
],
|
||||
"attributes": {
|
||||
"post.logout.redirect.uris": "http://localhost:3080"
|
||||
},
|
||||
"defaultClientScopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"offline_access"
|
||||
]
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
|
||||
@@ -1,583 +0,0 @@
|
||||
/* CERTifAI Keycloak Login Theme
|
||||
* Overrides PatternFly v4 / legacy Keycloak classes to match the dashboard.
|
||||
*
|
||||
* Actual page structure (Keycloak 26 with parent=keycloak):
|
||||
* html.login-pf > body
|
||||
* div.login-pf-page
|
||||
* div#kc-header.login-pf-page-header
|
||||
* div#kc-header-wrapper
|
||||
* div.card-pf
|
||||
* header.login-pf-header > h1#kc-page-title
|
||||
* div#kc-content > div#kc-content-wrapper
|
||||
* form#kc-form-login
|
||||
* .form-group (email)
|
||||
* .form-group (password + .pf-c-input-group)
|
||||
* .form-group.login-pf-settings (forgot pwd)
|
||||
* .form-group #kc-form-buttons (submit: input#kc-login.pf-c-button.pf-m-primary)
|
||||
* div#kc-info.login-pf-signup (register link)
|
||||
*
|
||||
* Classes used: pf-c-* (PF v4), login-pf-*, card-pf, form-group
|
||||
*/
|
||||
|
||||
/* ===== Google Fonts ===== */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&display=swap');
|
||||
|
||||
/* ===== CSS Variables ===== */
|
||||
:root {
|
||||
--cai-bg-body: #0f1116;
|
||||
--cai-bg-card: #1a1d26;
|
||||
--cai-bg-surface: #1e222d;
|
||||
--cai-bg-input: #12141a;
|
||||
--cai-text-primary: #e2e8f0;
|
||||
--cai-text-heading: #f1f5f9;
|
||||
--cai-text-muted: #8892a8;
|
||||
--cai-text-faint: #5a6478;
|
||||
--cai-border-primary: #1e222d;
|
||||
--cai-border-secondary: #2a2f3d;
|
||||
--cai-accent: #91a4d2;
|
||||
--cai-accent-secondary: #6d85c6;
|
||||
--cai-brand-indigo: #4B3FE0;
|
||||
--cai-brand-teal: #38B2AC;
|
||||
--cai-error: #f87171;
|
||||
--cai-success: #4ade80;
|
||||
}
|
||||
|
||||
/* ===== Animations ===== */
|
||||
|
||||
/* Slow-moving ambient gradient behind the page */
|
||||
@keyframes ambientShift {
|
||||
0% { background-position: 0% 0%; }
|
||||
25% { background-position: 100% 50%; }
|
||||
50% { background-position: 50% 100%; }
|
||||
75% { background-position: 0% 50%; }
|
||||
100% { background-position: 0% 0%; }
|
||||
}
|
||||
|
||||
/* Subtle glow pulse on the card */
|
||||
@keyframes cardGlow {
|
||||
0%, 100% { box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 60px rgba(75, 63, 224, 0.04); }
|
||||
50% { box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 80px rgba(56, 178, 172, 0.06); }
|
||||
}
|
||||
|
||||
/* Gentle float for the logo */
|
||||
@keyframes logoFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
/* Gradient shimmer on the button */
|
||||
@keyframes buttonShimmer {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
/* ===== Base Page ===== */
|
||||
html.login-pf {
|
||||
background-color: var(--cai-bg-body) !important;
|
||||
}
|
||||
|
||||
html.login-pf body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 20%, rgba(75, 63, 224, 0.07) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 80%, rgba(56, 178, 172, 0.05) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 50% 50%, rgba(109, 133, 198, 0.03) 0%, transparent 70%),
|
||||
var(--cai-bg-body) !important;
|
||||
background-size: 200% 200%, 200% 200%, 100% 100%, 100% 100% !important;
|
||||
animation: ambientShift 20s ease-in-out infinite !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ===== Page Layout ===== */
|
||||
.login-pf-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 40px 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ===== Header (Logo + Realm Name) ===== */
|
||||
#kc-header.login-pf-page-header {
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
padding: 0 0 32px !important;
|
||||
text-align: center;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#kc-header-wrapper {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
font-size: 28px !important;
|
||||
font-weight: 700 !important;
|
||||
color: var(--cai-text-heading) !important;
|
||||
letter-spacing: -0.02em;
|
||||
text-transform: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Logo via ::before pseudo-element */
|
||||
#kc-header-wrapper::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 16px;
|
||||
background-image: url('../img/logo.svg');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
animation: logoFloat 4s ease-in-out infinite;
|
||||
filter: drop-shadow(0 0 12px rgba(75, 63, 224, 0.3));
|
||||
}
|
||||
|
||||
/* ===== Login Card ===== */
|
||||
.card-pf {
|
||||
background-color: var(--cai-bg-card) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 12px !important;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
padding: 32px !important;
|
||||
margin: 0 !important;
|
||||
animation: cardGlow 6s ease-in-out infinite;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Subtle gradient border effect on the card via ::before overlay */
|
||||
.card-pf::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--cai-brand-indigo),
|
||||
var(--cai-brand-teal),
|
||||
var(--cai-accent-secondary),
|
||||
transparent
|
||||
);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ===== Card Header (Sign In Title) ===== */
|
||||
.login-pf-header {
|
||||
border-bottom: none !important;
|
||||
padding: 0 0 24px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
#kc-page-title {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
font-size: 22px !important;
|
||||
font-weight: 600 !important;
|
||||
color: var(--cai-text-heading) !important;
|
||||
text-align: center;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== Form Groups ===== */
|
||||
.form-group {
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
|
||||
/* ===== Labels ===== */
|
||||
.pf-c-form__label,
|
||||
.pf-c-form__label-text,
|
||||
.login-pf-page .form-group label,
|
||||
.card-pf label {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
color: var(--cai-text-muted) !important;
|
||||
margin-bottom: 6px !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ===== Text Inputs ===== */
|
||||
.pf-c-form-control,
|
||||
.login-pf-page .form-control,
|
||||
.card-pf input[type="text"],
|
||||
.card-pf input[type="password"],
|
||||
.card-pf input[type="email"] {
|
||||
background-color: var(--cai-bg-input) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
padding: 10px 14px !important;
|
||||
height: auto !important;
|
||||
line-height: 1.5 !important;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.pf-c-form-control:focus,
|
||||
.pf-c-form-control:focus-within,
|
||||
.card-pf input[type="text"]:focus,
|
||||
.card-pf input[type="password"]:focus,
|
||||
.card-pf input[type="email"]:focus {
|
||||
border-color: var(--cai-accent) !important;
|
||||
box-shadow: 0 0 0 1px var(--cai-accent), 0 0 12px rgba(145, 164, 210, 0.1) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.pf-c-form-control::placeholder,
|
||||
.card-pf input::placeholder {
|
||||
color: var(--cai-text-faint) !important;
|
||||
}
|
||||
|
||||
/* Override browser autofill yellow background */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
-webkit-box-shadow: 0 0 0 9999px var(--cai-bg-input) inset !important;
|
||||
-webkit-text-fill-color: var(--cai-text-primary) !important;
|
||||
caret-color: var(--cai-text-primary) !important;
|
||||
transition: background-color 5000s ease-in-out 0s !important;
|
||||
background-color: var(--cai-bg-input) !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
}
|
||||
|
||||
/* Firefox autofill override */
|
||||
input:autofill {
|
||||
background-color: var(--cai-bg-input) !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
border-color: var(--cai-border-secondary) !important;
|
||||
}
|
||||
|
||||
/* Additional specificity for autofill inside input-group */
|
||||
.pf-c-input-group input:-webkit-autofill,
|
||||
.card-pf input:-webkit-autofill,
|
||||
.form-group input:-webkit-autofill,
|
||||
#username:-webkit-autofill,
|
||||
#password:-webkit-autofill {
|
||||
-webkit-box-shadow: 0 0 0 9999px var(--cai-bg-input) inset !important;
|
||||
-webkit-text-fill-color: var(--cai-text-primary) !important;
|
||||
background-color: var(--cai-bg-input) !important;
|
||||
}
|
||||
|
||||
/* ===== Password Input Group ===== */
|
||||
/* FIX: The .pf-c-input-group has white bg from PF4, causing white corners
|
||||
* behind the rounded child elements. Set transparent + matching border-radius. */
|
||||
.pf-c-input-group {
|
||||
display: flex !important;
|
||||
align-items: stretch !important;
|
||||
background-color: transparent !important;
|
||||
background: transparent !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.pf-c-input-group > .pf-c-form-control,
|
||||
.pf-c-input-group > input.pf-c-form-control,
|
||||
.pf-c-input-group > input[type="password"],
|
||||
#password {
|
||||
border-radius: 8px 0 0 8px !important;
|
||||
border-right: none !important;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Password visibility toggle */
|
||||
.pf-c-button.pf-m-control,
|
||||
.pf-c-input-group > .pf-c-button.pf-m-control {
|
||||
background-color: var(--cai-bg-surface) !important;
|
||||
color: var(--cai-text-muted) !important;
|
||||
border-top: 1px solid var(--cai-border-secondary) !important;
|
||||
border-right: 1px solid var(--cai-border-secondary) !important;
|
||||
border-bottom: 1px solid var(--cai-border-secondary) !important;
|
||||
border-left: 1px solid var(--cai-border-primary) !important;
|
||||
border-radius: 0 8px 8px 0 !important;
|
||||
padding: 0 14px !important;
|
||||
transition: color 0.2s ease, background-color 0.2s ease !important;
|
||||
line-height: 1 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.pf-c-button.pf-m-control:hover,
|
||||
.pf-c-input-group > .pf-c-button.pf-m-control:hover {
|
||||
color: var(--cai-accent) !important;
|
||||
background-color: rgba(145, 164, 210, 0.08) !important;
|
||||
}
|
||||
|
||||
.pf-c-button.pf-m-control:focus,
|
||||
.pf-c-input-group > .pf-c-button.pf-m-control:focus {
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* ===== Primary Button (Sign In) ===== */
|
||||
.pf-c-button.pf-m-primary,
|
||||
input.pf-c-button.pf-m-primary,
|
||||
#kc-login {
|
||||
background: linear-gradient(135deg,
|
||||
var(--cai-accent),
|
||||
var(--cai-accent-secondary),
|
||||
var(--cai-brand-indigo),
|
||||
var(--cai-accent-secondary),
|
||||
var(--cai-accent)) !important;
|
||||
background-size: 300% 100% !important;
|
||||
animation: buttonShimmer 6s ease-in-out infinite !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
color: #0a0c10 !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 12px 20px !important;
|
||||
cursor: pointer !important;
|
||||
transition: opacity 0.15s ease, box-shadow 0.2s ease !important;
|
||||
text-shadow: none !important;
|
||||
box-shadow: 0 2px 12px rgba(109, 133, 198, 0.2) !important;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pf-c-button.pf-m-primary:hover,
|
||||
input.pf-c-button.pf-m-primary:hover,
|
||||
#kc-login:hover {
|
||||
opacity: 0.95;
|
||||
box-shadow: 0 4px 20px rgba(109, 133, 198, 0.35) !important;
|
||||
}
|
||||
|
||||
.pf-c-button.pf-m-primary:focus,
|
||||
#kc-login:focus {
|
||||
box-shadow: 0 0 0 2px var(--cai-accent), 0 4px 20px rgba(109, 133, 198, 0.3) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* ===== Links ===== */
|
||||
.login-pf-page a,
|
||||
.card-pf a {
|
||||
color: var(--cai-accent) !important;
|
||||
text-decoration: none !important;
|
||||
transition: color 0.15s ease !important;
|
||||
}
|
||||
|
||||
.login-pf-page a:hover,
|
||||
.card-pf a:hover {
|
||||
color: var(--cai-accent-secondary) !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* Forgot Password link */
|
||||
.login-pf-settings {
|
||||
text-align: right;
|
||||
margin-bottom: 24px !important;
|
||||
}
|
||||
|
||||
.login-pf-settings a {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* ===== Registration / Info Section ===== */
|
||||
#kc-info.login-pf-signup {
|
||||
background-color: var(--cai-bg-surface) !important;
|
||||
border-top: 1px solid var(--cai-border-primary) !important;
|
||||
padding: 16px 32px !important;
|
||||
margin: 0 -32px -32px !important;
|
||||
border-radius: 0 0 12px 12px !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#kc-info-wrapper,
|
||||
#kc-registration {
|
||||
font-size: 14px !important;
|
||||
color: var(--cai-text-muted) !important;
|
||||
}
|
||||
|
||||
#kc-registration span {
|
||||
color: var(--cai-text-muted) !important;
|
||||
}
|
||||
|
||||
/* ===== Alert / Error Messages ===== */
|
||||
.alert,
|
||||
.pf-c-alert {
|
||||
background-color: var(--cai-bg-surface) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
padding: 12px 16px !important;
|
||||
margin-bottom: 16px !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.alert-error,
|
||||
.alert-warning,
|
||||
.pf-c-alert.pf-m-danger,
|
||||
.pf-c-alert.pf-m-warning {
|
||||
border-color: var(--cai-error) !important;
|
||||
}
|
||||
|
||||
.alert-error .kc-feedback-text,
|
||||
.pf-c-alert .pf-c-alert__title {
|
||||
color: var(--cai-text-primary) !important;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
border-color: var(--cai-success) !important;
|
||||
}
|
||||
|
||||
/* ===== Checkboxes (Remember Me) ===== */
|
||||
.pf-c-check,
|
||||
.login-pf-page .checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pf-c-check__label,
|
||||
.login-pf-page .checkbox label {
|
||||
font-size: 13px !important;
|
||||
color: var(--cai-text-muted) !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pf-c-check__input,
|
||||
.login-pf-page input[type="checkbox"] {
|
||||
accent-color: var(--cai-accent);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ===== Select / Dropdown ===== */
|
||||
.card-pf select,
|
||||
.login-pf-page select {
|
||||
background-color: var(--cai-bg-input) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
padding: 10px 14px !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* ===== Social Login / Identity Providers ===== */
|
||||
#kc-social-providers {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--cai-border-primary);
|
||||
}
|
||||
|
||||
#kc-social-providers ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#kc-social-providers li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#kc-social-providers a,
|
||||
#kc-social-providers .pf-c-button {
|
||||
background-color: var(--cai-bg-surface) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
padding: 10px 16px !important;
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 14px !important;
|
||||
font-weight: 500 !important;
|
||||
transition: border-color 0.15s ease !important;
|
||||
}
|
||||
|
||||
#kc-social-providers a:hover,
|
||||
#kc-social-providers .pf-c-button:hover {
|
||||
border-color: var(--cai-accent) !important;
|
||||
}
|
||||
|
||||
/* ===== Form Buttons Row ===== */
|
||||
#kc-form-buttons {
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
#kc-form-options {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ===== Tooltip ===== */
|
||||
.kc-tooltip-text {
|
||||
background-color: var(--cai-bg-surface) !important;
|
||||
color: var(--cai-text-primary) !important;
|
||||
border: 1px solid var(--cai-border-secondary) !important;
|
||||
border-radius: 8px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* ===== Scrollbar ===== */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--cai-bg-body);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--cai-border-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--cai-text-faint);
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 768px) {
|
||||
.login-pf-page {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.card-pf {
|
||||
padding: 24px !important;
|
||||
}
|
||||
|
||||
#kc-header-wrapper {
|
||||
font-size: 24px !important;
|
||||
}
|
||||
|
||||
#kc-header-wrapper::before {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
#kc-info.login-pf-signup {
|
||||
margin: 0 -24px -24px !important;
|
||||
padding: 16px 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Override PatternFly background images ===== */
|
||||
.login-pf-page .login-pf-page-header,
|
||||
.login-pf body {
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
/* Remove any PF4 container-fluid stretching */
|
||||
.container-fluid {
|
||||
padding: 0 !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* Ensure the card doesn't stretch full width */
|
||||
.login-pf-page > .card-pf {
|
||||
max-width: 440px;
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,3 +0,0 @@
|
||||
parent=keycloak
|
||||
import=common/keycloak
|
||||
styles=css/login.css
|
||||
@@ -1,40 +0,0 @@
|
||||
# CERTifAI LibreChat Configuration
|
||||
# Ollama backend for self-hosted LLM inference.
|
||||
version: 1.2.8
|
||||
|
||||
cache: true
|
||||
|
||||
registration:
|
||||
socialLogins:
|
||||
- openid
|
||||
|
||||
interface:
|
||||
privacyPolicy:
|
||||
externalUrl: https://dash-dev.meghsakha.com/privacy
|
||||
termsOfService:
|
||||
externalUrl: https://dash-dev.meghsakha.com/impressum
|
||||
endpointsMenu: true
|
||||
modelSelect: true
|
||||
parameters: true
|
||||
|
||||
endpoints:
|
||||
custom:
|
||||
- name: "Ollama"
|
||||
apiKey: "ollama"
|
||||
baseURL: "https://mac-mini-von-benjamin-2:11434/v1/"
|
||||
models:
|
||||
default:
|
||||
- "llama3.1:8b"
|
||||
- "qwen3:30b-a3b"
|
||||
fetch: true
|
||||
titleConvo: true
|
||||
titleModel: "current_model"
|
||||
summarize: false
|
||||
summaryModel: "current_model"
|
||||
forcePrompt: false
|
||||
modelDisplayLabel: "CERTifAI Ollama"
|
||||
dropParams:
|
||||
- stop
|
||||
- user
|
||||
- frequency_penalty
|
||||
- presence_penalty
|
||||
@@ -1,25 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,743 +0,0 @@
|
||||
const undici = require('undici');
|
||||
const { get } = require('lodash');
|
||||
const fetch = require('node-fetch');
|
||||
const passport = require('passport');
|
||||
const client = require('openid-client');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { hashToken, logger } = require('@librechat/data-schemas');
|
||||
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
|
||||
const { CacheKeys, ErrorTypes, SystemRoles } = require('librechat-data-provider');
|
||||
const {
|
||||
isEnabled,
|
||||
logHeaders,
|
||||
safeStringify,
|
||||
findOpenIDUser,
|
||||
getBalanceConfig,
|
||||
isEmailDomainAllowed,
|
||||
} = require('@librechat/api');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { findUser, createUser, updateUser } = require('~/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
|
||||
/**
|
||||
* @typedef {import('openid-client').ClientMetadata} ClientMetadata
|
||||
* @typedef {import('openid-client').Configuration} Configuration
|
||||
**/
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {client.CustomFetchOptions} options
|
||||
*/
|
||||
async function customFetch(url, options) {
|
||||
const urlStr = url.toString();
|
||||
logger.debug(`[openidStrategy] Request to: ${urlStr}`);
|
||||
const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS);
|
||||
if (debugOpenId) {
|
||||
logger.debug(`[openidStrategy] Request method: ${options.method || 'GET'}`);
|
||||
logger.debug(`[openidStrategy] Request headers: ${logHeaders(options.headers)}`);
|
||||
if (options.body) {
|
||||
let bodyForLogging = '';
|
||||
if (options.body instanceof URLSearchParams) {
|
||||
bodyForLogging = options.body.toString();
|
||||
} else if (typeof options.body === 'string') {
|
||||
bodyForLogging = options.body;
|
||||
} else {
|
||||
bodyForLogging = safeStringify(options.body);
|
||||
}
|
||||
logger.debug(`[openidStrategy] Request body: ${bodyForLogging}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
/** @type {undici.RequestInit} */
|
||||
let fetchOptions = options;
|
||||
if (process.env.PROXY) {
|
||||
logger.info(`[openidStrategy] proxy agent configured: ${process.env.PROXY}`);
|
||||
fetchOptions = {
|
||||
...options,
|
||||
dispatcher: new undici.ProxyAgent(process.env.PROXY),
|
||||
};
|
||||
}
|
||||
|
||||
const response = await undici.fetch(url, fetchOptions);
|
||||
|
||||
if (debugOpenId) {
|
||||
logger.debug(`[openidStrategy] Response status: ${response.status} ${response.statusText}`);
|
||||
logger.debug(`[openidStrategy] Response headers: ${logHeaders(response.headers)}`);
|
||||
}
|
||||
|
||||
if (response.status === 200 && response.headers.has('www-authenticate')) {
|
||||
const wwwAuth = response.headers.get('www-authenticate');
|
||||
logger.warn(`[openidStrategy] Non-standard WWW-Authenticate header found in successful response (200 OK): ${wwwAuth}.
|
||||
This violates RFC 7235 and may cause issues with strict OAuth clients. Removing header for compatibility.`);
|
||||
|
||||
/** Cloned response without the WWW-Authenticate header */
|
||||
const responseBody = await response.arrayBuffer();
|
||||
const newHeaders = new Headers();
|
||||
for (const [key, value] of response.headers.entries()) {
|
||||
if (key.toLowerCase() !== 'www-authenticate') {
|
||||
newHeaders.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(responseBody, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: newHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error(`[openidStrategy] Fetch error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef {Configuration | null} */
|
||||
let openidConfig = null;
|
||||
|
||||
/**
|
||||
* Custom OpenID Strategy
|
||||
*
|
||||
* Note: Originally overrode currentUrl() to work around Express 4's req.host not including port.
|
||||
* With Express 5, req.host now includes the port by default, but we continue to use DOMAIN_SERVER
|
||||
* for consistency and explicit configuration control.
|
||||
* More info: https://github.com/panva/openid-client/pull/713
|
||||
*/
|
||||
class CustomOpenIDStrategy extends OpenIDStrategy {
|
||||
currentUrl(req) {
|
||||
const hostAndProtocol = process.env.DOMAIN_SERVER;
|
||||
return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`);
|
||||
}
|
||||
|
||||
authorizationRequestParams(req, options) {
|
||||
const params = super.authorizationRequestParams(req, options);
|
||||
if (options?.state && !params.has('state')) {
|
||||
params.set('state', options.state);
|
||||
}
|
||||
|
||||
if (process.env.OPENID_AUDIENCE) {
|
||||
params.set('audience', process.env.OPENID_AUDIENCE);
|
||||
logger.debug(
|
||||
`[openidStrategy] Adding audience to authorization request: ${process.env.OPENID_AUDIENCE}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse OPENID_AUTH_EXTRA_PARAMS (format: "key=value" or "key1=value1,key2=value2")
|
||||
if (process.env.OPENID_AUTH_EXTRA_PARAMS) {
|
||||
const extraParts = process.env.OPENID_AUTH_EXTRA_PARAMS.split(',');
|
||||
for (const part of extraParts) {
|
||||
const [key, ...rest] = part.trim().split('=');
|
||||
if (key && rest.length > 0) {
|
||||
params.set(key.trim(), rest.join('=').trim());
|
||||
logger.debug(`[openidStrategy] Adding extra auth param: ${key.trim()}=${rest.join('=').trim()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate nonce for federated providers that require it */
|
||||
const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE);
|
||||
if (shouldGenerateNonce && !params.has('nonce') && this._sessionKey) {
|
||||
const crypto = require('crypto');
|
||||
const nonce = crypto.randomBytes(16).toString('hex');
|
||||
params.set('nonce', nonce);
|
||||
logger.debug('[openidStrategy] Generated nonce for federated provider:', nonce);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the access token for a new access token using the on-behalf-of flow if required.
|
||||
* @param {Configuration} config
|
||||
* @param {string} accessToken access token to be exchanged if necessary
|
||||
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
|
||||
* @param {boolean} fromCache - Indicates whether to use cached tokens.
|
||||
* @returns {Promise<string>} The new access token if exchanged, otherwise the original access token.
|
||||
*/
|
||||
const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache = false) => {
|
||||
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
|
||||
const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED);
|
||||
if (onBehalfFlowRequired) {
|
||||
if (fromCache) {
|
||||
const cachedToken = await tokensCache.get(sub);
|
||||
if (cachedToken) {
|
||||
return cachedToken.access_token;
|
||||
}
|
||||
}
|
||||
const grantResponse = await client.genericGrantRequest(
|
||||
config,
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
{
|
||||
scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE || 'user.read',
|
||||
assertion: accessToken,
|
||||
requested_token_use: 'on_behalf_of',
|
||||
},
|
||||
);
|
||||
await tokensCache.set(
|
||||
sub,
|
||||
{
|
||||
access_token: grantResponse.access_token,
|
||||
},
|
||||
grantResponse.expires_in * 1000,
|
||||
);
|
||||
return grantResponse.access_token;
|
||||
}
|
||||
return accessToken;
|
||||
};
|
||||
|
||||
/**
|
||||
* get user info from openid provider
|
||||
* @param {Configuration} config
|
||||
* @param {string} accessToken access token
|
||||
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
const getUserInfo = async (config, accessToken, sub) => {
|
||||
try {
|
||||
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub);
|
||||
return await client.fetchUserInfo(config, exchangedAccessToken, sub);
|
||||
} catch (error) {
|
||||
logger.error('[openidStrategy] getUserInfo: Error fetching user info:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Downloads an image from a URL using an access token.
|
||||
* @param {string} url
|
||||
* @param {Configuration} config
|
||||
* @param {string} accessToken access token
|
||||
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
|
||||
* @returns {Promise<Buffer | string>} The image buffer or an empty string if the download fails.
|
||||
*/
|
||||
const downloadImage = async (url, config, accessToken, sub) => {
|
||||
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true);
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${exchangedAccessToken}`,
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.PROXY) {
|
||||
options.agent = new HttpsProxyAgent(process.env.PROXY);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (response.ok) {
|
||||
const buffer = await response.buffer();
|
||||
return buffer;
|
||||
} else {
|
||||
throw new Error(`${response.statusText} (HTTP ${response.status})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[openidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the full name of a user based on OpenID userinfo and environment configuration.
|
||||
*
|
||||
* @param {Object} userinfo - The user information object from OpenID Connect
|
||||
* @param {string} [userinfo.given_name] - The user's first name
|
||||
* @param {string} [userinfo.family_name] - The user's last name
|
||||
* @param {string} [userinfo.username] - The user's username
|
||||
* @param {string} [userinfo.email] - The user's email address
|
||||
* @returns {string} The determined full name of the user
|
||||
*/
|
||||
function getFullName(userinfo) {
|
||||
if (process.env.OPENID_NAME_CLAIM) {
|
||||
return userinfo[process.env.OPENID_NAME_CLAIM];
|
||||
}
|
||||
|
||||
if (userinfo.given_name && userinfo.family_name) {
|
||||
return `${userinfo.given_name} ${userinfo.family_name}`;
|
||||
}
|
||||
|
||||
if (userinfo.given_name) {
|
||||
return userinfo.given_name;
|
||||
}
|
||||
|
||||
if (userinfo.family_name) {
|
||||
return userinfo.family_name;
|
||||
}
|
||||
|
||||
return userinfo.username || userinfo.email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an input into a string suitable for a username.
|
||||
* If the input is a string, it will be returned as is.
|
||||
* If the input is an array, elements will be joined with underscores.
|
||||
* In case of undefined or other falsy values, a default value will be returned.
|
||||
*
|
||||
* @param {string | string[] | undefined} input - The input value to be converted into a username.
|
||||
* @param {string} [defaultValue=''] - The default value to return if the input is falsy.
|
||||
* @returns {string} The processed input as a string suitable for a username.
|
||||
*/
|
||||
function convertToUsername(input, defaultValue = '') {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
} else if (Array.isArray(input)) {
|
||||
return input.join('_');
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Azure AD groups when group overage is in effect (groups moved to _claim_names/_claim_sources).
|
||||
*
|
||||
* NOTE: Microsoft recommends treating _claim_names/_claim_sources as a signal only and using Microsoft Graph
|
||||
* to resolve group membership instead of calling the endpoint in _claim_sources directly.
|
||||
*
|
||||
* @param {string} accessToken - Access token with Microsoft Graph permissions
|
||||
* @returns {Promise<string[] | null>} Resolved group IDs or null on failure
|
||||
* @see https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference#groups-overage-claim
|
||||
* @see https://learn.microsoft.com/en-us/graph/api/directoryobject-getmemberobjects
|
||||
*/
|
||||
async function resolveGroupsFromOverage(accessToken) {
|
||||
try {
|
||||
if (!accessToken) {
|
||||
logger.error('[openidStrategy] Access token missing; cannot resolve group overage');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use /me/getMemberObjects so least-privileged delegated permission User.Read is sufficient
|
||||
// when resolving the signed-in user's group membership.
|
||||
const url = 'https://graph.microsoft.com/v1.0/me/getMemberObjects';
|
||||
|
||||
logger.debug(
|
||||
`[openidStrategy] Detected group overage, resolving groups via Microsoft Graph getMemberObjects: ${url}`,
|
||||
);
|
||||
|
||||
const fetchOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ securityEnabledOnly: false }),
|
||||
};
|
||||
|
||||
if (process.env.PROXY) {
|
||||
const { ProxyAgent } = undici;
|
||||
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
|
||||
}
|
||||
|
||||
const response = await undici.fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
`[openidStrategy] Failed to resolve groups via Microsoft Graph getMemberObjects: HTTP ${response.status} ${response.statusText}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const values = Array.isArray(data?.value) ? data.value : null;
|
||||
if (!values) {
|
||||
logger.error(
|
||||
'[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const groupIds = values.filter((id) => typeof id === 'string');
|
||||
|
||||
logger.debug(
|
||||
`[openidStrategy] Successfully resolved ${groupIds.length} groups via Microsoft Graph getMemberObjects`,
|
||||
);
|
||||
return groupIds;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[openidStrategy] Error resolving groups via Microsoft Graph getMemberObjects:',
|
||||
err,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OpenID authentication tokenset and userinfo
|
||||
* This is the core logic extracted from the passport strategy callback
|
||||
* Can be reused by both the passport strategy and proxy authentication
|
||||
*
|
||||
* @param {Object} tokenset - The OpenID tokenset containing access_token, id_token, etc.
|
||||
* @param {boolean} existingUsersOnly - If true, only existing users will be processed
|
||||
* @returns {Promise<Object>} The authenticated user object with tokenset
|
||||
*/
|
||||
async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
|
||||
const claims = tokenset.claims ? tokenset.claims() : tokenset;
|
||||
const userinfo = {
|
||||
...claims,
|
||||
};
|
||||
|
||||
if (tokenset.access_token) {
|
||||
const providerUserinfo = await getUserInfo(openidConfig, tokenset.access_token, claims.sub);
|
||||
Object.assign(userinfo, providerUserinfo);
|
||||
}
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
/** Azure AD sometimes doesn't return email, use preferred_username as fallback */
|
||||
const email = userinfo.email || userinfo.preferred_username || userinfo.upn;
|
||||
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
|
||||
logger.error(
|
||||
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
|
||||
);
|
||||
throw new Error('Email domain not allowed');
|
||||
}
|
||||
|
||||
const result = await findOpenIDUser({
|
||||
findUser,
|
||||
email: email,
|
||||
openidId: claims.sub || userinfo.sub,
|
||||
idOnTheSource: claims.oid || userinfo.oid,
|
||||
strategyName: 'openidStrategy',
|
||||
});
|
||||
let user = result.user;
|
||||
const error = result.error;
|
||||
|
||||
if (error) {
|
||||
throw new Error(ErrorTypes.AUTH_FAILED);
|
||||
}
|
||||
|
||||
const fullName = getFullName(userinfo);
|
||||
|
||||
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
|
||||
if (requiredRole) {
|
||||
const requiredRoles = requiredRole
|
||||
.split(',')
|
||||
.map((role) => role.trim())
|
||||
.filter(Boolean);
|
||||
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
|
||||
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
|
||||
|
||||
let decodedToken = '';
|
||||
if (requiredRoleTokenKind === 'access' && tokenset.access_token) {
|
||||
decodedToken = jwtDecode(tokenset.access_token);
|
||||
} else if (requiredRoleTokenKind === 'id' && tokenset.id_token) {
|
||||
decodedToken = jwtDecode(tokenset.id_token);
|
||||
}
|
||||
|
||||
let roles = get(decodedToken, requiredRoleParameterPath);
|
||||
|
||||
// Handle Azure AD group overage for ID token groups: when hasgroups or _claim_* indicate overage,
|
||||
// resolve groups via Microsoft Graph instead of relying on token group values.
|
||||
if (
|
||||
!Array.isArray(roles) &&
|
||||
typeof roles !== 'string' &&
|
||||
requiredRoleTokenKind === 'id' &&
|
||||
requiredRoleParameterPath === 'groups' &&
|
||||
decodedToken &&
|
||||
(decodedToken.hasgroups ||
|
||||
(decodedToken._claim_names?.groups &&
|
||||
decodedToken._claim_sources?.[decodedToken._claim_names.groups]))
|
||||
) {
|
||||
const overageGroups = await resolveGroupsFromOverage(tokenset.access_token);
|
||||
if (overageGroups) {
|
||||
roles = overageGroups;
|
||||
}
|
||||
}
|
||||
|
||||
if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) {
|
||||
logger.error(
|
||||
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
|
||||
);
|
||||
const rolesList =
|
||||
requiredRoles.length === 1
|
||||
? `"${requiredRoles[0]}"`
|
||||
: `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
|
||||
throw new Error(`You must have ${rolesList} role to log in.`);
|
||||
}
|
||||
|
||||
const roleValues = Array.isArray(roles) ? roles : [roles];
|
||||
|
||||
if (!requiredRoles.some((role) => roleValues.includes(role))) {
|
||||
const rolesList =
|
||||
requiredRoles.length === 1
|
||||
? `"${requiredRoles[0]}"`
|
||||
: `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
|
||||
throw new Error(`You must have ${rolesList} role to log in.`);
|
||||
}
|
||||
}
|
||||
|
||||
let username = '';
|
||||
if (process.env.OPENID_USERNAME_CLAIM) {
|
||||
username = userinfo[process.env.OPENID_USERNAME_CLAIM];
|
||||
} else {
|
||||
username = convertToUsername(
|
||||
userinfo.preferred_username || userinfo.username || userinfo.email,
|
||||
);
|
||||
}
|
||||
|
||||
if (existingUsersOnly && !user) {
|
||||
throw new Error('User does not exist');
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
user = {
|
||||
provider: 'openid',
|
||||
openidId: userinfo.sub,
|
||||
username,
|
||||
email: email || '',
|
||||
emailVerified: userinfo.email_verified || false,
|
||||
name: fullName,
|
||||
idOnTheSource: userinfo.oid,
|
||||
};
|
||||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
user = await createUser(user, balanceConfig, true, true);
|
||||
} else {
|
||||
user.provider = 'openid';
|
||||
user.openidId = userinfo.sub;
|
||||
user.username = username;
|
||||
user.name = fullName;
|
||||
user.idOnTheSource = userinfo.oid;
|
||||
if (email && email !== user.email) {
|
||||
user.email = email;
|
||||
user.emailVerified = userinfo.email_verified || false;
|
||||
}
|
||||
}
|
||||
|
||||
const adminRole = process.env.OPENID_ADMIN_ROLE;
|
||||
const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
|
||||
const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
|
||||
|
||||
if (adminRole && adminRoleParameterPath && adminRoleTokenKind) {
|
||||
let adminRoleObject;
|
||||
switch (adminRoleTokenKind) {
|
||||
case 'access':
|
||||
adminRoleObject = jwtDecode(tokenset.access_token);
|
||||
break;
|
||||
case 'id':
|
||||
adminRoleObject = jwtDecode(tokenset.id_token);
|
||||
break;
|
||||
case 'userinfo':
|
||||
adminRoleObject = userinfo;
|
||||
break;
|
||||
default:
|
||||
logger.error(
|
||||
`[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`,
|
||||
);
|
||||
throw new Error('Invalid admin role token kind');
|
||||
}
|
||||
|
||||
const adminRoles = get(adminRoleObject, adminRoleParameterPath);
|
||||
|
||||
if (
|
||||
adminRoles &&
|
||||
(adminRoles === true ||
|
||||
adminRoles === adminRole ||
|
||||
(Array.isArray(adminRoles) && adminRoles.includes(adminRole)))
|
||||
) {
|
||||
user.role = SystemRoles.ADMIN;
|
||||
logger.info(`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`);
|
||||
} else if (user.role === SystemRoles.ADMIN) {
|
||||
user.role = SystemRoles.USER;
|
||||
logger.info(
|
||||
`[openidStrategy] User ${username} demoted from admin - role no longer present in token`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
|
||||
/** @type {string | undefined} */
|
||||
const imageUrl = userinfo.picture;
|
||||
|
||||
let fileName;
|
||||
if (crypto) {
|
||||
fileName = (await hashToken(userinfo.sub)) + '.png';
|
||||
} else {
|
||||
fileName = userinfo.sub + '.png';
|
||||
}
|
||||
|
||||
const imageBuffer = await downloadImage(
|
||||
imageUrl,
|
||||
openidConfig,
|
||||
tokenset.access_token,
|
||||
userinfo.sub,
|
||||
);
|
||||
if (imageBuffer) {
|
||||
const { saveBuffer } = getStrategyFunctions(
|
||||
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
|
||||
);
|
||||
const imagePath = await saveBuffer({
|
||||
fileName,
|
||||
userId: user._id.toString(),
|
||||
buffer: imageBuffer,
|
||||
});
|
||||
user.avatar = imagePath ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
user = await updateUser(user._id, user);
|
||||
|
||||
logger.info(
|
||||
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
|
||||
{
|
||||
user: {
|
||||
openidId: user.openidId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...user,
|
||||
tokenset,
|
||||
federatedTokens: {
|
||||
access_token: tokenset.access_token,
|
||||
id_token: tokenset.id_token,
|
||||
refresh_token: tokenset.refresh_token,
|
||||
expires_at: tokenset.expires_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean | undefined} [existingUsersOnly]
|
||||
*/
|
||||
function createOpenIDCallback(existingUsersOnly) {
|
||||
return async (tokenset, done) => {
|
||||
try {
|
||||
const user = await processOpenIDAuth(tokenset, existingUsersOnly);
|
||||
done(null, user);
|
||||
} catch (err) {
|
||||
if (err.message === 'Email domain not allowed') {
|
||||
return done(null, false, { message: err.message });
|
||||
}
|
||||
if (err.message === ErrorTypes.AUTH_FAILED) {
|
||||
return done(null, false, { message: err.message });
|
||||
}
|
||||
if (err.message && err.message.includes('role to log in')) {
|
||||
return done(null, false, { message: err.message });
|
||||
}
|
||||
logger.error('[openidStrategy] login failed', err);
|
||||
done(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the OpenID strategy specifically for admin authentication.
|
||||
* @param {Configuration} openidConfig
|
||||
*/
|
||||
const setupOpenIdAdmin = (openidConfig) => {
|
||||
try {
|
||||
if (!openidConfig) {
|
||||
throw new Error('OpenID configuration not initialized');
|
||||
}
|
||||
|
||||
const openidAdminLogin = new CustomOpenIDStrategy(
|
||||
{
|
||||
config: openidConfig,
|
||||
scope: process.env.OPENID_SCOPE,
|
||||
usePKCE: isEnabled(process.env.OPENID_USE_PKCE),
|
||||
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
|
||||
callbackURL: process.env.DOMAIN_SERVER + '/api/admin/oauth/openid/callback',
|
||||
},
|
||||
createOpenIDCallback(true),
|
||||
);
|
||||
|
||||
passport.use('openidAdmin', openidAdminLogin);
|
||||
} catch (err) {
|
||||
logger.error('[openidStrategy] setupOpenIdAdmin', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up the OpenID strategy for authentication.
|
||||
* This function configures the OpenID client, handles proxy settings,
|
||||
* and defines the OpenID strategy for Passport.js.
|
||||
*
|
||||
* @async
|
||||
* @function setupOpenId
|
||||
* @returns {Promise<Configuration | null>} A promise that resolves when the OpenID strategy is set up and returns the openid client config object.
|
||||
* @throws {Error} If an error occurs during the setup process.
|
||||
*/
|
||||
async function setupOpenId() {
|
||||
try {
|
||||
const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE);
|
||||
|
||||
/** @type {ClientMetadata} */
|
||||
const clientMetadata = {
|
||||
client_id: process.env.OPENID_CLIENT_ID,
|
||||
client_secret: process.env.OPENID_CLIENT_SECRET,
|
||||
};
|
||||
|
||||
if (shouldGenerateNonce) {
|
||||
clientMetadata.response_types = ['code'];
|
||||
clientMetadata.grant_types = ['authorization_code'];
|
||||
clientMetadata.token_endpoint_auth_method = 'client_secret_post';
|
||||
}
|
||||
|
||||
/** @type {Configuration} */
|
||||
openidConfig = await client.discovery(
|
||||
new URL(process.env.OPENID_ISSUER),
|
||||
process.env.OPENID_CLIENT_ID,
|
||||
clientMetadata,
|
||||
undefined,
|
||||
{
|
||||
[client.customFetch]: customFetch,
|
||||
execute: [client.allowInsecureRequests],
|
||||
},
|
||||
);
|
||||
|
||||
logger.info(`[openidStrategy] OpenID authentication configuration`, {
|
||||
generateNonce: shouldGenerateNonce,
|
||||
reason: shouldGenerateNonce
|
||||
? 'OPENID_GENERATE_NONCE=true - Will generate nonce and use explicit metadata for federated providers'
|
||||
: 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata',
|
||||
});
|
||||
|
||||
const openidLogin = new CustomOpenIDStrategy(
|
||||
{
|
||||
config: openidConfig,
|
||||
scope: process.env.OPENID_SCOPE,
|
||||
callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL,
|
||||
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
|
||||
usePKCE: isEnabled(process.env.OPENID_USE_PKCE),
|
||||
},
|
||||
createOpenIDCallback(),
|
||||
);
|
||||
passport.use('openid', openidLogin);
|
||||
setupOpenIdAdmin(openidConfig);
|
||||
return openidConfig;
|
||||
} catch (err) {
|
||||
logger.error('[openidStrategy]', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @function getOpenIdConfig
|
||||
* @description Returns the OpenID client instance.
|
||||
* @throws {Error} If the OpenID client is not initialized.
|
||||
* @returns {Configuration}
|
||||
*/
|
||||
function getOpenIdConfig() {
|
||||
if (!openidConfig) {
|
||||
throw new Error('OpenID client is not initialized. Please call setupOpenId first.');
|
||||
}
|
||||
return openidConfig;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setupOpenId,
|
||||
getOpenIdConfig,
|
||||
};
|
||||
@@ -4,7 +4,6 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [["html"], ["list"]],
|
||||
timeout: 30_000,
|
||||
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL ?? "http://localhost:8000",
|
||||
actionTimeout: 10_000,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /auth\.setup\.ts/,
|
||||
},
|
||||
{
|
||||
name: "public",
|
||||
testMatch: /public\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "authenticated",
|
||||
testMatch: /\.spec\.ts$/,
|
||||
testIgnore: /public\.spec\.ts$/,
|
||||
dependencies: ["setup"],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
storageState: "e2e/.auth/user.json",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
93
src/app.rs
93
src/app.rs
@@ -1,13 +1,11 @@
|
||||
use crate::i18n::Locale;
|
||||
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.
|
||||
/// outside the `AppShell` layout. Authenticated pages like `OverviewPage`
|
||||
/// are wrapped in `AppShell` which renders the sidebar.
|
||||
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||
#[rustfmt::skip]
|
||||
pub enum Route {
|
||||
@@ -19,35 +17,15 @@ pub enum Route {
|
||||
PrivacyPage {},
|
||||
#[layout(AppShell)]
|
||||
#[route("/dashboard")]
|
||||
DashboardPage {},
|
||||
#[route("/providers")]
|
||||
ProvidersPage {},
|
||||
|
||||
#[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]
|
||||
OverviewPage {},
|
||||
#[end_layout]
|
||||
|
||||
#[route("/login?:redirect_url")]
|
||||
Login { redirect_url: String },
|
||||
}
|
||||
|
||||
const FAVICON: Asset = asset!("/assets/favicon.svg");
|
||||
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
||||
const MAIN_CSS: Asset = asset!("/assets/main.css");
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
const MANIFEST: Asset = asset!("/assets/manifest.json");
|
||||
|
||||
/// Google Fonts URL for Inter (body) and Space Grotesk (headings).
|
||||
const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
|
||||
@@ -56,49 +34,10 @@ const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
|
||||
display=swap";
|
||||
|
||||
/// Root application component. Loads global assets and mounts the router.
|
||||
///
|
||||
/// Provides a `Signal<Locale>` context that all child components can read
|
||||
/// via `use_context::<Signal<Locale>>()` to access the current locale.
|
||||
/// The locale is persisted in `localStorage` under `"certifai_locale"`.
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
// Read persisted locale from localStorage on first render.
|
||||
let initial_locale = {
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok().flatten())
|
||||
.and_then(|s| s.get_item("certifai_locale").ok().flatten())
|
||||
.map(|code| Locale::from_code(&code))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
#[cfg(not(feature = "web"))]
|
||||
{
|
||||
Locale::default()
|
||||
}
|
||||
};
|
||||
use_context_provider(|| Signal::new(initial_locale));
|
||||
|
||||
rsx! {
|
||||
// Seggwat feedback widget
|
||||
document::Script {
|
||||
src: "https://seggwat.com/static/widgets/v1/seggwat-feedback.js",
|
||||
r#defer: true,
|
||||
"data-project-key": "a04b8cf1-9177-42ce-8a7b-084f38b99799",
|
||||
"data-button-color": "#6d85c6",
|
||||
"data-button-position": "right-side",
|
||||
"data-enable-screenshots": "true",
|
||||
}
|
||||
|
||||
document::Link { rel: "icon", href: FAVICON }
|
||||
document::Link { rel: "manifest", href: MANIFEST }
|
||||
document::Meta { name: "theme-color", content: "#4B3FE0" }
|
||||
document::Meta { name: "apple-mobile-web-app-capable", content: "yes" }
|
||||
document::Meta {
|
||||
name: "apple-mobile-web-app-status-bar-style",
|
||||
content: "black-translucent",
|
||||
}
|
||||
document::Link { rel: "apple-touch-icon", href: FAVICON }
|
||||
document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" }
|
||||
document::Link {
|
||||
rel: "preconnect",
|
||||
@@ -108,28 +47,6 @@ pub fn App() -> Element {
|
||||
document::Link { rel: "stylesheet", href: GOOGLE_FONTS }
|
||||
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
||||
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||
|
||||
// Register PWA service worker
|
||||
document::Script {
|
||||
r#"
|
||||
if ('serviceWorker' in navigator) {{
|
||||
navigator.serviceWorker.register('/assets/sw.js')
|
||||
.catch(function(e) {{ console.warn('SW registration failed:', e); }});
|
||||
}}
|
||||
"#
|
||||
}
|
||||
|
||||
// Apply persisted theme to <html> before first paint to avoid flash.
|
||||
// Default to certifai-dark when no preference is stored.
|
||||
document::Script {
|
||||
r#"
|
||||
(function() {{
|
||||
var t = localStorage.getItem('theme') || 'certifai-dark';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
}})();
|
||||
"#
|
||||
}
|
||||
|
||||
Router::<Route> {}
|
||||
div { "data-theme": "certifai-dark", Router::<Route> {} }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +1,21 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::{BsList, BsX};
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::components::sidebar::Sidebar;
|
||||
use crate::i18n::{t, tw, Locale};
|
||||
use crate::infrastructure::auth_check::check_auth;
|
||||
use crate::models::AuthInfo;
|
||||
use crate::Route;
|
||||
|
||||
/// Application shell layout that wraps all authenticated pages.
|
||||
///
|
||||
/// Calls [`check_auth`] on mount to fetch the current user's session.
|
||||
/// If unauthenticated, redirects to `/auth`. Otherwise renders the
|
||||
/// sidebar with real user data and the active child route.
|
||||
/// 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 {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let mut mobile_menu_open = use_signal(|| false);
|
||||
|
||||
// use_resource memoises the async call and avoids infinite re-render
|
||||
// loops that use_effect + spawn + signal writes can cause.
|
||||
#[allow(clippy::redundant_closure)]
|
||||
let auth = use_resource(move || check_auth());
|
||||
|
||||
// Clone the inner value out of the Signal to avoid holding the
|
||||
// borrow across the rsx! return (Dioxus lifetime constraint).
|
||||
let auth_snapshot: Option<Result<AuthInfo, ServerFnError>> = auth.read().clone();
|
||||
|
||||
match auth_snapshot {
|
||||
Some(Ok(info)) if info.authenticated => {
|
||||
let menu_open = *mobile_menu_open.read();
|
||||
let sidebar_cls = if menu_open {
|
||||
"sidebar sidebar--open"
|
||||
} else {
|
||||
"sidebar"
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "app-shell",
|
||||
// Mobile top bar (visible only on small screens via CSS)
|
||||
header { class: "mobile-header",
|
||||
button {
|
||||
class: "mobile-menu-btn",
|
||||
onclick: move |_| {
|
||||
let current = *mobile_menu_open.read();
|
||||
mobile_menu_open.set(!current);
|
||||
},
|
||||
if menu_open {
|
||||
Icon { icon: BsX, width: 24, height: 24 }
|
||||
} else {
|
||||
Icon { icon: BsList, width: 24, height: 24 }
|
||||
}
|
||||
}
|
||||
span { class: "mobile-header-title", "CERTifAI" }
|
||||
}
|
||||
// Backdrop overlay when sidebar is open on mobile
|
||||
if menu_open {
|
||||
div {
|
||||
class: "sidebar-backdrop",
|
||||
onclick: move |_| mobile_menu_open.set(false),
|
||||
}
|
||||
}
|
||||
Sidebar {
|
||||
email: info.email,
|
||||
name: info.name,
|
||||
avatar_url: info.avatar_url,
|
||||
librechat_url: info.librechat_url,
|
||||
class: sidebar_cls,
|
||||
on_nav: move |_| mobile_menu_open.set(false),
|
||||
}
|
||||
main { class: "main-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) => {
|
||||
// Not authenticated -- redirect to login.
|
||||
let nav = navigator();
|
||||
nav.push(NavigationTarget::<Route>::External("/auth".into()));
|
||||
rsx! {
|
||||
div { class: "app-shell loading",
|
||||
p { {t(*locale.read(), "auth.redirecting_login")} }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
let msg = e.to_string();
|
||||
let error_text = tw(*locale.read(), "auth.auth_error", &[("msg", &msg)]);
|
||||
rsx! {
|
||||
div { class: "auth-error",
|
||||
p { {error_text} }
|
||||
a { href: "/auth", {t(*locale.read(), "common.login")} }
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Still loading.
|
||||
rsx! {
|
||||
div { class: "app-shell loading",
|
||||
p { {t(*locale.read(), "common.loading")} }
|
||||
}
|
||||
rsx! {
|
||||
div { class: "app-shell",
|
||||
Sidebar {
|
||||
email: "user@example.com".to_string(),
|
||||
avatar_url: String::new(),
|
||||
}
|
||||
main { class: "main-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::infrastructure::llm::FollowUpMessage;
|
||||
use crate::models::NewsCard;
|
||||
|
||||
/// Side panel displaying the full details of a selected news article.
|
||||
///
|
||||
/// Shows the article title, source, date, category badge, full content,
|
||||
/// a link to the original article, an AI summary bubble, and a follow-up
|
||||
/// chat window for asking questions about the article.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `card` - The selected news card data
|
||||
/// * `on_close` - Handler to close the detail panel
|
||||
/// * `summary` - Optional AI-generated summary text
|
||||
/// * `is_summarizing` - Whether a summarization request is in progress
|
||||
/// * `chat_messages` - Follow-up chat conversation history (user + assistant turns)
|
||||
/// * `is_chatting` - Whether a chat response is being generated
|
||||
/// * `on_chat_send` - Handler called with the user's follow-up question
|
||||
#[component]
|
||||
pub fn ArticleDetail(
|
||||
card: NewsCard,
|
||||
on_close: EventHandler,
|
||||
summary: Option<String>,
|
||||
#[props(default = false)] is_summarizing: bool,
|
||||
chat_messages: Vec<FollowUpMessage>,
|
||||
#[props(default = false)] is_chatting: bool,
|
||||
on_chat_send: EventHandler<String>,
|
||||
) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
let css_suffix = card.category.to_lowercase().replace(' ', "-");
|
||||
let badge_class = format!("news-badge news-badge--{css_suffix}");
|
||||
let mut chat_input = use_signal(String::new);
|
||||
let has_summary = summary.is_some() && !is_summarizing;
|
||||
|
||||
// Build favicon URL using DuckDuckGo's privacy-friendly icon service
|
||||
let favicon_url = format!("https://icons.duckduckgo.com/ip3/{}.ico", card.source);
|
||||
|
||||
rsx! {
|
||||
aside { class: "article-detail-panel",
|
||||
// Close button
|
||||
button {
|
||||
class: "article-detail-close",
|
||||
onclick: move |_| on_close.call(()),
|
||||
"{t(l, \"common.close\")}"
|
||||
}
|
||||
|
||||
div { class: "article-detail-content",
|
||||
// Header
|
||||
h2 { class: "article-detail-title", "{card.title}" }
|
||||
|
||||
div { class: "article-detail-meta",
|
||||
span { class: "{badge_class}", "{card.category}" }
|
||||
span { class: "article-detail-source",
|
||||
img {
|
||||
class: "source-favicon",
|
||||
src: "{favicon_url}",
|
||||
alt: "",
|
||||
width: "16",
|
||||
height: "16",
|
||||
}
|
||||
"{card.source}"
|
||||
}
|
||||
span { class: "article-detail-date", "{card.published_at}" }
|
||||
}
|
||||
|
||||
// Content body
|
||||
div { class: "article-detail-body",
|
||||
p { "{card.content}" }
|
||||
}
|
||||
|
||||
// Link to original
|
||||
a {
|
||||
class: "article-detail-link",
|
||||
href: "{card.url}",
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
"{t(l, \"article.read_original\")}"
|
||||
}
|
||||
|
||||
// AI Summary bubble (below the link)
|
||||
div { class: "ai-summary-bubble",
|
||||
if is_summarizing {
|
||||
div { class: "ai-summary-bubble-loading",
|
||||
div { class: "ai-summary-dot-pulse" }
|
||||
span { "{t(l, \"article.summarizing\")}" }
|
||||
}
|
||||
} else if let Some(ref text) = summary {
|
||||
p { class: "ai-summary-bubble-text", "{text}" }
|
||||
span { class: "ai-summary-bubble-label", "{t(l, \"article.summarized_with_ai\")}" }
|
||||
}
|
||||
}
|
||||
|
||||
// Follow-up chat window (visible after summary is ready)
|
||||
if has_summary {
|
||||
div { class: "article-chat",
|
||||
// Chat message history
|
||||
if !chat_messages.is_empty() {
|
||||
div { class: "article-chat-messages",
|
||||
for msg in chat_messages.iter() {
|
||||
{
|
||||
let bubble_class = if msg.role == "user" {
|
||||
"chat-msg chat-msg--user"
|
||||
} else {
|
||||
"chat-msg chat-msg--assistant"
|
||||
};
|
||||
rsx! {
|
||||
div { class: "{bubble_class}",
|
||||
p { "{msg.content}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_chatting {
|
||||
div { class: "chat-msg chat-msg--assistant chat-msg--typing",
|
||||
div { class: "ai-summary-dot-pulse" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chat input
|
||||
div { class: "article-chat-input",
|
||||
input {
|
||||
class: "article-chat-textbox",
|
||||
r#type: "text",
|
||||
placeholder: "{t(l, \"article.ask_followup\")}",
|
||||
value: "{chat_input}",
|
||||
disabled: is_chatting,
|
||||
oninput: move |e| chat_input.set(e.value()),
|
||||
onkeypress: move |e| {
|
||||
if e.key() == Key::Enter && !is_chatting {
|
||||
let val = chat_input.read().trim().to_string();
|
||||
if !val.is_empty() {
|
||||
on_chat_send.call(val);
|
||||
chat_input.set(String::new());
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "article-chat-send",
|
||||
disabled: is_chatting,
|
||||
onclick: move |_| {
|
||||
let val = chat_input.read().trim().to_string();
|
||||
if !val.is_empty() {
|
||||
on_chat_send.call(val);
|
||||
chat_input.set(String::new());
|
||||
}
|
||||
},
|
||||
"{t(l, \"common.send\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::infrastructure::ollama::{get_ollama_status, OllamaStatus};
|
||||
|
||||
/// Right sidebar for the dashboard, showing Ollama status, trending topics,
|
||||
/// and recent search history.
|
||||
///
|
||||
/// Appears when no article card is selected. Disappears when the user opens
|
||||
/// the article detail split view.
|
||||
///
|
||||
/// # Props
|
||||
///
|
||||
/// * `ollama_url` - Ollama instance URL for status polling
|
||||
/// * `trending` - Trending topic keywords extracted from recent news headlines
|
||||
/// * `recent_searches` - Recent search topics stored in localStorage
|
||||
/// * `on_topic_click` - Fires when a trending or recent topic is clicked
|
||||
#[component]
|
||||
pub fn DashboardSidebar(
|
||||
ollama_url: String,
|
||||
trending: Vec<String>,
|
||||
recent_searches: Vec<String>,
|
||||
on_topic_click: EventHandler<String>,
|
||||
) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
// Fetch Ollama status once on mount.
|
||||
// use_resource with no signal dependencies runs exactly once and
|
||||
// won't re-fire on parent re-renders (unlike use_effect).
|
||||
let url = ollama_url.clone();
|
||||
let status_resource = use_resource(move || {
|
||||
let u = url.clone();
|
||||
async move {
|
||||
get_ollama_status(u).await.unwrap_or(OllamaStatus {
|
||||
online: false,
|
||||
models: Vec::new(),
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
let current_status: OllamaStatus =
|
||||
status_resource
|
||||
.read()
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or(OllamaStatus {
|
||||
online: false,
|
||||
models: Vec::new(),
|
||||
});
|
||||
|
||||
rsx! {
|
||||
aside { class: "dashboard-sidebar",
|
||||
|
||||
// -- Ollama Status Section --
|
||||
div { class: "sidebar-section",
|
||||
h4 { class: "sidebar-section-title", "{t(l, \"dashboard.ollama_status\")}" }
|
||||
div { class: "sidebar-status-row",
|
||||
span { class: if current_status.online { "sidebar-status-dot sidebar-status-dot--online" } else { "sidebar-status-dot sidebar-status-dot--offline" } }
|
||||
span { class: "sidebar-status-label",
|
||||
if current_status.online {
|
||||
"{t(l, \"common.online\")}"
|
||||
} else {
|
||||
"{t(l, \"common.offline\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
if !current_status.models.is_empty() {
|
||||
div { class: "sidebar-model-list",
|
||||
for model in current_status.models.iter() {
|
||||
span { class: "sidebar-model-tag", "{model}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Trending Topics Section --
|
||||
if !trending.is_empty() {
|
||||
div { class: "sidebar-section",
|
||||
h4 { class: "sidebar-section-title", "{t(l, \"dashboard.trending\")}" }
|
||||
for topic in trending.iter() {
|
||||
{
|
||||
let t = topic.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "sidebar-topic-link",
|
||||
onclick: move |_| on_topic_click.call(t.clone()),
|
||||
"{topic}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Recent Searches Section --
|
||||
if !recent_searches.is_empty() {
|
||||
div { class: "sidebar-section",
|
||||
h4 { class: "sidebar-section-title", "{t(l, \"dashboard.recent_searches\")}" }
|
||||
for search in recent_searches.iter() {
|
||||
{
|
||||
let s = search.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "sidebar-topic-link",
|
||||
onclick: move |_| on_topic_click.call(s.clone()),
|
||||
"{search}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::Route;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Login redirect component.
|
||||
///
|
||||
@@ -14,8 +12,6 @@ use crate::Route;
|
||||
#[component]
|
||||
pub fn Login(redirect_url: String) -> Element {
|
||||
let navigator = use_navigator();
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
use_effect(move || {
|
||||
// Default to /dashboard when redirect_url is empty.
|
||||
@@ -29,6 +25,6 @@ pub fn Login(redirect_url: String) -> Element {
|
||||
});
|
||||
|
||||
rsx!(
|
||||
div { class: "text-center p-6", "{t(l, \"auth.redirecting_secure\")}" }
|
||||
div { class: "text-center p-6", "Redirecting to secure login page…" }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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,22 +1,8 @@
|
||||
mod app_shell;
|
||||
mod article_detail;
|
||||
mod card;
|
||||
mod dashboard_sidebar;
|
||||
mod login;
|
||||
mod member_row;
|
||||
pub mod news_card;
|
||||
mod page_header;
|
||||
mod pricing_card;
|
||||
pub mod sidebar;
|
||||
pub mod sub_nav;
|
||||
|
||||
pub use app_shell::*;
|
||||
pub use article_detail::*;
|
||||
pub use card::*;
|
||||
pub use dashboard_sidebar::*;
|
||||
pub use login::*;
|
||||
pub use member_row::*;
|
||||
pub use news_card::*;
|
||||
pub use page_header::*;
|
||||
pub use pricing_card::*;
|
||||
pub use sub_nav::*;
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
use crate::models::NewsCard as NewsCardModel;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Renders a news feed card with title, source, category badge, and summary.
|
||||
///
|
||||
/// When a thumbnail URL is present but the image fails to load, the card
|
||||
/// automatically switches to the centered no-thumbnail layout.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `card` - The news card model data to render
|
||||
/// * `on_click` - Event handler triggered when the card is clicked
|
||||
/// * `selected` - Whether this card is currently selected (highlighted)
|
||||
#[component]
|
||||
pub fn NewsCardView(
|
||||
card: NewsCardModel,
|
||||
on_click: EventHandler<NewsCardModel>,
|
||||
#[props(default = false)] selected: bool,
|
||||
) -> Element {
|
||||
// Derive a CSS class from the category string (lowercase, hyphenated)
|
||||
let css_suffix = card.category.to_lowercase().replace(' ', "-");
|
||||
let badge_class = format!("news-badge news-badge--{css_suffix}");
|
||||
|
||||
// Track whether the thumbnail loaded successfully.
|
||||
// Starts as true if a URL is provided; set to false on image error.
|
||||
let has_thumb_url = card.thumbnail_url.is_some();
|
||||
let mut thumb_ok = use_signal(|| has_thumb_url);
|
||||
|
||||
let show_thumb = has_thumb_url && *thumb_ok.read();
|
||||
let selected_cls = if selected { " news-card--selected" } else { "" };
|
||||
let thumb_cls = if show_thumb {
|
||||
""
|
||||
} else {
|
||||
" news-card--no-thumb"
|
||||
};
|
||||
let card_class = format!("news-card{selected_cls}{thumb_cls}");
|
||||
|
||||
// Clone the card for the click handler closure
|
||||
let card_for_click = card.clone();
|
||||
|
||||
rsx! {
|
||||
article {
|
||||
class: "{card_class}",
|
||||
onclick: move |_| on_click.call(card_for_click.clone()),
|
||||
if let Some(ref thumb) = card.thumbnail_url {
|
||||
if *thumb_ok.read() {
|
||||
div { class: "news-card-thumb",
|
||||
img {
|
||||
src: "{thumb}",
|
||||
alt: "",
|
||||
loading: "lazy",
|
||||
// Hide the thumbnail container if the image fails to load
|
||||
onerror: move |_| thumb_ok.set(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "news-card-body",
|
||||
div { class: "news-card-meta",
|
||||
span { class: "{badge_class}", "{card.category}" }
|
||||
span { class: "news-card-source", "{card.source}" }
|
||||
span { class: "news-card-date", "{card.published_at}" }
|
||||
}
|
||||
h3 { class: "news-card-title", "{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(),
|
||||
content: "Meta has officially released Llama 4, their latest \
|
||||
open-weight large language model featuring a groundbreaking \
|
||||
1 million token context window. This represents a major \
|
||||
leap in context length capabilities."
|
||||
.into(),
|
||||
category: "AI".into(),
|
||||
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(),
|
||||
content: "The EU AI Act has officially entered its enforcement \
|
||||
phase. Member states are now required to comply with the \
|
||||
comprehensive regulatory framework governing AI systems."
|
||||
.into(),
|
||||
category: "Privacy".into(),
|
||||
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(),
|
||||
content: "LangChain v0.4 introduces native Model Context Protocol \
|
||||
support, enabling seamless integration with MCP servers for \
|
||||
tool use and context management in agent workflows."
|
||||
.into(),
|
||||
category: "Technology".into(),
|
||||
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(),
|
||||
content: "Ollama now supports multi-GPU scheduling with automatic \
|
||||
model sharding. Users can run models across multiple GPUs \
|
||||
for improved inference performance."
|
||||
.into(),
|
||||
category: "Infrastructure".into(),
|
||||
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(),
|
||||
content: "Mistral AI has open-sourced Codestral 2, a code \
|
||||
generation model that achieves state-of-the-art results \
|
||||
on HumanEval and other coding benchmarks."
|
||||
.into(),
|
||||
category: "Open Source".into(),
|
||||
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(),
|
||||
content: "NVIDIA has released NeMo 3.0, an updated framework \
|
||||
that simplifies enterprise LLM fine-tuning with improved \
|
||||
distributed training capabilities."
|
||||
.into(),
|
||||
category: "Infrastructure".into(),
|
||||
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(),
|
||||
content: "Anthropic's Claude 4 has set new records across major \
|
||||
reasoning benchmarks, demonstrating significant improvements \
|
||||
in mathematical and logical reasoning capabilities."
|
||||
.into(),
|
||||
category: "AI".into(),
|
||||
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(),
|
||||
content: "CrewAI has raised $52M in Series B funding to expand \
|
||||
its multi-agent orchestration platform, enabling teams \
|
||||
to build and deploy complex AI agent workflows."
|
||||
.into(),
|
||||
category: "Technology".into(),
|
||||
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(),
|
||||
content: "DeepSeek has released V4 under the Apache 2.0 license, \
|
||||
an open-weight model that competes with proprietary \
|
||||
offerings in both performance and efficiency."
|
||||
.into(),
|
||||
category: "Open Source".into(),
|
||||
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(),
|
||||
content: "European regulators have issued record-high GDPR fines \
|
||||
for AI training data misuse, signaling stricter enforcement \
|
||||
of data protection laws in the AI sector."
|
||||
.into(),
|
||||
category: "Privacy".into(),
|
||||
url: "#".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2026-02-09".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::i18n::{t, tw, Locale};
|
||||
use crate::models::PricingPlan;
|
||||
|
||||
/// 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 locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
let card_class = if plan.highlighted {
|
||||
"pricing-card pricing-card--highlighted"
|
||||
} else {
|
||||
"pricing-card"
|
||||
};
|
||||
|
||||
let seats_label = match plan.max_seats {
|
||||
Some(n) => tw(l, "common.up_to_seats", &[("n", &n.to_string())]),
|
||||
None => t(l, "common.unlimited_seats"),
|
||||
};
|
||||
|
||||
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", " {t(l, \"common.eur_per_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())
|
||||
},
|
||||
"{t(l, \"common.get_started\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,16 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::{
|
||||
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsGithub, BsGlobe2,
|
||||
BsGrid, BsHouseDoor, BsMoonFill, BsSunFill,
|
||||
BsBoxArrowRight, BsFileEarmarkText, BsGear, BsGithub, BsGrid, BsHouseDoor, BsRobot,
|
||||
};
|
||||
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::Route;
|
||||
|
||||
/// Destination for a sidebar link: either an internal route or an external URL.
|
||||
enum NavTarget {
|
||||
/// Internal Dioxus route (rendered as `Link { to: route }`).
|
||||
Internal(Route),
|
||||
/// External URL opened in a new tab (rendered as `<a href>`).
|
||||
External(String),
|
||||
}
|
||||
|
||||
/// Navigation entry for the sidebar.
|
||||
///
|
||||
/// `key` is a stable identifier used for active-route detection and never
|
||||
/// changes across locales. `label` is the translated display string.
|
||||
struct NavItem {
|
||||
key: &'static str,
|
||||
label: String,
|
||||
target: NavTarget,
|
||||
label: &'static str,
|
||||
route: Route,
|
||||
/// Bootstrap icon element rendered beside the label.
|
||||
icon: Element,
|
||||
}
|
||||
@@ -32,331 +19,103 @@ struct NavItem {
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - User display name (shown in header if non-empty).
|
||||
/// * `email` - Email address displayed beneath the avatar placeholder.
|
||||
/// * `avatar_url` - URL for the avatar image (unused placeholder for now).
|
||||
/// * `class` - CSS class override (e.g. to add `sidebar--open` on mobile).
|
||||
/// * `on_nav` - Callback fired when a nav link is clicked (used to close
|
||||
/// the mobile menu).
|
||||
#[component]
|
||||
pub fn Sidebar(
|
||||
name: String,
|
||||
email: String,
|
||||
avatar_url: String,
|
||||
#[props(default = "http://localhost:3080".to_string())] librechat_url: String,
|
||||
#[props(default = "sidebar".to_string())] class: String,
|
||||
#[props(default)] on_nav: EventHandler<()>,
|
||||
) -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let locale_val = *locale.read();
|
||||
|
||||
pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
||||
let nav_items: Vec<NavItem> = vec![
|
||||
NavItem {
|
||||
key: "dashboard",
|
||||
label: t(locale_val, "nav.dashboard"),
|
||||
target: NavTarget::Internal(Route::DashboardPage {}),
|
||||
label: "Overview",
|
||||
route: Route::OverviewPage {},
|
||||
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
key: "providers",
|
||||
label: t(locale_val, "nav.providers"),
|
||||
target: NavTarget::Internal(Route::ProvidersPage {}),
|
||||
icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } },
|
||||
label: "Documentation",
|
||||
route: Route::OverviewPage {},
|
||||
icon: rsx! { Icon { icon: BsFileEarmarkText, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
key: "chat",
|
||||
label: t(locale_val, "nav.chat"),
|
||||
// Opens LibreChat in a new tab; SSO via shared Keycloak realm.
|
||||
target: NavTarget::External(librechat_url.clone()),
|
||||
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
|
||||
label: "Agents",
|
||||
route: Route::OverviewPage {},
|
||||
icon: rsx! { Icon { icon: BsRobot, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
key: "developer",
|
||||
label: t(locale_val, "nav.developer"),
|
||||
target: NavTarget::Internal(Route::AgentsPage {}),
|
||||
icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } },
|
||||
label: "Models",
|
||||
route: Route::OverviewPage {},
|
||||
icon: rsx! { Icon { icon: FaCubes, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
key: "organization",
|
||||
label: t(locale_val, "nav.organization"),
|
||||
target: NavTarget::Internal(Route::OrgPricingPage {}),
|
||||
icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } },
|
||||
label: "Settings",
|
||||
route: Route::OverviewPage {},
|
||||
icon: rsx! { Icon { icon: BsGear, width: 18, height: 18 } },
|
||||
},
|
||||
];
|
||||
|
||||
// Determine current path to highlight the active nav link.
|
||||
let current_route = use_route::<Route>();
|
||||
let logout_label = t(locale_val, "common.logout");
|
||||
|
||||
rsx! {
|
||||
aside { class: "{class}",
|
||||
div { class: "sidebar-top-row",
|
||||
SidebarHeader { name, email: email.clone(), avatar_url }
|
||||
LocalePicker {}
|
||||
}
|
||||
aside { class: "sidebar",
|
||||
// -- Header: avatar circle + email --
|
||||
SidebarHeader { email: email.clone(), avatar_url }
|
||||
|
||||
// -- Navigation links --
|
||||
nav { class: "sidebar-nav",
|
||||
for item in nav_items {
|
||||
{
|
||||
match &item.target {
|
||||
NavTarget::Internal(route) => {
|
||||
// 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.key == "developer"
|
||||
}
|
||||
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
|
||||
item.key == "organization"
|
||||
}
|
||||
_ => *route == current_route,
|
||||
};
|
||||
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
|
||||
let route = route.clone();
|
||||
rsx! {
|
||||
Link {
|
||||
to: route,
|
||||
class: cls,
|
||||
onclick: move |_| on_nav.call(()),
|
||||
{item.icon}
|
||||
span { "{item.label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
NavTarget::External(url) => {
|
||||
let url = url.clone();
|
||||
rsx! {
|
||||
a {
|
||||
href: url,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
class: "sidebar-link",
|
||||
onclick: move |_| on_nav.call(()),
|
||||
{item.icon}
|
||||
span { "{item.label}" }
|
||||
}
|
||||
}
|
||||
// Simple active check: highlight Overview only when on `/`.
|
||||
let is_active = item.route == current_route;
|
||||
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
|
||||
rsx! {
|
||||
Link { to: item.route, class: cls,
|
||||
{item.icon}
|
||||
span { "{item.label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "sidebar-bottom-actions",
|
||||
// -- Logout button --
|
||||
div { class: "sidebar-logout",
|
||||
Link {
|
||||
to: NavigationTarget::<Route>::External("/logout".into()),
|
||||
to: NavigationTarget::<Route>::External("/auth/logout".into()),
|
||||
class: "sidebar-link logout-btn",
|
||||
Icon { icon: BsBoxArrowRight, width: 18, height: 18 }
|
||||
span { "{logout_label}" }
|
||||
span { "Logout" }
|
||||
}
|
||||
ThemeToggle {}
|
||||
}
|
||||
|
||||
// -- Footer: version + social links --
|
||||
SidebarFooter {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar circle, name, and email display at the top of the sidebar.
|
||||
/// Avatar circle and email display at the top of the sidebar.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - User display name. If non-empty, shown above the email.
|
||||
/// * `email` - User email to display.
|
||||
/// * `avatar_url` - Placeholder for future avatar image URL.
|
||||
#[component]
|
||||
fn SidebarHeader(name: String, email: String, avatar_url: String) -> Element {
|
||||
// Derive initials: prefer name words, fall back to email prefix.
|
||||
let initials: String = if name.is_empty() {
|
||||
email
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("U")
|
||||
.chars()
|
||||
.take(2)
|
||||
.collect::<String>()
|
||||
.to_uppercase()
|
||||
} else {
|
||||
name.split_whitespace()
|
||||
.filter_map(|w| w.chars().next())
|
||||
.take(2)
|
||||
.collect::<String>()
|
||||
.to_uppercase()
|
||||
};
|
||||
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}" }
|
||||
}
|
||||
div { class: "sidebar-user-info",
|
||||
if !name.is_empty() {
|
||||
p { class: "sidebar-name", "{name}" }
|
||||
}
|
||||
p { class: "sidebar-email", "{email}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle button that switches between dark and light themes.
|
||||
///
|
||||
/// Sets `data-theme` on the `<html>` element and persists the choice
|
||||
/// in `localStorage` so it survives page reloads.
|
||||
#[component]
|
||||
fn ThemeToggle() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
|
||||
let mut is_dark = use_signal(|| {
|
||||
// Read persisted preference from localStorage on first render.
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok().flatten())
|
||||
.and_then(|s| s.get_item("theme").ok().flatten())
|
||||
.is_none_or(|v| v != "certifai-light")
|
||||
}
|
||||
#[cfg(not(feature = "web"))]
|
||||
{
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
// Apply the persisted theme to the DOM on first render so the
|
||||
// page doesn't flash dark if the user previously chose light.
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let dark = *is_dark.read();
|
||||
use_effect(move || {
|
||||
let theme = if dark {
|
||||
"certifai-dark"
|
||||
} else {
|
||||
"certifai-light"
|
||||
};
|
||||
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
|
||||
if let Some(el) = doc.document_element() {
|
||||
let _ = el.set_attribute("data-theme", theme);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let toggle = move |_| {
|
||||
let new_dark = !*is_dark.read();
|
||||
is_dark.set(new_dark);
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let theme = if new_dark {
|
||||
"certifai-dark"
|
||||
} else {
|
||||
"certifai-light"
|
||||
};
|
||||
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
|
||||
if let Some(el) = doc.document_element() {
|
||||
let _ = el.set_attribute("data-theme", theme);
|
||||
}
|
||||
}
|
||||
if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten())
|
||||
{
|
||||
let _ = storage.set_item("theme", theme);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let dark = *is_dark.read();
|
||||
let locale_val = *locale.read();
|
||||
let title = if dark {
|
||||
t(locale_val, "nav.switch_light")
|
||||
} else {
|
||||
t(locale_val, "nav.switch_dark")
|
||||
};
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
class: "theme-toggle-btn",
|
||||
title: "{title}",
|
||||
onclick: toggle,
|
||||
if dark {
|
||||
Icon { icon: BsSunFill, width: 16, height: 16 }
|
||||
} else {
|
||||
Icon { icon: BsMoonFill, width: 16, height: 16 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact language picker with globe icon and ISO 3166-1 alpha-2 code.
|
||||
///
|
||||
/// Renders a button showing a globe icon and the current locale's two-letter
|
||||
/// country code (e.g. "EN", "DE"). Clicking toggles a dropdown overlay with
|
||||
/// all available locales. Persists the selection to `localStorage`.
|
||||
#[component]
|
||||
fn LocalePicker() -> Element {
|
||||
let mut locale = use_context::<Signal<Locale>>();
|
||||
let current = *locale.read();
|
||||
let mut open = use_signal(|| false);
|
||||
|
||||
let mut select_locale = move |new_locale: Locale| {
|
||||
locale.set(new_locale);
|
||||
open.set(false);
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten())
|
||||
{
|
||||
let _ = storage.set_item("certifai_locale", new_locale.code());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let code_upper = current.code().to_uppercase();
|
||||
|
||||
rsx! {
|
||||
div { class: "locale-picker",
|
||||
button {
|
||||
class: "locale-picker-btn",
|
||||
title: current.label(),
|
||||
onclick: move |_| {
|
||||
let cur = *open.read();
|
||||
open.set(!cur);
|
||||
},
|
||||
Icon { icon: BsGlobe2, width: 14, height: 14 }
|
||||
span { class: "locale-picker-code", "{code_upper}" }
|
||||
}
|
||||
if *open.read() {
|
||||
// Invisible backdrop to close dropdown on outside click
|
||||
div {
|
||||
class: "locale-picker-backdrop",
|
||||
onclick: move |_| open.set(false),
|
||||
}
|
||||
div { class: "locale-picker-dropdown",
|
||||
for loc in Locale::all() {
|
||||
{
|
||||
let is_active = *loc == current;
|
||||
let cls = if is_active {
|
||||
"locale-picker-item locale-picker-item--active"
|
||||
} else {
|
||||
"locale-picker-item"
|
||||
};
|
||||
let loc_copy = *loc;
|
||||
rsx! {
|
||||
button {
|
||||
class: "{cls}",
|
||||
onclick: move |_| select_locale(loc_copy),
|
||||
span { class: "locale-picker-item-code",
|
||||
"{loc_copy.code().to_uppercase()}"
|
||||
}
|
||||
span { class: "locale-picker-item-label",
|
||||
"{loc_copy.label()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p { class: "sidebar-email", "{email}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,30 +123,18 @@ fn LocalePicker() -> Element {
|
||||
/// Footer section with version string and placeholder social links.
|
||||
#[component]
|
||||
fn SidebarFooter() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let locale_val = *locale.read();
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
let github_title = t(locale_val, "nav.github");
|
||||
let impressum_title = t(locale_val, "common.impressum");
|
||||
let privacy_label = t(locale_val, "common.privacy_policy");
|
||||
let impressum_label = t(locale_val, "common.impressum");
|
||||
|
||||
rsx! {
|
||||
footer { class: "sidebar-footer",
|
||||
div { class: "sidebar-social",
|
||||
a { href: "#", class: "social-link", title: "{github_title}",
|
||||
a { href: "#", class: "social-link", title: "GitHub",
|
||||
Icon { icon: BsGithub, width: 16, height: 16 }
|
||||
}
|
||||
a { href: "#", class: "social-link", title: "{impressum_title}",
|
||||
a { href: "#", class: "social-link", title: "Impressum",
|
||||
Icon { icon: BsGrid, width: 16, height: 16 }
|
||||
}
|
||||
}
|
||||
div { class: "sidebar-legal",
|
||||
Link { to: Route::PrivacyPage {}, class: "legal-link", "{privacy_label}" }
|
||||
span { class: "legal-sep", "|" }
|
||||
Link { to: Route::ImpressumPage {}, class: "legal-link", "{impressum_label}" }
|
||||
}
|
||||
p { class: "sidebar-version", "v{version}" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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: String,
|
||||
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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
242
src/i18n/mod.rs
242
src/i18n/mod.rs
@@ -1,242 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Supported application locales.
|
||||
///
|
||||
/// Each variant maps to an ISO 639-1 code and a human-readable label
|
||||
/// displayed in the language picker.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Locale {
|
||||
#[default]
|
||||
En,
|
||||
De,
|
||||
Fr,
|
||||
Es,
|
||||
Pt,
|
||||
}
|
||||
|
||||
impl Locale {
|
||||
/// ISO 639-1 language code.
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Locale::En => "en",
|
||||
Locale::De => "de",
|
||||
Locale::Fr => "fr",
|
||||
Locale::Es => "es",
|
||||
Locale::Pt => "pt",
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable label in the locale's own language.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Locale::En => "English",
|
||||
Locale::De => "Deutsch",
|
||||
Locale::Fr => "Francais",
|
||||
Locale::Es => "Espanol",
|
||||
Locale::Pt => "Portugues",
|
||||
}
|
||||
}
|
||||
|
||||
/// All available locales.
|
||||
pub fn all() -> &'static [Locale] {
|
||||
&[Locale::En, Locale::De, Locale::Fr, Locale::Es, Locale::Pt]
|
||||
}
|
||||
|
||||
/// Parse a locale from its ISO 639-1 code.
|
||||
///
|
||||
/// Returns `Locale::En` for unrecognized codes.
|
||||
pub fn from_code(code: &str) -> Self {
|
||||
match code {
|
||||
"de" => Locale::De,
|
||||
"fr" => Locale::Fr,
|
||||
"es" => Locale::Es,
|
||||
"pt" => Locale::Pt,
|
||||
_ => Locale::En,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type TranslationMap = HashMap<String, String>;
|
||||
|
||||
/// All translations loaded at compile time and parsed lazily on first access.
|
||||
///
|
||||
/// Uses `LazyLock` (stable since Rust 1.80) to avoid runtime file I/O.
|
||||
/// Each locale's JSON is embedded via `include_str!` and flattened into
|
||||
/// dot-separated keys (e.g. `"nav.dashboard"` -> `"Dashboard"`).
|
||||
static TRANSLATIONS: LazyLock<HashMap<&'static str, TranslationMap>> = LazyLock::new(|| {
|
||||
let mut map = HashMap::with_capacity(5);
|
||||
map.insert(
|
||||
"en",
|
||||
parse_translations(include_str!("../../assets/i18n/en.json")),
|
||||
);
|
||||
map.insert(
|
||||
"de",
|
||||
parse_translations(include_str!("../../assets/i18n/de.json")),
|
||||
);
|
||||
map.insert(
|
||||
"fr",
|
||||
parse_translations(include_str!("../../assets/i18n/fr.json")),
|
||||
);
|
||||
map.insert(
|
||||
"es",
|
||||
parse_translations(include_str!("../../assets/i18n/es.json")),
|
||||
);
|
||||
map.insert(
|
||||
"pt",
|
||||
parse_translations(include_str!("../../assets/i18n/pt.json")),
|
||||
);
|
||||
map
|
||||
});
|
||||
|
||||
/// Parse a JSON string into a flat `key -> value` map.
|
||||
///
|
||||
/// Nested objects are flattened with dot separators:
|
||||
/// `{ "nav": { "home": "Home" } }` becomes `"nav.home" -> "Home"`.
|
||||
fn parse_translations(json: &str) -> TranslationMap {
|
||||
// SAFETY: translation JSON files are bundled at compile time and are
|
||||
// validated during development. A malformed file will panic here during
|
||||
// the first access, which surfaces immediately in testing.
|
||||
let value: Value = serde_json::from_str(json).unwrap_or(Value::Object(Default::default()));
|
||||
let mut map = TranslationMap::new();
|
||||
flatten_json("", &value, &mut map);
|
||||
map
|
||||
}
|
||||
|
||||
/// Recursively flatten a JSON value into dot-separated keys.
|
||||
fn flatten_json(prefix: &str, value: &Value, map: &mut TranslationMap) {
|
||||
match value {
|
||||
Value::Object(obj) => {
|
||||
for (key, val) in obj {
|
||||
let new_prefix = if prefix.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{prefix}.{key}")
|
||||
};
|
||||
flatten_json(&new_prefix, val, map);
|
||||
}
|
||||
}
|
||||
Value::String(s) => {
|
||||
map.insert(prefix.to_string(), s.clone());
|
||||
}
|
||||
// Non-string leaf values are skipped (numbers, bools, nulls)
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a translation for the given locale and key.
|
||||
///
|
||||
/// Falls back to English if the key is missing in the target locale.
|
||||
/// Returns the raw key if not found in any locale (useful for debugging
|
||||
/// missing translations).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `locale` - The target locale
|
||||
/// * `key` - Dot-separated translation key (e.g. `"nav.dashboard"`)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The translated string, or the key itself as a fallback.
|
||||
pub fn t(locale: Locale, key: &str) -> String {
|
||||
TRANSLATIONS
|
||||
.get(locale.code())
|
||||
.and_then(|map| map.get(key))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback to English
|
||||
TRANSLATIONS
|
||||
.get("en")
|
||||
.and_then(|map| map.get(key))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| key.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up a translation and substitute variables.
|
||||
///
|
||||
/// Variables in the translation string use `{name}` syntax.
|
||||
/// Each `(name, value)` pair in `vars` replaces `{name}` with `value`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `locale` - The target locale
|
||||
/// * `key` - Dot-separated translation key
|
||||
/// * `vars` - Slice of `(name, value)` pairs for substitution
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The translated string with all variables substituted.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use dashboard::i18n::{tw, Locale};
|
||||
/// let text = tw(Locale::En, "common.up_to_seats", &[("n", "5")]);
|
||||
/// assert_eq!(text, "Up to 5 seats");
|
||||
/// ```
|
||||
pub fn tw(locale: Locale, key: &str, vars: &[(&str, &str)]) -> String {
|
||||
let mut result = t(locale, key);
|
||||
for (name, value) in vars {
|
||||
result = result.replace(&format!("{{{name}}}"), value);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn english_lookup() {
|
||||
let result = t(Locale::En, "nav.dashboard");
|
||||
assert_eq!(result, "Dashboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_lookup() {
|
||||
let result = t(Locale::De, "nav.dashboard");
|
||||
assert_eq!(result, "Dashboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_to_english() {
|
||||
// If a key exists in English but not in another locale, English is returned
|
||||
let en = t(Locale::En, "common.loading");
|
||||
let result = t(Locale::De, "common.loading");
|
||||
// German should have its own translation, but if missing, falls back to EN
|
||||
assert!(!result.is_empty());
|
||||
// Just verify it doesn't return the key itself
|
||||
assert_ne!(result, "common.loading");
|
||||
let _ = en; // suppress unused warning
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_key_returns_key() {
|
||||
let result = t(Locale::En, "nonexistent.key");
|
||||
assert_eq!(result, "nonexistent.key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_substitution() {
|
||||
let result = tw(Locale::En, "common.up_to_seats", &[("n", "5")]);
|
||||
assert_eq!(result, "Up to 5 seats");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_from_code() {
|
||||
assert_eq!(Locale::from_code("de"), Locale::De);
|
||||
assert_eq!(Locale::from_code("fr"), Locale::Fr);
|
||||
assert_eq!(Locale::from_code("unknown"), Locale::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_locales_loaded() {
|
||||
for locale in Locale::all() {
|
||||
let result = t(*locale, "nav.dashboard");
|
||||
assert!(!result.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,7 @@ use rand::RngExt;
|
||||
use tower_sessions::Session;
|
||||
use url::Url;
|
||||
|
||||
use crate::infrastructure::{
|
||||
server_state::ServerState,
|
||||
state::{User, UserStateInner},
|
||||
Error,
|
||||
};
|
||||
use crate::infrastructure::{state::User, Error, UserStateInner};
|
||||
|
||||
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
|
||||
|
||||
@@ -24,9 +20,9 @@ pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
|
||||
/// post-login redirect URL and the PKCE code verifier needed for the
|
||||
/// token exchange.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingOAuthEntry {
|
||||
pub(crate) redirect_url: Option<String>,
|
||||
pub(crate) code_verifier: String,
|
||||
struct PendingOAuthEntry {
|
||||
redirect_url: Option<String>,
|
||||
code_verifier: String,
|
||||
}
|
||||
|
||||
/// In-memory store for pending OAuth states. Keyed by the random state
|
||||
@@ -38,7 +34,7 @@ pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
|
||||
|
||||
impl PendingOAuthStore {
|
||||
/// Insert a pending state with an optional redirect URL and PKCE verifier.
|
||||
pub(crate) fn insert(&self, state: String, entry: PendingOAuthEntry) {
|
||||
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)]
|
||||
@@ -50,7 +46,7 @@ impl PendingOAuthStore {
|
||||
|
||||
/// Remove and return the entry if the state was pending.
|
||||
/// Returns `None` if the state was never stored (CSRF failure).
|
||||
pub(crate) fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
|
||||
fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
|
||||
#[allow(clippy::expect_used)]
|
||||
self.0
|
||||
.write()
|
||||
@@ -59,9 +55,72 @@ impl PendingOAuthStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration loaded from environment variables for Keycloak OAuth.
|
||||
struct OAuthConfig {
|
||||
keycloak_url: String,
|
||||
realm: String,
|
||||
client_id: String,
|
||||
redirect_uri: String,
|
||||
app_url: String,
|
||||
}
|
||||
|
||||
impl OAuthConfig {
|
||||
/// Load OAuth configuration from environment variables.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::StateError` if any required env var is missing.
|
||||
fn from_env() -> Result<Self, Error> {
|
||||
dotenvy::dotenv().ok();
|
||||
Ok(Self {
|
||||
keycloak_url: std::env::var("KEYCLOAK_URL")
|
||||
.map_err(|_| Error::StateError("KEYCLOAK_URL not set".into()))?,
|
||||
realm: std::env::var("KEYCLOAK_REALM")
|
||||
.map_err(|_| Error::StateError("KEYCLOAK_REALM not set".into()))?,
|
||||
client_id: std::env::var("KEYCLOAK_CLIENT_ID")
|
||||
.map_err(|_| Error::StateError("KEYCLOAK_CLIENT_ID not set".into()))?,
|
||||
redirect_uri: std::env::var("REDIRECT_URI")
|
||||
.map_err(|_| Error::StateError("REDIRECT_URI not set".into()))?,
|
||||
app_url: std::env::var("APP_URL")
|
||||
.map_err(|_| Error::StateError("APP_URL not set".into()))?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the Keycloak OpenID Connect authorization endpoint URL.
|
||||
fn auth_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/auth",
|
||||
self.keycloak_url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the Keycloak OpenID Connect token endpoint URL.
|
||||
fn token_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/token",
|
||||
self.keycloak_url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the Keycloak OpenID Connect userinfo endpoint URL.
|
||||
fn userinfo_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/userinfo",
|
||||
self.keycloak_url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the Keycloak OpenID Connect end-session (logout) endpoint URL.
|
||||
fn logout_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/logout",
|
||||
self.keycloak_url, self.realm
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a cryptographically random state string for CSRF protection.
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
pub(crate) fn generate_state() -> String {
|
||||
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| {
|
||||
@@ -76,7 +135,7 @@ pub(crate) fn generate_state() -> String {
|
||||
///
|
||||
/// Uses 32 random bytes encoded as base64url (no padding) to produce
|
||||
/// a 43-character verifier per RFC 7636.
|
||||
pub(crate) fn generate_code_verifier() -> String {
|
||||
fn generate_code_verifier() -> String {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
|
||||
let bytes: [u8; 32] = rand::rng().random();
|
||||
@@ -86,7 +145,7 @@ pub(crate) fn generate_code_verifier() -> String {
|
||||
/// Derive the S256 code challenge from a code verifier per RFC 7636.
|
||||
///
|
||||
/// `code_challenge = BASE64URL(SHA256(code_verifier))`
|
||||
pub(crate) fn derive_code_challenge(verifier: &str) -> String {
|
||||
fn derive_code_challenge(verifier: &str) -> String {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
@@ -106,36 +165,35 @@ pub(crate) fn derive_code_challenge(verifier: &str) -> String {
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error` if the Keycloak config is missing or the URL is malformed.
|
||||
/// Returns `Error` if env vars are missing.
|
||||
#[axum::debug_handler]
|
||||
pub async fn auth_login(
|
||||
Extension(state): Extension<ServerState>,
|
||||
Extension(pending): Extension<PendingOAuthStore>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
let kc = state.keycloak;
|
||||
let csrf_state = generate_state();
|
||||
let config = OAuthConfig::from_env()?;
|
||||
let state = generate_state();
|
||||
let code_verifier = generate_code_verifier();
|
||||
let code_challenge = derive_code_challenge(&code_verifier);
|
||||
|
||||
let redirect_url = params.get("redirect_url").cloned();
|
||||
pending.insert(
|
||||
csrf_state.clone(),
|
||||
state.clone(),
|
||||
PendingOAuthEntry {
|
||||
redirect_url,
|
||||
code_verifier,
|
||||
},
|
||||
);
|
||||
|
||||
let mut url = Url::parse(&kc.auth_endpoint())
|
||||
let mut url = Url::parse(&config.auth_endpoint())
|
||||
.map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?;
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("client_id", &kc.client_id)
|
||||
.append_pair("redirect_uri", &kc.redirect_uri)
|
||||
.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", &csrf_state)
|
||||
.append_pair("state", &state)
|
||||
.append_pair("code_challenge", &code_challenge)
|
||||
.append_pair("code_challenge_method", "S256");
|
||||
|
||||
@@ -155,10 +213,6 @@ struct UserinfoResponse {
|
||||
/// The subject identifier (unique user ID in Keycloak).
|
||||
sub: String,
|
||||
email: Option<String>,
|
||||
/// Keycloak `preferred_username` claim.
|
||||
preferred_username: Option<String>,
|
||||
/// Full name from the Keycloak profile.
|
||||
name: Option<String>,
|
||||
/// Keycloak may include a picture/avatar URL via protocol mappers.
|
||||
picture: Option<String>,
|
||||
}
|
||||
@@ -180,11 +234,10 @@ struct UserinfoResponse {
|
||||
#[axum::debug_handler]
|
||||
pub async fn auth_callback(
|
||||
session: Session,
|
||||
Extension(state): Extension<ServerState>,
|
||||
Extension(pending): Extension<PendingOAuthStore>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
let kc = state.keycloak;
|
||||
let config = OAuthConfig::from_env()?;
|
||||
|
||||
// --- CSRF validation via the in-memory pending store ---
|
||||
let returned_state = params
|
||||
@@ -202,11 +255,11 @@ pub async fn auth_callback(
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let token_resp = client
|
||||
.post(kc.token_endpoint())
|
||||
.post(config.token_endpoint())
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("client_id", kc.client_id.as_str()),
|
||||
("redirect_uri", kc.redirect_uri.as_str()),
|
||||
("client_id", &config.client_id),
|
||||
("redirect_uri", &config.redirect_uri),
|
||||
("code", code),
|
||||
("code_verifier", &entry.code_verifier),
|
||||
])
|
||||
@@ -226,7 +279,7 @@ pub async fn auth_callback(
|
||||
|
||||
// --- Fetch userinfo ---
|
||||
let userinfo: UserinfoResponse = client
|
||||
.get(kc.userinfo_endpoint())
|
||||
.get(config.userinfo_endpoint())
|
||||
.bearer_auth(&tokens.access_token)
|
||||
.send()
|
||||
.await
|
||||
@@ -235,12 +288,6 @@ pub async fn auth_callback(
|
||||
.await
|
||||
.map_err(|e| Error::StateError(format!("userinfo parse failed: {e}")))?;
|
||||
|
||||
// Prefer `name`, fall back to `preferred_username`, then empty.
|
||||
let display_name = userinfo
|
||||
.name
|
||||
.or(userinfo.preferred_username)
|
||||
.unwrap_or_default();
|
||||
|
||||
// --- Build user state and persist in session ---
|
||||
let user_state = UserStateInner {
|
||||
sub: userinfo.sub,
|
||||
@@ -248,7 +295,6 @@ pub async fn auth_callback(
|
||||
refresh_token: tokens.refresh_token.unwrap_or_default(),
|
||||
user: User {
|
||||
email: userinfo.email.unwrap_or_default(),
|
||||
name: display_name,
|
||||
avatar_url: userinfo.picture.unwrap_or_default(),
|
||||
},
|
||||
};
|
||||
@@ -270,13 +316,10 @@ pub async fn auth_callback(
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error` if the session cannot be flushed or the URL is malformed.
|
||||
/// Returns `Error` if env vars are missing or the session cannot be flushed.
|
||||
#[axum::debug_handler]
|
||||
pub async fn logout(
|
||||
session: Session,
|
||||
Extension(state): Extension<ServerState>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
let kc = state.keycloak;
|
||||
pub async fn logout(session: Session) -> Result<impl IntoResponse, Error> {
|
||||
let config = OAuthConfig::from_env()?;
|
||||
|
||||
// Flush all session data.
|
||||
session
|
||||
@@ -284,12 +327,12 @@ pub async fn logout(
|
||||
.await
|
||||
.map_err(|e| Error::StateError(format!("session flush failed: {e}")))?;
|
||||
|
||||
let mut url = Url::parse(&kc.logout_endpoint())
|
||||
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", &kc.client_id)
|
||||
.append_pair("post_logout_redirect_uri", &kc.app_url);
|
||||
.append_pair("client_id", &config.client_id)
|
||||
.append_pair("post_logout_redirect_uri", &config.app_url);
|
||||
|
||||
Ok(Redirect::temporary(url.as_str()))
|
||||
}
|
||||
@@ -305,117 +348,3 @@ pub async fn set_login_session(session: Session, data: UserStateInner) -> Result
|
||||
.await
|
||||
.map_err(|e| Error::StateError(format!("session insert failed: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// generate_state()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn generate_state_length_is_64() {
|
||||
let state = generate_state();
|
||||
assert_eq!(state.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_state_chars_are_hex() {
|
||||
let state = generate_state();
|
||||
assert!(state.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_state_two_calls_differ() {
|
||||
let a = generate_state();
|
||||
let b = generate_state();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// generate_code_verifier()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn code_verifier_length_is_43() {
|
||||
let verifier = generate_code_verifier();
|
||||
assert_eq!(verifier.len(), 43);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_verifier_chars_are_url_safe_base64() {
|
||||
let verifier = generate_code_verifier();
|
||||
// URL-safe base64 without padding uses [A-Za-z0-9_-]
|
||||
assert!(verifier
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// derive_code_challenge()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn code_challenge_deterministic() {
|
||||
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||
let a = derive_code_challenge(verifier);
|
||||
let b = derive_code_challenge(verifier);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_challenge_rfc7636_test_vector() {
|
||||
// RFC 7636 Appendix B test vector:
|
||||
// verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
// expected challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||
let challenge = derive_code_challenge(verifier);
|
||||
assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PendingOAuthStore
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pending_store_insert_and_take() {
|
||||
let store = PendingOAuthStore::default();
|
||||
store.insert(
|
||||
"state-1".into(),
|
||||
PendingOAuthEntry {
|
||||
redirect_url: Some("/dashboard".into()),
|
||||
code_verifier: "verifier-1".into(),
|
||||
},
|
||||
);
|
||||
let entry = store.take("state-1");
|
||||
assert!(entry.is_some());
|
||||
let entry = entry.unwrap();
|
||||
assert_eq!(entry.redirect_url, Some("/dashboard".into()));
|
||||
assert_eq!(entry.code_verifier, "verifier-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_store_take_removes_entry() {
|
||||
let store = PendingOAuthStore::default();
|
||||
store.insert(
|
||||
"state-2".into(),
|
||||
PendingOAuthEntry {
|
||||
redirect_url: None,
|
||||
code_verifier: "v2".into(),
|
||||
},
|
||||
);
|
||||
let _ = store.take("state-2");
|
||||
// Second take should return None since the entry was removed.
|
||||
assert!(store.take("state-2").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_store_take_unknown_returns_none() {
|
||||
let store = PendingOAuthStore::default();
|
||||
assert!(store.take("nonexistent").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
use crate::models::AuthInfo;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Check the current user's authentication state.
|
||||
///
|
||||
/// Reads the tower-sessions session on the server and returns an
|
||||
/// [`AuthInfo`] describing the logged-in user. When no valid session
|
||||
/// exists, `authenticated` is `false` and all other fields are empty.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the session store cannot be read.
|
||||
#[server(endpoint = "check-auth")]
|
||||
pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
|
||||
use crate::infrastructure::auth::LOGGED_IN_USER_SESS_KEY;
|
||||
use crate::infrastructure::state::UserStateInner;
|
||||
use dioxus_fullstack::FullstackContext;
|
||||
|
||||
let session: tower_sessions::Session = FullstackContext::extract().await?;
|
||||
|
||||
let user_state: Option<UserStateInner> = session
|
||||
.get(LOGGED_IN_USER_SESS_KEY)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
|
||||
|
||||
match user_state {
|
||||
Some(u) => {
|
||||
let librechat_url =
|
||||
std::env::var("LIBRECHAT_URL").unwrap_or_else(|_| "http://localhost:3080".into());
|
||||
Ok(AuthInfo {
|
||||
authenticated: true,
|
||||
sub: u.sub,
|
||||
email: u.user.email,
|
||||
name: u.user.name,
|
||||
avatar_url: u.user.avatar_url,
|
||||
librechat_url,
|
||||
})
|
||||
}
|
||||
None => Ok(AuthInfo::default()),
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
use axum::{
|
||||
extract::Request,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::infrastructure::auth::LOGGED_IN_USER_SESS_KEY;
|
||||
use crate::infrastructure::state::UserStateInner;
|
||||
|
||||
/// Server function endpoints that are allowed without authentication.
|
||||
///
|
||||
/// `check-auth` must be public so the frontend can determine login state.
|
||||
const PUBLIC_API_ENDPOINTS: &[&str] = &["/api/check-auth"];
|
||||
|
||||
/// Axum middleware that enforces authentication on `/api/` server
|
||||
/// function endpoints.
|
||||
///
|
||||
/// Requests whose path starts with `/api/` (except those listed in
|
||||
/// [`PUBLIC_API_ENDPOINTS`]) are rejected with `401 Unauthorized` when
|
||||
/// no valid session exists. All other paths pass through untouched.
|
||||
pub async fn require_auth(session: Session, request: Request, next: Next) -> Response {
|
||||
let path = request.uri().path();
|
||||
|
||||
// Only gate /api/ server function routes.
|
||||
if path.starts_with("/api/") && !PUBLIC_API_ENDPOINTS.contains(&path) {
|
||||
let is_authed = session
|
||||
.get::<UserStateInner>(LOGGED_IN_USER_SESS_KEY)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
if !is_authed {
|
||||
return (StatusCode::UNAUTHORIZED, "Authentication required").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
@@ -1,741 +0,0 @@
|
||||
//! Chat CRUD server functions for session and message persistence.
|
||||
//!
|
||||
//! Each function extracts the user's `sub` from the tower-sessions session
|
||||
//! to scope all queries to the authenticated user. The `ServerState` provides
|
||||
//! access to the MongoDB [`Database`](super::database::Database).
|
||||
|
||||
use crate::models::{ChatMessage, ChatSession};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Convert a raw BSON document to a `ChatSession`, extracting `_id` as a hex string.
|
||||
#[cfg(feature = "server")]
|
||||
pub(crate) fn doc_to_chat_session(doc: &mongodb::bson::Document) -> ChatSession {
|
||||
use crate::models::ChatNamespace;
|
||||
|
||||
let id = doc
|
||||
.get_object_id("_id")
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_default();
|
||||
let namespace = match doc.get_str("namespace").unwrap_or("General") {
|
||||
"News" => ChatNamespace::News,
|
||||
_ => ChatNamespace::General,
|
||||
};
|
||||
let article_url = doc
|
||||
.get_str("article_url")
|
||||
.ok()
|
||||
.map(String::from)
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
ChatSession {
|
||||
id,
|
||||
user_sub: doc.get_str("user_sub").unwrap_or_default().to_string(),
|
||||
title: doc.get_str("title").unwrap_or_default().to_string(),
|
||||
namespace,
|
||||
provider: doc.get_str("provider").unwrap_or_default().to_string(),
|
||||
model: doc.get_str("model").unwrap_or_default().to_string(),
|
||||
created_at: doc.get_str("created_at").unwrap_or_default().to_string(),
|
||||
updated_at: doc.get_str("updated_at").unwrap_or_default().to_string(),
|
||||
article_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a raw BSON document to a `ChatMessage`, extracting `_id` as a hex string.
|
||||
#[cfg(feature = "server")]
|
||||
pub(crate) fn doc_to_chat_message(doc: &mongodb::bson::Document) -> ChatMessage {
|
||||
use crate::models::ChatRole;
|
||||
|
||||
let id = doc
|
||||
.get_object_id("_id")
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_default();
|
||||
let role = match doc.get_str("role").unwrap_or("User") {
|
||||
"Assistant" => ChatRole::Assistant,
|
||||
"System" => ChatRole::System,
|
||||
_ => ChatRole::User,
|
||||
};
|
||||
ChatMessage {
|
||||
id,
|
||||
session_id: doc.get_str("session_id").unwrap_or_default().to_string(),
|
||||
role,
|
||||
content: doc.get_str("content").unwrap_or_default().to_string(),
|
||||
attachments: Vec::new(),
|
||||
timestamp: doc.get_str("timestamp").unwrap_or_default().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: extract the authenticated user's `sub` from the session.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the session is missing or unreadable.
|
||||
#[cfg(feature = "server")]
|
||||
async fn require_user_sub() -> Result<String, ServerFnError> {
|
||||
use crate::infrastructure::auth::LOGGED_IN_USER_SESS_KEY;
|
||||
use crate::infrastructure::state::UserStateInner;
|
||||
use dioxus_fullstack::FullstackContext;
|
||||
|
||||
let session: tower_sessions::Session = FullstackContext::extract().await?;
|
||||
let user: UserStateInner = session
|
||||
.get(LOGGED_IN_USER_SESS_KEY)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?
|
||||
.ok_or_else(|| ServerFnError::new("not authenticated"))?;
|
||||
Ok(user.sub)
|
||||
}
|
||||
|
||||
/// Helper: extract the [`ServerState`] from the request context.
|
||||
#[cfg(feature = "server")]
|
||||
async fn require_state() -> Result<crate::infrastructure::ServerState, ServerFnError> {
|
||||
dioxus_fullstack::FullstackContext::extract().await
|
||||
}
|
||||
|
||||
/// List all chat sessions for the authenticated user, ordered by
|
||||
/// `updated_at` descending (most recent first).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if authentication or the database query fails.
|
||||
#[server(endpoint = "list-chat-sessions")]
|
||||
pub async fn list_chat_sessions() -> Result<Vec<ChatSession>, ServerFnError> {
|
||||
use mongodb::bson::doc;
|
||||
use mongodb::options::FindOptions;
|
||||
|
||||
let user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
let opts = FindOptions::builder()
|
||||
.sort(doc! { "updated_at": -1 })
|
||||
.build();
|
||||
|
||||
let mut cursor = state
|
||||
.db
|
||||
.raw_collection("chat_sessions")
|
||||
.find(doc! { "user_sub": &user_sub })
|
||||
.with_options(opts)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?;
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
use futures::TryStreamExt;
|
||||
while let Some(raw_doc) = cursor
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("cursor error: {e}")))?
|
||||
{
|
||||
sessions.push(doc_to_chat_session(&raw_doc));
|
||||
}
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// Create a new chat session and return it with the MongoDB-generated ID.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `title` - Display title for the session
|
||||
/// * `namespace` - Namespace string: `"General"` or `"News"`
|
||||
/// * `provider` - LLM provider name (e.g. "ollama")
|
||||
/// * `model` - Model ID (e.g. "llama3.1:8b")
|
||||
/// * `article_url` - Source article URL (only for `News` namespace, empty if none)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if authentication or the insert fails.
|
||||
#[server(endpoint = "create-chat-session")]
|
||||
pub async fn create_chat_session(
|
||||
title: String,
|
||||
namespace: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
article_url: String,
|
||||
) -> Result<ChatSession, ServerFnError> {
|
||||
use crate::models::ChatNamespace;
|
||||
|
||||
let user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
let ns = if namespace == "News" {
|
||||
ChatNamespace::News
|
||||
} else {
|
||||
ChatNamespace::General
|
||||
};
|
||||
|
||||
let url = if article_url.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(article_url)
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let session = ChatSession {
|
||||
id: String::new(), // MongoDB will generate _id
|
||||
user_sub,
|
||||
title,
|
||||
namespace: ns,
|
||||
provider,
|
||||
model,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
article_url: url,
|
||||
};
|
||||
|
||||
let result = state
|
||||
.db
|
||||
.chat_sessions()
|
||||
.insert_one(&session)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("insert failed: {e}")))?;
|
||||
|
||||
// Return the session with the generated ID
|
||||
let id = result
|
||||
.inserted_id
|
||||
.as_object_id()
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(ChatSession { id, ..session })
|
||||
}
|
||||
|
||||
/// Rename a chat session.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The MongoDB document ID of the session
|
||||
/// * `new_title` - The new title to set
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if authentication, the session is not found,
|
||||
/// or the update fails.
|
||||
#[server(endpoint = "rename-chat-session")]
|
||||
pub async fn rename_chat_session(
|
||||
session_id: String,
|
||||
new_title: String,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use mongodb::bson::{doc, oid::ObjectId};
|
||||
|
||||
let user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
let oid = ObjectId::parse_str(&session_id)
|
||||
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
|
||||
|
||||
let result = state
|
||||
.db
|
||||
.chat_sessions()
|
||||
.update_one(
|
||||
doc! { "_id": oid, "user_sub": &user_sub },
|
||||
doc! { "$set": { "title": &new_title, "updated_at": chrono::Utc::now().to_rfc3339() } },
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("update failed: {e}")))?;
|
||||
|
||||
if result.matched_count == 0 {
|
||||
return Err(ServerFnError::new("session not found or not owned by user"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a chat session and all its messages.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The MongoDB document ID of the session
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if authentication or the delete fails.
|
||||
#[server(endpoint = "delete-chat-session")]
|
||||
pub async fn delete_chat_session(session_id: String) -> Result<(), ServerFnError> {
|
||||
use mongodb::bson::{doc, oid::ObjectId};
|
||||
|
||||
let user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
let oid = ObjectId::parse_str(&session_id)
|
||||
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
|
||||
|
||||
// Delete the session (scoped to user)
|
||||
state
|
||||
.db
|
||||
.chat_sessions()
|
||||
.delete_one(doc! { "_id": oid, "user_sub": &user_sub })
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("delete session failed: {e}")))?;
|
||||
|
||||
// Delete all messages belonging to this session
|
||||
state
|
||||
.db
|
||||
.chat_messages()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("delete messages failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load all messages for a chat session, ordered by timestamp ascending.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The MongoDB document ID of the session
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if authentication or the query fails.
|
||||
#[server(endpoint = "list-chat-messages")]
|
||||
pub async fn list_chat_messages(session_id: String) -> Result<Vec<ChatMessage>, ServerFnError> {
|
||||
use mongodb::bson::doc;
|
||||
use mongodb::options::FindOptions;
|
||||
|
||||
// Verify the user owns this session
|
||||
let user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
// Verify the user owns this session using ObjectId for _id matching
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
let session_oid = ObjectId::parse_str(&session_id)
|
||||
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
|
||||
|
||||
let session_exists = state
|
||||
.db
|
||||
.raw_collection("chat_sessions")
|
||||
.count_documents(doc! { "_id": session_oid, "user_sub": &user_sub })
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?;
|
||||
|
||||
if session_exists == 0 {
|
||||
return Err(ServerFnError::new("session not found or not owned by user"));
|
||||
}
|
||||
|
||||
let opts = FindOptions::builder().sort(doc! { "timestamp": 1 }).build();
|
||||
|
||||
let mut cursor = state
|
||||
.db
|
||||
.raw_collection("chat_messages")
|
||||
.find(doc! { "session_id": &session_id })
|
||||
.with_options(opts)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
use futures::TryStreamExt;
|
||||
while let Some(raw_doc) = cursor
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("cursor error: {e}")))?
|
||||
{
|
||||
messages.push(doc_to_chat_message(&raw_doc));
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Persist a single chat message and return it with the MongoDB-generated ID.
|
||||
///
|
||||
/// Also updates the parent session's `updated_at` timestamp.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The session this message belongs to
|
||||
/// * `role` - Message role string: `"user"`, `"assistant"`, or `"system"`
|
||||
/// * `content` - Message text content
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if authentication or the insert fails.
|
||||
#[server(endpoint = "save-chat-message")]
|
||||
pub async fn save_chat_message(
|
||||
session_id: String,
|
||||
role: String,
|
||||
content: String,
|
||||
) -> Result<ChatMessage, ServerFnError> {
|
||||
use crate::models::ChatRole;
|
||||
use mongodb::bson::{doc, oid::ObjectId};
|
||||
|
||||
let _user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
let chat_role = match role.as_str() {
|
||||
"assistant" => ChatRole::Assistant,
|
||||
"system" => ChatRole::System,
|
||||
_ => ChatRole::User,
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let message = ChatMessage {
|
||||
id: String::new(),
|
||||
session_id: session_id.clone(),
|
||||
role: chat_role,
|
||||
content,
|
||||
attachments: Vec::new(),
|
||||
timestamp: now.clone(),
|
||||
};
|
||||
|
||||
let result = state
|
||||
.db
|
||||
.chat_messages()
|
||||
.insert_one(&message)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("insert failed: {e}")))?;
|
||||
|
||||
let id = result
|
||||
.inserted_id
|
||||
.as_object_id()
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Update session's updated_at timestamp
|
||||
if let Ok(session_oid) = ObjectId::parse_str(&session_id) {
|
||||
let _ = state
|
||||
.db
|
||||
.chat_sessions()
|
||||
.update_one(
|
||||
doc! { "_id": session_oid },
|
||||
doc! { "$set": { "updated_at": &now } },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(ChatMessage { id, ..message })
|
||||
}
|
||||
|
||||
/// Non-streaming chat completion (fallback for article panel).
|
||||
///
|
||||
/// Sends the full conversation history to the configured LLM provider
|
||||
/// and returns the complete response. Used where SSE streaming is not
|
||||
/// needed (e.g. dashboard article follow-up panel).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The chat session ID (loads provider/model config)
|
||||
/// * `messages_json` - Conversation history as JSON string:
|
||||
/// `[{"role":"user","content":"..."},...]`
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the LLM request fails.
|
||||
#[server(endpoint = "chat-complete")]
|
||||
pub async fn chat_complete(
|
||||
session_id: String,
|
||||
messages_json: String,
|
||||
) -> Result<String, ServerFnError> {
|
||||
use mongodb::bson::{doc, oid::ObjectId};
|
||||
|
||||
let _user_sub = require_user_sub().await?;
|
||||
let state = require_state().await?;
|
||||
|
||||
// Load the session to get provider/model
|
||||
let session_oid = ObjectId::parse_str(&session_id)
|
||||
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
|
||||
|
||||
let session_doc = state
|
||||
.db
|
||||
.raw_collection("chat_sessions")
|
||||
.find_one(doc! { "_id": session_oid })
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?
|
||||
.ok_or_else(|| ServerFnError::new("session not found"))?;
|
||||
let session = doc_to_chat_session(&session_doc);
|
||||
|
||||
// Resolve provider URL and model
|
||||
let (base_url, model) = resolve_provider_url(
|
||||
&state.services.ollama_url,
|
||||
&state.services.ollama_model,
|
||||
&session.provider,
|
||||
&session.model,
|
||||
);
|
||||
|
||||
// Parse messages from JSON
|
||||
let chat_msgs: Vec<serde_json::Value> = serde_json::from_str(&messages_json)
|
||||
.map_err(|e| ServerFnError::new(format!("invalid messages JSON: {e}")))?;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": chat_msgs,
|
||||
"stream": false,
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("LLM request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!("LLM returned {status}: {text}")));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("parse error: {e}")))?;
|
||||
|
||||
json["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
.ok_or_else(|| ServerFnError::new("empty LLM response"))
|
||||
}
|
||||
|
||||
/// Resolve the base URL for a provider, falling back to Ollama defaults.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `ollama_url` - Default Ollama base URL from config
|
||||
/// * `ollama_model` - Default Ollama model from config
|
||||
/// * `provider` - Provider name (e.g. "openai", "anthropic", "huggingface")
|
||||
/// * `model` - Model ID (may be empty for Ollama default)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `(base_url, model)` tuple resolved for the given provider.
|
||||
#[cfg(feature = "server")]
|
||||
pub(crate) fn resolve_provider_url(
|
||||
ollama_url: &str,
|
||||
ollama_model: &str,
|
||||
provider: &str,
|
||||
model: &str,
|
||||
) -> (String, String) {
|
||||
match provider {
|
||||
"openai" => ("https://api.openai.com".to_string(), model.to_string()),
|
||||
"anthropic" => ("https://api.anthropic.com".to_string(), model.to_string()),
|
||||
"huggingface" => (
|
||||
format!("https://api-inference.huggingface.co/models/{}", model),
|
||||
model.to_string(),
|
||||
),
|
||||
// Default to Ollama
|
||||
_ => (
|
||||
ollama_url.to_string(),
|
||||
if model.is_empty() {
|
||||
ollama_model.to_string()
|
||||
} else {
|
||||
model.to_string()
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// -----------------------------------------------------------------------
|
||||
// BSON document conversion tests (server feature required)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
mod server_tests {
|
||||
use super::super::{doc_to_chat_message, doc_to_chat_session, resolve_provider_url};
|
||||
use crate::models::{ChatNamespace, ChatRole};
|
||||
use mongodb::bson::{doc, oid::ObjectId, Document};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
// -- doc_to_chat_session --
|
||||
|
||||
fn sample_session_doc() -> (ObjectId, Document) {
|
||||
let oid = ObjectId::new();
|
||||
let doc = doc! {
|
||||
"_id": oid,
|
||||
"user_sub": "user-42",
|
||||
"title": "Test Session",
|
||||
"namespace": "News",
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-02T00:00:00Z",
|
||||
"article_url": "https://example.com/article",
|
||||
};
|
||||
(oid, doc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_to_chat_session_extracts_id_as_hex() {
|
||||
let (oid, doc) = sample_session_doc();
|
||||
let session = doc_to_chat_session(&doc);
|
||||
assert_eq!(session.id, oid.to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_to_chat_session_maps_news_namespace() {
|
||||
let (_, doc) = sample_session_doc();
|
||||
let session = doc_to_chat_session(&doc);
|
||||
assert_eq!(session.namespace, ChatNamespace::News);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_to_chat_session_defaults_to_general_for_unknown() {
|
||||
let mut doc = sample_session_doc().1;
|
||||
doc.insert("namespace", "SomethingElse");
|
||||
let session = doc_to_chat_session(&doc);
|
||||
assert_eq!(session.namespace, ChatNamespace::General);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_to_chat_session_extracts_all_string_fields() {
|
||||
let (_, doc) = sample_session_doc();
|
||||
let session = doc_to_chat_session(&doc);
|
||||
assert_eq!(session.user_sub, "user-42");
|
||||
assert_eq!(session.title, "Test Session");
|
||||
assert_eq!(session.provider, "openai");
|
||||
assert_eq!(session.model, "gpt-4");
|
||||
assert_eq!(session.created_at, "2025-01-01T00:00:00Z");
|
||||
assert_eq!(session.updated_at, "2025-01-02T00:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_to_chat_session_handles_missing_article_url() {
|
||||
let oid = ObjectId::new();
|
||||
let doc = doc! {
|
||||
"_id": oid,
|
||||
"user_sub": "u",
|
||||
"title": "t",
|
||||
"provider": "ollama",
|
||||
"model": "m",
|
||||
"created_at": "c",
|
||||
"updated_at": "u",
|
||||
};
|
||||
let session = doc_to_chat_session(&doc);
|
||||
assert_eq!(session.article_url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_to_chat_session_filters_empty_article_url() {
|
||||
let oid = ObjectId::new();
|
||||
let doc = doc! {
|
||||
"_id": oid,
|
||||
"user_sub": "u",
|
||||
"title": "t",
|
||||
"namespace": "News",
|
||||
"provider": "ollama",
|
||||
"model": "m",
|
||||
"created_at": "c",
|
||||
"updated_at": "u",
|
||||
"article_url": "",
|
||||
};
|
||||
let session = doc_to_chat_session(&doc);
|
||||
assert_eq!(session.article_url, None);
|
||||
}
|
||||
|
||||
// -- doc_to_chat_message --
|
||||
|
||||
fn sample_message_doc() -> (ObjectId, Document) {
|
||||
let oid = ObjectId::new();
|
||||
let doc = doc! {
|
||||
"_id": oid,
|
||||
"session_id": "sess-1",
|
||||
"role": "Assistant",
|
||||
"content": "Hello there!",
|
||||
"timestamp": "2025-01-01T12:00:00Z",
|
||||
};
|
||||
(oid, doc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_to_chat_message_extracts_id_as_hex() {
|
||||
let (oid, doc) = sample_message_doc();
|
||||
let msg = doc_to_chat_message(&doc);
|
||||
assert_eq!(msg.id, oid.to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_to_chat_message_maps_assistant_role() {
|
||||
let (_, doc) = sample_message_doc();
|
||||
let msg = doc_to_chat_message(&doc);
|
||||
assert_eq!(msg.role, ChatRole::Assistant);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_to_chat_message_maps_system_role() {
|
||||
let mut doc = sample_message_doc().1;
|
||||
doc.insert("role", "System");
|
||||
let msg = doc_to_chat_message(&doc);
|
||||
assert_eq!(msg.role, ChatRole::System);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_to_chat_message_defaults_to_user_for_unknown() {
|
||||
let mut doc = sample_message_doc().1;
|
||||
doc.insert("role", "SomethingElse");
|
||||
let msg = doc_to_chat_message(&doc);
|
||||
assert_eq!(msg.role, ChatRole::User);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_to_chat_message_extracts_content_and_timestamp() {
|
||||
let (_, doc) = sample_message_doc();
|
||||
let msg = doc_to_chat_message(&doc);
|
||||
assert_eq!(msg.content, "Hello there!");
|
||||
assert_eq!(msg.timestamp, "2025-01-01T12:00:00Z");
|
||||
assert_eq!(msg.session_id, "sess-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_to_chat_message_attachments_always_empty() {
|
||||
let (_, doc) = sample_message_doc();
|
||||
let msg = doc_to_chat_message(&doc);
|
||||
assert!(msg.attachments.is_empty());
|
||||
}
|
||||
|
||||
// -- resolve_provider_url --
|
||||
|
||||
const TEST_OLLAMA_URL: &str = "http://localhost:11434";
|
||||
const TEST_OLLAMA_MODEL: &str = "llama3.1:8b";
|
||||
|
||||
#[test]
|
||||
fn resolve_openai_returns_api_openai() {
|
||||
let (url, model) =
|
||||
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "openai", "gpt-4o");
|
||||
assert_eq!(url, "https://api.openai.com");
|
||||
assert_eq!(model, "gpt-4o");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_anthropic_returns_api_anthropic() {
|
||||
let (url, model) = resolve_provider_url(
|
||||
TEST_OLLAMA_URL,
|
||||
TEST_OLLAMA_MODEL,
|
||||
"anthropic",
|
||||
"claude-3-opus",
|
||||
);
|
||||
assert_eq!(url, "https://api.anthropic.com");
|
||||
assert_eq!(model, "claude-3-opus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_huggingface_returns_model_url() {
|
||||
let (url, model) = resolve_provider_url(
|
||||
TEST_OLLAMA_URL,
|
||||
TEST_OLLAMA_MODEL,
|
||||
"huggingface",
|
||||
"meta-llama/Llama-2-7b",
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://api-inference.huggingface.co/models/meta-llama/Llama-2-7b"
|
||||
);
|
||||
assert_eq!(model, "meta-llama/Llama-2-7b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_unknown_defaults_to_ollama() {
|
||||
let (url, model) =
|
||||
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "mistral:7b");
|
||||
assert_eq!(url, TEST_OLLAMA_URL);
|
||||
assert_eq!(model, "mistral:7b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_empty_model_falls_back_to_server_default() {
|
||||
let (url, model) =
|
||||
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "");
|
||||
assert_eq!(url, TEST_OLLAMA_URL);
|
||||
assert_eq!(model, TEST_OLLAMA_MODEL);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
//! Configuration structs loaded once at startup from environment variables.
|
||||
//!
|
||||
//! Each struct provides a `from_env()` constructor that reads `std::env::var`
|
||||
//! values. Required variables cause an `Error::ConfigError` on failure;
|
||||
//! optional ones default to an empty string.
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use super::Error;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read a required environment variable or return `Error::ConfigError`.
|
||||
fn required_env(name: &str) -> Result<String, Error> {
|
||||
std::env::var(name).map_err(|_| Error::ConfigError(format!("{name} is required but not set")))
|
||||
}
|
||||
|
||||
/// Read an optional environment variable, defaulting to an empty string.
|
||||
fn optional_env(name: &str) -> String {
|
||||
std::env::var(name).unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KeycloakConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Keycloak OpenID Connect settings for the public (frontend) client.
|
||||
///
|
||||
/// Also carries the admin service-account credentials used for
|
||||
/// server-to-server calls (e.g. user management APIs).
|
||||
#[derive(Debug)]
|
||||
pub struct KeycloakConfig {
|
||||
/// Base URL of the Keycloak instance (e.g. `http://localhost:8080`).
|
||||
pub url: String,
|
||||
/// Keycloak realm name.
|
||||
pub realm: String,
|
||||
/// Public client ID used by the dashboard frontend.
|
||||
pub client_id: String,
|
||||
/// OAuth redirect URI registered in Keycloak.
|
||||
pub redirect_uri: String,
|
||||
/// Root URL of this application (used for post-logout redirect).
|
||||
pub app_url: String,
|
||||
/// Confidential client ID for admin/server-to-server calls.
|
||||
pub admin_client_id: String,
|
||||
/// Confidential client secret (wrapped for debug safety).
|
||||
pub admin_client_secret: SecretString,
|
||||
}
|
||||
|
||||
impl KeycloakConfig {
|
||||
/// Load Keycloak configuration from environment variables.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::ConfigError` if a required variable is missing.
|
||||
pub fn from_env() -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
url: required_env("KEYCLOAK_URL")?,
|
||||
realm: required_env("KEYCLOAK_REALM")?,
|
||||
client_id: required_env("KEYCLOAK_CLIENT_ID")?,
|
||||
redirect_uri: required_env("REDIRECT_URI")?,
|
||||
app_url: required_env("APP_URL")?,
|
||||
admin_client_id: optional_env("KEYCLOAK_ADMIN_CLIENT_ID"),
|
||||
admin_client_secret: SecretString::from(optional_env("KEYCLOAK_ADMIN_CLIENT_SECRET")),
|
||||
})
|
||||
}
|
||||
|
||||
/// OpenID Connect authorization endpoint URL.
|
||||
pub fn auth_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/auth",
|
||||
self.url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
/// OpenID Connect token endpoint URL.
|
||||
pub fn token_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/token",
|
||||
self.url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
/// OpenID Connect userinfo endpoint URL.
|
||||
pub fn userinfo_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/userinfo",
|
||||
self.url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
/// OpenID Connect end-session (logout) endpoint URL.
|
||||
pub fn logout_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/logout",
|
||||
self.url, self.realm
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SmtpConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// SMTP mail settings for transactional emails (invites, alerts, etc.).
|
||||
#[derive(Debug)]
|
||||
pub struct SmtpConfig {
|
||||
/// SMTP server hostname.
|
||||
pub host: String,
|
||||
/// SMTP server port (as string for flexibility, e.g. "587").
|
||||
pub port: String,
|
||||
/// SMTP username.
|
||||
pub username: String,
|
||||
/// SMTP password (wrapped for debug safety).
|
||||
pub password: SecretString,
|
||||
/// Sender address shown in the `From:` header.
|
||||
pub from_address: String,
|
||||
}
|
||||
|
||||
impl SmtpConfig {
|
||||
/// Load SMTP configuration from environment variables.
|
||||
///
|
||||
/// All fields are optional; defaults to empty strings when absent.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Currently infallible but returns `Result` for consistency.
|
||||
pub fn from_env() -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
host: optional_env("SMTP_HOST"),
|
||||
port: optional_env("SMTP_PORT"),
|
||||
username: optional_env("SMTP_USERNAME"),
|
||||
password: SecretString::from(optional_env("SMTP_PASSWORD")),
|
||||
from_address: optional_env("SMTP_FROM_ADDRESS"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ServiceUrls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// URLs and credentials for external services (Ollama, SearXNG, S3, etc.).
|
||||
#[derive(Debug)]
|
||||
pub struct ServiceUrls {
|
||||
/// Ollama LLM instance base URL.
|
||||
pub ollama_url: String,
|
||||
/// Default Ollama model to use.
|
||||
pub ollama_model: String,
|
||||
/// SearXNG meta-search engine base URL.
|
||||
pub searxng_url: String,
|
||||
/// LangChain service URL.
|
||||
pub langchain_url: String,
|
||||
/// LangGraph service URL.
|
||||
pub langgraph_url: String,
|
||||
/// Langfuse observability URL.
|
||||
pub langfuse_url: String,
|
||||
/// Vector database URL.
|
||||
pub vectordb_url: String,
|
||||
/// S3-compatible object storage URL.
|
||||
pub s3_url: String,
|
||||
/// S3 access key.
|
||||
pub s3_access_key: String,
|
||||
/// S3 secret key (wrapped for debug safety).
|
||||
pub s3_secret_key: SecretString,
|
||||
}
|
||||
|
||||
impl ServiceUrls {
|
||||
/// Load service URLs from environment variables.
|
||||
///
|
||||
/// All fields are optional with sensible defaults where applicable.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Currently infallible but returns `Result` for consistency.
|
||||
pub fn from_env() -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
ollama_url: std::env::var("OLLAMA_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:11434".into()),
|
||||
ollama_model: std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into()),
|
||||
searxng_url: std::env::var("SEARXNG_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8888".into()),
|
||||
langchain_url: optional_env("LANGCHAIN_URL"),
|
||||
langgraph_url: optional_env("LANGGRAPH_URL"),
|
||||
langfuse_url: optional_env("LANGFUSE_URL"),
|
||||
vectordb_url: optional_env("VECTORDB_URL"),
|
||||
s3_url: optional_env("S3_URL"),
|
||||
s3_access_key: optional_env("S3_ACCESS_KEY"),
|
||||
s3_secret_key: SecretString::from(optional_env("S3_SECRET_KEY")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StripeConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Stripe billing configuration.
|
||||
#[derive(Debug)]
|
||||
pub struct StripeConfig {
|
||||
/// Stripe secret API key (wrapped for debug safety).
|
||||
pub secret_key: SecretString,
|
||||
/// Stripe webhook signing secret (wrapped for debug safety).
|
||||
pub webhook_secret: SecretString,
|
||||
/// Stripe publishable key (safe to expose to the frontend).
|
||||
pub publishable_key: String,
|
||||
}
|
||||
|
||||
impl StripeConfig {
|
||||
/// Load Stripe configuration from environment variables.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Currently infallible but returns `Result` for consistency.
|
||||
pub fn from_env() -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
secret_key: SecretString::from(optional_env("STRIPE_SECRET_KEY")),
|
||||
webhook_secret: SecretString::from(optional_env("STRIPE_WEBHOOK_SECRET")),
|
||||
publishable_key: optional_env("STRIPE_PUBLISHABLE_KEY"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LlmProvidersConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Comma-separated list of enabled LLM provider identifiers.
|
||||
///
|
||||
/// For example: `LLM_PROVIDERS=ollama,openai,anthropic`
|
||||
#[derive(Debug)]
|
||||
pub struct LlmProvidersConfig {
|
||||
/// Parsed provider names.
|
||||
pub providers: Vec<String>,
|
||||
}
|
||||
|
||||
impl LlmProvidersConfig {
|
||||
/// Load the provider list from `LLM_PROVIDERS`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Currently infallible but returns `Result` for consistency.
|
||||
pub fn from_env() -> Result<Self, Error> {
|
||||
let raw = optional_env("LLM_PROVIDERS");
|
||||
let providers: Vec<String> = raw
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
Ok(Self { providers })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serial_test::serial;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// KeycloakConfig endpoint methods (no env vars needed)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn sample_keycloak() -> KeycloakConfig {
|
||||
KeycloakConfig {
|
||||
url: "https://auth.example.com".into(),
|
||||
realm: "myrealm".into(),
|
||||
client_id: "dashboard".into(),
|
||||
redirect_uri: "https://app.example.com/callback".into(),
|
||||
app_url: "https://app.example.com".into(),
|
||||
admin_client_id: String::new(),
|
||||
admin_client_secret: SecretString::from(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keycloak_auth_endpoint() {
|
||||
let kc = sample_keycloak();
|
||||
assert_eq!(
|
||||
kc.auth_endpoint(),
|
||||
"https://auth.example.com/realms/myrealm/protocol/openid-connect/auth"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keycloak_token_endpoint() {
|
||||
let kc = sample_keycloak();
|
||||
assert_eq!(
|
||||
kc.token_endpoint(),
|
||||
"https://auth.example.com/realms/myrealm/protocol/openid-connect/token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keycloak_userinfo_endpoint() {
|
||||
let kc = sample_keycloak();
|
||||
assert_eq!(
|
||||
kc.userinfo_endpoint(),
|
||||
"https://auth.example.com/realms/myrealm/protocol/openid-connect/userinfo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keycloak_logout_endpoint() {
|
||||
let kc = sample_keycloak();
|
||||
assert_eq!(
|
||||
kc.logout_endpoint(),
|
||||
"https://auth.example.com/realms/myrealm/protocol/openid-connect/logout"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// LlmProvidersConfig::from_env()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn llm_providers_empty_string() {
|
||||
std::env::set_var("LLM_PROVIDERS", "");
|
||||
let cfg = LlmProvidersConfig::from_env().unwrap();
|
||||
assert!(cfg.providers.is_empty());
|
||||
std::env::remove_var("LLM_PROVIDERS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn llm_providers_single() {
|
||||
std::env::set_var("LLM_PROVIDERS", "ollama");
|
||||
let cfg = LlmProvidersConfig::from_env().unwrap();
|
||||
assert_eq!(cfg.providers, vec!["ollama"]);
|
||||
std::env::remove_var("LLM_PROVIDERS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn llm_providers_multiple() {
|
||||
std::env::set_var("LLM_PROVIDERS", "ollama,openai,anthropic");
|
||||
let cfg = LlmProvidersConfig::from_env().unwrap();
|
||||
assert_eq!(cfg.providers, vec!["ollama", "openai", "anthropic"]);
|
||||
std::env::remove_var("LLM_PROVIDERS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn llm_providers_trims_whitespace() {
|
||||
std::env::set_var("LLM_PROVIDERS", " ollama , openai ");
|
||||
let cfg = LlmProvidersConfig::from_env().unwrap();
|
||||
assert_eq!(cfg.providers, vec!["ollama", "openai"]);
|
||||
std::env::remove_var("LLM_PROVIDERS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn llm_providers_filters_empty_entries() {
|
||||
std::env::set_var("LLM_PROVIDERS", "ollama,,openai,");
|
||||
let cfg = LlmProvidersConfig::from_env().unwrap();
|
||||
assert_eq!(cfg.providers, vec!["ollama", "openai"]);
|
||||
std::env::remove_var("LLM_PROVIDERS");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ServiceUrls::from_env() defaults
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn service_urls_default_ollama_url() {
|
||||
std::env::remove_var("OLLAMA_URL");
|
||||
let svc = ServiceUrls::from_env().unwrap();
|
||||
assert_eq!(svc.ollama_url, "http://localhost:11434");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn service_urls_default_ollama_model() {
|
||||
std::env::remove_var("OLLAMA_MODEL");
|
||||
let svc = ServiceUrls::from_env().unwrap();
|
||||
assert_eq!(svc.ollama_model, "llama3.1:8b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn service_urls_default_searxng_url() {
|
||||
std::env::remove_var("SEARXNG_URL");
|
||||
let svc = ServiceUrls::from_env().unwrap();
|
||||
assert_eq!(svc.searxng_url, "http://localhost:8888");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn service_urls_custom_ollama_url() {
|
||||
std::env::set_var("OLLAMA_URL", "http://gpu-host:11434");
|
||||
let svc = ServiceUrls::from_env().unwrap();
|
||||
assert_eq!(svc.ollama_url, "http://gpu-host:11434");
|
||||
std::env::remove_var("OLLAMA_URL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn required_env_missing_returns_config_error() {
|
||||
std::env::remove_var("__TEST_REQUIRED_MISSING__");
|
||||
let result = required_env("__TEST_REQUIRED_MISSING__");
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("__TEST_REQUIRED_MISSING__"));
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//! MongoDB connection wrapper with typed collection accessors.
|
||||
|
||||
use mongodb::{bson::doc, Client, Collection};
|
||||
|
||||
use super::Error;
|
||||
use crate::models::{ChatMessage, ChatSession, OrgBillingRecord, OrgSettings, UserPreferences};
|
||||
|
||||
/// Thin wrapper around [`mongodb::Database`] that provides typed
|
||||
/// collection accessors for the application's domain models.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Database {
|
||||
inner: mongodb::Database,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Connect to MongoDB, select the given database, and verify
|
||||
/// connectivity with a `ping` command.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `uri` - MongoDB connection string (e.g. `mongodb://localhost:27017`)
|
||||
/// * `db_name` - Database name to use
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::DatabaseError` if the client cannot be created
|
||||
/// or the ping fails.
|
||||
pub async fn connect(uri: &str, db_name: &str) -> Result<Self, Error> {
|
||||
let client = Client::with_uri_str(uri).await?;
|
||||
let db = client.database(db_name);
|
||||
|
||||
// Verify the connection is alive.
|
||||
db.run_command(doc! { "ping": 1 }).await?;
|
||||
|
||||
Ok(Self { inner: db })
|
||||
}
|
||||
|
||||
/// Collection for per-user preferences (theme, custom topics, etc.).
|
||||
pub fn user_preferences(&self) -> Collection<UserPreferences> {
|
||||
self.inner.collection("user_preferences")
|
||||
}
|
||||
|
||||
/// Collection for organisation-level settings.
|
||||
pub fn org_settings(&self) -> Collection<OrgSettings> {
|
||||
self.inner.collection("org_settings")
|
||||
}
|
||||
|
||||
/// Collection for per-cycle billing records.
|
||||
pub fn org_billing(&self) -> Collection<OrgBillingRecord> {
|
||||
self.inner.collection("org_billing")
|
||||
}
|
||||
|
||||
/// Collection for persisted chat sessions (sidebar listing).
|
||||
pub fn chat_sessions(&self) -> Collection<ChatSession> {
|
||||
self.inner.collection("chat_sessions")
|
||||
}
|
||||
|
||||
/// Collection for individual chat messages within sessions.
|
||||
pub fn chat_messages(&self) -> Collection<ChatMessage> {
|
||||
self.inner.collection("chat_messages")
|
||||
}
|
||||
|
||||
/// Raw BSON document collection for queries that need manual
|
||||
/// `_id` → `String` conversion (avoids `ObjectId` deserialization issues).
|
||||
pub fn raw_collection(&self, name: &str) -> Collection<mongodb::bson::Document> {
|
||||
self.inner.collection(name)
|
||||
}
|
||||
}
|
||||
@@ -1,93 +1,22 @@
|
||||
use axum::response::IntoResponse;
|
||||
use reqwest::StatusCode;
|
||||
|
||||
/// Central error type for infrastructure-layer failures.
|
||||
///
|
||||
/// Each variant maps to an appropriate HTTP status code when converted
|
||||
/// into an Axum response.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
StateError(String),
|
||||
|
||||
#[error("database error: {0}")]
|
||||
DatabaseError(String),
|
||||
|
||||
#[error("configuration error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
#[error("IoError: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl From<mongodb::error::Error> for Error {
|
||||
fn from(err: mongodb::error::Error) -> Self {
|
||||
Self::DatabaseError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let msg = self.to_string();
|
||||
tracing::error!("Converting Error to Response: {msg}");
|
||||
match self {
|
||||
Self::StateError(e) | Self::ConfigError(e) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e).into_response()
|
||||
}
|
||||
Self::DatabaseError(e) => (StatusCode::SERVICE_UNAVAILABLE, e).into_response(),
|
||||
Self::IoError(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown error").into_response()
|
||||
}
|
||||
Self::StateError(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Unknown error").into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::response::IntoResponse;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn state_error_display() {
|
||||
let err = Error::StateError("bad state".into());
|
||||
assert_eq!(err.to_string(), "bad state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_error_display() {
|
||||
let err = Error::DatabaseError("connection lost".into());
|
||||
assert_eq!(err.to_string(), "database error: connection lost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_error_display() {
|
||||
let err = Error::ConfigError("missing var".into());
|
||||
assert_eq!(err.to_string(), "configuration error: missing var");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_error_into_response_500() {
|
||||
let resp = Error::StateError("oops".into()).into_response();
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_error_into_response_503() {
|
||||
let resp = Error::DatabaseError("down".into()).into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_error_into_response_500() {
|
||||
let resp = Error::ConfigError("bad cfg".into()).into_response();
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn io_error_into_response_500() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
|
||||
let resp = Error::IoError(io_err).into_response();
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
mod inner {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single message in the OpenAI-compatible chat format used by Ollama.
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct ChatMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Request body for Ollama's OpenAI-compatible chat completions endpoint.
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct OllamaChatRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// Disable streaming so we get a single JSON response.
|
||||
pub stream: bool,
|
||||
}
|
||||
|
||||
/// A single choice in the Ollama chat completions response.
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ChatChoice {
|
||||
pub message: ChatResponseMessage,
|
||||
}
|
||||
|
||||
/// The assistant message returned inside a choice.
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ChatResponseMessage {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Top-level response from Ollama's `/v1/chat/completions` endpoint.
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct OllamaChatResponse {
|
||||
pub choices: Vec<ChatChoice>,
|
||||
}
|
||||
|
||||
/// Fetch the full text content of a webpage by downloading its HTML
|
||||
/// and extracting the main article body, skipping navigation, headers,
|
||||
/// footers, and sidebars.
|
||||
///
|
||||
/// Uses a tiered extraction strategy:
|
||||
/// 1. Try content within `<article>`, `<main>`, or `[role="main"]`
|
||||
/// 2. Fall back to all `<p>` tags outside excluded containers
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - The article URL to fetch
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The extracted text, or `None` if the fetch/parse fails.
|
||||
/// Text is capped at 8000 characters to stay within LLM context limits.
|
||||
pub(super) async fn fetch_article_text(url: &str) -> Option<String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("User-Agent", "CERTifAI/1.0 (Article Summarizer)")
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let html = resp.text().await.ok()?;
|
||||
parse_article_html(&html)
|
||||
}
|
||||
|
||||
/// Parse article text from raw HTML without any network I/O.
|
||||
///
|
||||
/// Uses a tiered extraction strategy:
|
||||
/// 1. Try content within `<article>`, `<main>`, or `[role="main"]`
|
||||
/// 2. Fall back to all `<p>` tags outside excluded containers
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `html` - Raw HTML string to parse
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The extracted text, or `None` if extraction yields < 100 chars.
|
||||
/// Output is capped at 8000 characters.
|
||||
pub(crate) fn parse_article_html(html: &str) -> Option<String> {
|
||||
let document = scraper::Html::parse_document(html);
|
||||
|
||||
// Strategy 1: Extract from semantic article containers.
|
||||
// Most news sites wrap the main content in <article>, <main>,
|
||||
// or an element with role="main".
|
||||
let article_selector = scraper::Selector::parse("article, main, [role='main']").ok()?;
|
||||
let paragraph_sel = scraper::Selector::parse("p, h1, h2, h3, li").ok()?;
|
||||
|
||||
let mut text_parts: Vec<String> = Vec::with_capacity(64);
|
||||
|
||||
for container in document.select(&article_selector) {
|
||||
for element in container.select(¶graph_sel) {
|
||||
collect_text_fragment(element, &mut text_parts);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: If article containers yielded little text, fall back
|
||||
// to all <p> tags that are NOT inside nav/header/footer/aside.
|
||||
if joined_len(&text_parts) < 200 {
|
||||
text_parts.clear();
|
||||
let all_p = scraper::Selector::parse("p").ok()?;
|
||||
|
||||
// Tags whose descendants should be excluded from extraction
|
||||
const EXCLUDED_TAGS: &[&str] = &["nav", "header", "footer", "aside", "script", "style"];
|
||||
|
||||
for element in document.select(&all_p) {
|
||||
// Walk ancestors and skip if inside an excluded container.
|
||||
// Checks tag names directly to avoid ego_tree version issues.
|
||||
let inside_excluded = element.ancestors().any(|ancestor| {
|
||||
ancestor
|
||||
.value()
|
||||
.as_element()
|
||||
.is_some_and(|el| EXCLUDED_TAGS.contains(&el.name.local.as_ref()))
|
||||
});
|
||||
if !inside_excluded {
|
||||
collect_text_fragment(element, &mut text_parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let full_text = text_parts.join("\n\n");
|
||||
if full_text.len() < 100 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Cap at 8000 chars to stay within reasonable LLM context
|
||||
let truncated: String = full_text.chars().take(8000).collect();
|
||||
Some(truncated)
|
||||
}
|
||||
|
||||
/// Extract text from an HTML element and append it to the parts list
|
||||
/// if it meets a minimum length threshold.
|
||||
fn collect_text_fragment(element: scraper::ElementRef<'_>, parts: &mut Vec<String>) {
|
||||
let text: String = element.text().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = text.trim().to_string();
|
||||
// Skip very short fragments (nav items, buttons, etc.)
|
||||
if trimmed.len() >= 30 {
|
||||
parts.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sum the total character length of all collected text parts.
|
||||
pub(crate) fn joined_len(parts: &[String]) -> usize {
|
||||
parts.iter().map(|s| s.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Summarize an article using a local Ollama instance.
|
||||
///
|
||||
/// First attempts to fetch the full article text from the provided URL.
|
||||
/// If that fails (paywall, timeout, etc.), falls back to the search snippet.
|
||||
/// This mirrors how Perplexity fetches and reads source pages before answering.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `snippet` - The search result snippet (fallback content)
|
||||
/// * `article_url` - The original article URL to fetch full text from
|
||||
/// * `ollama_url` - Base URL of the Ollama instance (e.g. "http://localhost:11434")
|
||||
/// * `model` - The Ollama model ID to use (e.g. "llama3.1:8b")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A summary string generated by the LLM, or a `ServerFnError` on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
|
||||
#[post("/api/summarize")]
|
||||
pub async fn summarize_article(
|
||||
snippet: String,
|
||||
article_url: String,
|
||||
ollama_url: String,
|
||||
model: String,
|
||||
) -> Result<String, ServerFnError> {
|
||||
use inner::{fetch_article_text, ChatMessage, OllamaChatRequest, OllamaChatResponse};
|
||||
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
// Use caller-provided values or fall back to ServerState config
|
||||
let base_url = if ollama_url.is_empty() {
|
||||
state.services.ollama_url.clone()
|
||||
} else {
|
||||
ollama_url
|
||||
};
|
||||
|
||||
let model = if model.is_empty() {
|
||||
state.services.ollama_model.clone()
|
||||
} else {
|
||||
model
|
||||
};
|
||||
|
||||
// Try to fetch the full article; fall back to the search snippet
|
||||
let article_text = fetch_article_text(&article_url).await.unwrap_or(snippet);
|
||||
|
||||
let request_body = OllamaChatRequest {
|
||||
model,
|
||||
stream: false,
|
||||
messages: vec![ChatMessage {
|
||||
role: "user".into(),
|
||||
content: format!(
|
||||
"You are a news summarizer. Summarize the following article text \
|
||||
in 2-3 concise paragraphs. Focus only on the key points and \
|
||||
implications. Do NOT comment on the source, the date, the URL, \
|
||||
the formatting, or whether the content seems complete or not. \
|
||||
Just summarize whatever content is provided.\n\n\
|
||||
{article_text}"
|
||||
),
|
||||
}],
|
||||
};
|
||||
|
||||
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!(
|
||||
"Ollama returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body: OllamaChatResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
|
||||
|
||||
body.choices
|
||||
.first()
|
||||
.map(|choice| choice.message.content.clone())
|
||||
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
|
||||
}
|
||||
|
||||
/// A lightweight chat message for the follow-up conversation.
|
||||
/// Uses simple String role ("system"/"user"/"assistant") for Ollama compatibility.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FollowUpMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Send a follow-up question about an article using a local Ollama instance.
|
||||
///
|
||||
/// Accepts the full conversation history (system context + prior turns) and
|
||||
/// returns the assistant's next response. The system message should contain
|
||||
/// the article text and summary so the LLM has full context.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `messages` - The conversation history including system context
|
||||
/// * `ollama_url` - Base URL of the Ollama instance
|
||||
/// * `model` - The Ollama model ID to use
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The assistant's response text, or a `ServerFnError` on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
|
||||
#[post("/api/chat")]
|
||||
pub async fn chat_followup(
|
||||
messages: Vec<FollowUpMessage>,
|
||||
ollama_url: String,
|
||||
model: String,
|
||||
) -> Result<String, ServerFnError> {
|
||||
use inner::{ChatMessage, OllamaChatRequest, OllamaChatResponse};
|
||||
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
let base_url = if ollama_url.is_empty() {
|
||||
state.services.ollama_url.clone()
|
||||
} else {
|
||||
ollama_url
|
||||
};
|
||||
|
||||
let model = if model.is_empty() {
|
||||
state.services.ollama_model.clone()
|
||||
} else {
|
||||
model
|
||||
};
|
||||
|
||||
// Convert FollowUpMessage to inner ChatMessage for the request
|
||||
let chat_messages: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.map(|m| ChatMessage {
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let request_body = OllamaChatRequest {
|
||||
model,
|
||||
stream: false,
|
||||
messages: chat_messages,
|
||||
};
|
||||
|
||||
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!(
|
||||
"Ollama returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body: OllamaChatResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
|
||||
|
||||
body.choices
|
||||
.first()
|
||||
.map(|choice| choice.message.content.clone())
|
||||
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// FollowUpMessage serde tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn followup_message_serde_round_trip() {
|
||||
let msg = FollowUpMessage {
|
||||
role: "assistant".into(),
|
||||
content: "Here is my answer.".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).expect("serialize FollowUpMessage");
|
||||
let back: FollowUpMessage =
|
||||
serde_json::from_str(&json).expect("deserialize FollowUpMessage");
|
||||
assert_eq!(msg, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn followup_message_deserialize_from_json_literal() {
|
||||
let json = r#"{"role":"system","content":"You are helpful."}"#;
|
||||
let msg: FollowUpMessage = serde_json::from_str(json).expect("deserialize literal");
|
||||
assert_eq!(msg.role, "system");
|
||||
assert_eq!(msg.content, "You are helpful.");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// joined_len and parse_article_html tests (server feature required)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
mod server_tests {
|
||||
use super::super::inner::{joined_len, parse_article_html};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn joined_len_empty_input() {
|
||||
assert_eq!(joined_len(&[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joined_len_sums_correctly() {
|
||||
let parts = vec!["abc".into(), "de".into(), "fghij".into()];
|
||||
assert_eq!(joined_len(&parts), 10);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// parse_article_html tests
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Helper: generate a string of given length from a repeated word.
|
||||
fn lorem(len: usize) -> String {
|
||||
"Lorem ipsum dolor sit amet consectetur adipiscing elit "
|
||||
.repeat((len / 55) + 1)
|
||||
.chars()
|
||||
.take(len)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn article_tag_extracts_text() {
|
||||
let body = lorem(250);
|
||||
let html = format!("<html><body><article><p>{body}</p></article></body></html>");
|
||||
let result = parse_article_html(&html);
|
||||
assert!(result.is_some(), "expected Some for article tag");
|
||||
assert!(result.unwrap().contains("Lorem"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_tag_extracts_text() {
|
||||
let body = lorem(250);
|
||||
let html = format!("<html><body><main><p>{body}</p></main></body></html>");
|
||||
let result = parse_article_html(&html);
|
||||
assert!(result.is_some(), "expected Some for main tag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_to_p_tags_when_article_main_yield_little() {
|
||||
// No <article>/<main>, so falls back to <p> tags
|
||||
let body = lorem(250);
|
||||
let html = format!("<html><body><div><p>{body}</p></div></body></html>");
|
||||
let result = parse_article_html(&html);
|
||||
assert!(result.is_some(), "expected fallback to <p> tags");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_nav_footer_aside_content() {
|
||||
// Content only inside excluded containers -- should be excluded
|
||||
let body = lorem(250);
|
||||
let html = format!(
|
||||
"<html><body>\
|
||||
<nav><p>{body}</p></nav>\
|
||||
<footer><p>{body}</p></footer>\
|
||||
<aside><p>{body}</p></aside>\
|
||||
</body></html>"
|
||||
);
|
||||
let result = parse_article_html(&html);
|
||||
assert!(result.is_none(), "expected None for excluded-only content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_text_too_short() {
|
||||
let html = "<html><body><p>Short.</p></body></html>";
|
||||
let result = parse_article_html(html);
|
||||
assert!(result.is_none(), "expected None for short text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_at_8000_chars() {
|
||||
let body = lorem(10000);
|
||||
let html = format!("<html><body><article><p>{body}</p></article></body></html>");
|
||||
let result = parse_article_html(&html).expect("expected Some");
|
||||
assert!(
|
||||
result.len() <= 8000,
|
||||
"expected <= 8000 chars, got {}",
|
||||
result.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_fragments_under_30_chars() {
|
||||
// Only fragments < 30 chars -- should yield None
|
||||
let html = "<html><body><article>\
|
||||
<p>Short frag one</p>\
|
||||
<p>Another tiny bit</p>\
|
||||
</article></body></html>";
|
||||
let result = parse_article_html(html);
|
||||
assert!(result.is_none(), "expected None for tiny fragments");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_from_role_main_attribute() {
|
||||
let body = lorem(250);
|
||||
let html = format!(
|
||||
"<html><body>\
|
||||
<div role=\"main\"><p>{body}</p></div>\
|
||||
</body></html>"
|
||||
);
|
||||
let result = parse_article_html(&html);
|
||||
assert!(result.is_some(), "expected Some for role=main");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,10 @@
|
||||
// Server function modules (compiled for both web and server features;
|
||||
// the #[server] macro generates client stubs for the web target)
|
||||
pub mod auth_check;
|
||||
pub mod chat;
|
||||
pub mod llm;
|
||||
pub mod ollama;
|
||||
pub mod searxng;
|
||||
|
||||
// Server-only modules (Axum handlers, state, configs, DB, etc.)
|
||||
#[cfg(feature = "server")]
|
||||
#![cfg(feature = "server")]
|
||||
mod auth;
|
||||
#[cfg(feature = "server")]
|
||||
mod auth_middleware;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod config;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod database;
|
||||
#[cfg(feature = "server")]
|
||||
mod error;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod provider_client;
|
||||
#[cfg(feature = "server")]
|
||||
mod server;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod server_state;
|
||||
#[cfg(feature = "server")]
|
||||
mod state;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub use auth::*;
|
||||
#[cfg(feature = "server")]
|
||||
pub use auth_middleware::*;
|
||||
#[cfg(feature = "server")]
|
||||
pub use error::*;
|
||||
#[cfg(feature = "server")]
|
||||
pub use server::*;
|
||||
#[cfg(feature = "server")]
|
||||
pub use server_state::*;
|
||||
#[cfg(feature = "server")]
|
||||
pub use state::*;
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Status of a local Ollama instance, including connectivity and loaded models.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `online` - Whether the Ollama API responded successfully
|
||||
/// * `models` - List of model names currently available on the instance
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OllamaStatus {
|
||||
pub online: bool,
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Response from Ollama's `GET /api/tags` endpoint.
|
||||
#[cfg(feature = "server")]
|
||||
#[derive(Deserialize)]
|
||||
struct OllamaTagsResponse {
|
||||
models: Vec<OllamaModel>,
|
||||
}
|
||||
|
||||
/// A single model entry from Ollama's tags API.
|
||||
#[cfg(feature = "server")]
|
||||
#[derive(Deserialize)]
|
||||
struct OllamaModel {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Check the status of a local Ollama instance by querying its tags endpoint.
|
||||
///
|
||||
/// Calls `GET <ollama_url>/api/tags` to list available models and determine
|
||||
/// whether the instance is reachable.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `ollama_url` - Base URL of the Ollama instance (e.g. "http://localhost:11434")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// An `OllamaStatus` with `online: true` and model names if reachable,
|
||||
/// or `online: false` with an empty model list on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` only on serialization issues; network failures
|
||||
/// are caught and returned as `online: false`
|
||||
#[post("/api/ollama-status")]
|
||||
pub async fn get_ollama_status(ollama_url: String) -> Result<OllamaStatus, ServerFnError> {
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
let base_url = if ollama_url.is_empty() {
|
||||
state.services.ollama_url.clone()
|
||||
} else {
|
||||
ollama_url
|
||||
};
|
||||
|
||||
let url = format!("{}/api/tags", base_url.trim_end_matches('/'));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
|
||||
|
||||
let resp = match client.get(&url).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => {
|
||||
return Ok(OllamaStatus {
|
||||
online: false,
|
||||
models: Vec::new(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let body: OllamaTagsResponse = match resp.json().await {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
return Ok(OllamaStatus {
|
||||
online: true,
|
||||
models: Vec::new(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let models = body.models.into_iter().map(|m| m.name).collect();
|
||||
|
||||
Ok(OllamaStatus {
|
||||
online: true,
|
||||
models,
|
||||
})
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
//! Unified LLM provider dispatch.
|
||||
//!
|
||||
//! Routes chat completion requests to Ollama, OpenAI, Anthropic, or
|
||||
//! HuggingFace based on the session's provider setting. All providers
|
||||
//! except Anthropic use the OpenAI-compatible chat completions format.
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::server_state::ServerState;
|
||||
|
||||
/// OpenAI-compatible chat message used for request bodies.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Send a chat completion request to the configured provider.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `state` - Server state (for default Ollama URL/model)
|
||||
/// * `provider` - Provider name (`"ollama"`, `"openai"`, `"anthropic"`, `"huggingface"`)
|
||||
/// * `model` - Model ID
|
||||
/// * `messages` - Conversation history
|
||||
/// * `api_key` - API key (required for non-Ollama providers)
|
||||
/// * `stream` - Whether to request streaming
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The raw `reqwest::Response` for the caller to consume (streaming or not).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the HTTP request fails.
|
||||
pub async fn send_chat_request(
|
||||
state: &ServerState,
|
||||
provider: &str,
|
||||
model: &str,
|
||||
messages: &[ProviderMessage],
|
||||
api_key: Option<&str>,
|
||||
stream: bool,
|
||||
) -> Result<reqwest::Response, reqwest::Error> {
|
||||
let client = Client::new();
|
||||
|
||||
match provider {
|
||||
"openai" => {
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
});
|
||||
client
|
||||
.post("https://api.openai.com/v1/chat/completions")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", api_key.unwrap_or_default()),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
"anthropic" => {
|
||||
// Anthropic uses a different API format -- translate.
|
||||
// Extract system message separately, convert roles.
|
||||
let system_msg: String = messages
|
||||
.iter()
|
||||
.filter(|m| m.role == "system")
|
||||
.map(|m| m.content.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let anthropic_msgs: Vec<serde_json::Value> = messages
|
||||
.iter()
|
||||
.filter(|m| m.role != "system")
|
||||
.map(|m| {
|
||||
serde_json::json!({
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": anthropic_msgs,
|
||||
"max_tokens": 4096,
|
||||
"stream": stream,
|
||||
});
|
||||
if !system_msg.is_empty() {
|
||||
body["system"] = serde_json::Value::String(system_msg);
|
||||
}
|
||||
|
||||
client
|
||||
.post("https://api.anthropic.com/v1/messages")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-api-key", api_key.unwrap_or_default())
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
"huggingface" => {
|
||||
let url = format!(
|
||||
"https://api-inference.huggingface.co/models/{}/v1/chat/completions",
|
||||
model
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
});
|
||||
client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", api_key.unwrap_or_default()),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
// Default: Ollama (OpenAI-compatible endpoint)
|
||||
_ => {
|
||||
let base_url = &state.services.ollama_url;
|
||||
let resolved_model = if model.is_empty() {
|
||||
&state.services.ollama_model
|
||||
} else {
|
||||
model
|
||||
};
|
||||
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||
let body = serde_json::json!({
|
||||
"model": resolved_model,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
});
|
||||
client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn provider_message_serde_round_trip() {
|
||||
let msg = ProviderMessage {
|
||||
role: "assistant".into(),
|
||||
content: "Hello, world!".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).expect("serialize ProviderMessage");
|
||||
let back: ProviderMessage =
|
||||
serde_json::from_str(&json).expect("deserialize ProviderMessage");
|
||||
assert_eq!(msg.role, back.role);
|
||||
assert_eq!(msg.content, back.content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_message_deserialize_from_json_literal() {
|
||||
let json = r#"{"role":"user","content":"What is Rust?"}"#;
|
||||
let msg: ProviderMessage = serde_json::from_str(json).expect("deserialize from literal");
|
||||
assert_eq!(msg.role, "user");
|
||||
assert_eq!(msg.content, "What is Rust?");
|
||||
}
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
use crate::models::NewsCard;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
// Server-side helpers and types are only needed for the server build.
|
||||
// The #[server] macro generates a client stub for the web build that
|
||||
// sends a network request instead of executing this function body.
|
||||
#[cfg(feature = "server")]
|
||||
pub(crate) mod inner {
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Individual result from the SearXNG search API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct SearxngResult {
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub content: Option<String>,
|
||||
#[serde(rename = "publishedDate")]
|
||||
pub published_date: Option<String>,
|
||||
pub thumbnail: Option<String>,
|
||||
/// Relevance score assigned by SearXNG (higher = more relevant).
|
||||
#[serde(default)]
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
/// Top-level response from the SearXNG search API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct SearxngResponse {
|
||||
pub results: Vec<SearxngResult>,
|
||||
}
|
||||
|
||||
/// Extract the domain name from a URL to use as the source label.
|
||||
///
|
||||
/// Strips common prefixes like "www." for cleaner display.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url_str` - The full URL string
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The domain host or a fallback "Web" string
|
||||
pub(crate) fn extract_source(url_str: &str) -> String {
|
||||
url::Url::parse(url_str)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(String::from))
|
||||
.map(|host| host.strip_prefix("www.").unwrap_or(&host).to_string())
|
||||
.unwrap_or_else(|| "Web".into())
|
||||
}
|
||||
|
||||
/// Deduplicate and rank search results for quality, similar to Perplexity.
|
||||
///
|
||||
/// Applies the following filters in order:
|
||||
/// 1. Remove results with empty content (no snippet = low value)
|
||||
/// 2. Deduplicate by domain (keep highest-scored result per domain)
|
||||
/// 3. Sort by SearXNG relevance score (descending)
|
||||
/// 4. Cap at `max_results`
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `results` - Raw search results from SearXNG
|
||||
/// * `max_results` - Maximum number of results to return
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Filtered, deduplicated, and ranked results
|
||||
pub(crate) fn rank_and_deduplicate(
|
||||
mut results: Vec<SearxngResult>,
|
||||
max_results: usize,
|
||||
) -> Vec<SearxngResult> {
|
||||
// Filter out results with no meaningful content
|
||||
results.retain(|r| r.content.as_ref().is_some_and(|c| c.trim().len() >= 20));
|
||||
|
||||
// Sort by score descending so we keep the best result per domain
|
||||
results.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
// Deduplicate by domain: keep only the first (highest-scored) per domain
|
||||
let mut seen_domains = HashSet::new();
|
||||
results.retain(|r| {
|
||||
let domain = extract_source(&r.url);
|
||||
seen_domains.insert(domain)
|
||||
});
|
||||
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
/// Search for news using the SearXNG meta-search engine.
|
||||
///
|
||||
/// Uses Perplexity-style query enrichment and result ranking:
|
||||
/// - Queries the "news" and "general" categories for fresh, relevant results
|
||||
/// - Filters to the last month for recency
|
||||
/// - Deduplicates by domain for source diversity
|
||||
/// - Ranks by SearXNG relevance score
|
||||
/// - Filters out results without meaningful content
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `query` - The search query string
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Up to 15 high-quality `NewsCard` results, or a `ServerFnError` on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the SearXNG request fails or response parsing fails
|
||||
#[post("/api/search")]
|
||||
pub async fn search_topic(query: String) -> Result<Vec<NewsCard>, ServerFnError> {
|
||||
use inner::{extract_source, rank_and_deduplicate, SearxngResponse};
|
||||
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let searxng_url = state.services.searxng_url.clone();
|
||||
|
||||
// Enrich the query with "latest news" context for better results,
|
||||
// similar to how Perplexity reformulates queries before searching.
|
||||
let enriched_query = format!("{query} latest news");
|
||||
|
||||
// Use POST with form-encoded body because SearXNG's default config
|
||||
// sets `method: "POST"` which rejects GET requests with 405.
|
||||
let search_url = format!("{searxng_url}/search");
|
||||
let params = [
|
||||
("q", enriched_query.as_str()),
|
||||
("format", "json"),
|
||||
("language", "en"),
|
||||
("categories", "news,general"),
|
||||
("time_range", "month"),
|
||||
];
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&search_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("SearXNG request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(ServerFnError::new(format!(
|
||||
"SearXNG returned status {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body: SearxngResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse SearXNG response: {e}")))?;
|
||||
|
||||
// Apply Perplexity-style ranking: filter empties, deduplicate domains, sort by score
|
||||
let ranked = rank_and_deduplicate(body.results, 15);
|
||||
|
||||
let cards: Vec<NewsCard> = ranked
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let summary = r
|
||||
.content
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.take(200)
|
||||
.collect::<String>();
|
||||
let content = r.content.unwrap_or_default();
|
||||
NewsCard {
|
||||
title: r.title,
|
||||
source: extract_source(&r.url),
|
||||
summary,
|
||||
content,
|
||||
category: query.clone(),
|
||||
url: r.url,
|
||||
thumbnail_url: r.thumbnail,
|
||||
published_at: r.published_date.unwrap_or_else(|| "Recent".into()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(cards)
|
||||
}
|
||||
|
||||
/// Fetch trending topic keywords by running a broad news search and
|
||||
/// extracting the most frequent meaningful terms from result titles.
|
||||
///
|
||||
/// This approach works regardless of whether SearXNG has autocomplete
|
||||
/// configured, since it uses the standard search API.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Up to 8 trending keyword strings, or a `ServerFnError` on failure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the SearXNG search request fails
|
||||
#[get("/api/trending")]
|
||||
pub async fn get_trending_topics() -> Result<Vec<String>, ServerFnError> {
|
||||
use inner::SearxngResponse;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let state: crate::infrastructure::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let searxng_url = state.services.searxng_url.clone();
|
||||
|
||||
// Use POST to match SearXNG's default `method: "POST"` setting
|
||||
let search_url = format!("{searxng_url}/search");
|
||||
let params = [
|
||||
("q", "trending technology AI"),
|
||||
("format", "json"),
|
||||
("language", "en"),
|
||||
("categories", "news"),
|
||||
("time_range", "week"),
|
||||
];
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
|
||||
|
||||
let resp = client
|
||||
.post(&search_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("SearXNG trending search failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(ServerFnError::new(format!(
|
||||
"SearXNG trending search returned status {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body: SearxngResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse trending response: {e}")))?;
|
||||
|
||||
// Common stop words to exclude from trending keywords
|
||||
const STOP_WORDS: &[&str] = &[
|
||||
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
|
||||
"from", "is", "are", "was", "were", "be", "been", "has", "have", "had", "do", "does",
|
||||
"did", "will", "would", "could", "should", "may", "can", "not", "no", "it", "its", "this",
|
||||
"that", "these", "how", "what", "why", "who", "when", "new", "says", "said", "about",
|
||||
"after", "over", "into", "up", "out", "as", "all", "more", "than", "just", "now", "also",
|
||||
"us", "we", "you", "your", "our", "if", "so", "like", "get", "make", "year", "years",
|
||||
"one", "two",
|
||||
];
|
||||
|
||||
// Count word frequency across all result titles. Words are lowercased
|
||||
// and must be at least 3 characters to filter out noise.
|
||||
let mut word_counts: HashMap<String, u32> = HashMap::new();
|
||||
for result in &body.results {
|
||||
for word in result.title.split_whitespace() {
|
||||
// Strip punctuation from edges, lowercase
|
||||
let clean: String = word
|
||||
.trim_matches(|c: char| !c.is_alphanumeric())
|
||||
.to_lowercase();
|
||||
if clean.len() >= 3 && !STOP_WORDS.contains(&clean.as_str()) {
|
||||
*word_counts.entry(clean).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by frequency descending, take top 8
|
||||
let mut sorted: Vec<(String, u32)> = word_counts.into_iter().collect();
|
||||
sorted.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
// Capitalize first letter for display
|
||||
let topics: Vec<String> = sorted
|
||||
.into_iter()
|
||||
.filter(|(_, count)| *count >= 2)
|
||||
.take(8)
|
||||
.map(|(word, _)| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
||||
None => word,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(topics)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::inner::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// extract_source()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn extract_source_strips_www() {
|
||||
assert_eq!(
|
||||
extract_source("https://www.example.com/page"),
|
||||
"example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_source_returns_domain() {
|
||||
assert_eq!(
|
||||
extract_source("https://techcrunch.com/article"),
|
||||
"techcrunch.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_source_invalid_url_returns_web() {
|
||||
assert_eq!(extract_source("not-a-url"), "Web");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_source_no_scheme_returns_web() {
|
||||
// url::Url::parse requires a scheme; bare domain fails
|
||||
assert_eq!(extract_source("example.com/path"), "Web");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// rank_and_deduplicate()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn make_result(url: &str, content: &str, score: f64) -> SearxngResult {
|
||||
SearxngResult {
|
||||
title: "Title".into(),
|
||||
url: url.into(),
|
||||
content: if content.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(content.into())
|
||||
},
|
||||
published_date: None,
|
||||
thumbnail: None,
|
||||
score,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_filters_empty_content() {
|
||||
let results = vec![
|
||||
make_result("https://a.com", "", 10.0),
|
||||
make_result(
|
||||
"https://b.com",
|
||||
"This is meaningful content that passes the length filter",
|
||||
5.0,
|
||||
),
|
||||
];
|
||||
let ranked = rank_and_deduplicate(results, 10);
|
||||
assert_eq!(ranked.len(), 1);
|
||||
assert_eq!(ranked[0].url, "https://b.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_filters_short_content() {
|
||||
let results = vec![
|
||||
make_result("https://a.com", "short", 10.0),
|
||||
make_result(
|
||||
"https://b.com",
|
||||
"This content is long enough to pass the 20-char filter threshold",
|
||||
5.0,
|
||||
),
|
||||
];
|
||||
let ranked = rank_and_deduplicate(results, 10);
|
||||
assert_eq!(ranked.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_deduplicates_by_domain_keeps_highest() {
|
||||
let results = vec![
|
||||
make_result(
|
||||
"https://example.com/page1",
|
||||
"First result with enough content here for the filter",
|
||||
3.0,
|
||||
),
|
||||
make_result(
|
||||
"https://example.com/page2",
|
||||
"Second result with enough content here for the filter",
|
||||
8.0,
|
||||
),
|
||||
];
|
||||
let ranked = rank_and_deduplicate(results, 10);
|
||||
assert_eq!(ranked.len(), 1);
|
||||
// Should keep the highest-scored one (page2 with score 8.0)
|
||||
assert_eq!(ranked[0].url, "https://example.com/page2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_sorts_by_score_descending() {
|
||||
let results = vec![
|
||||
make_result(
|
||||
"https://a.com/p",
|
||||
"Content A that is long enough to pass the filter check",
|
||||
1.0,
|
||||
),
|
||||
make_result(
|
||||
"https://b.com/p",
|
||||
"Content B that is long enough to pass the filter check",
|
||||
5.0,
|
||||
),
|
||||
make_result(
|
||||
"https://c.com/p",
|
||||
"Content C that is long enough to pass the filter check",
|
||||
3.0,
|
||||
),
|
||||
];
|
||||
let ranked = rank_and_deduplicate(results, 10);
|
||||
assert_eq!(ranked.len(), 3);
|
||||
assert!(ranked[0].score >= ranked[1].score);
|
||||
assert!(ranked[1].score >= ranked[2].score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_truncates_to_max_results() {
|
||||
let results: Vec<_> = (0..20)
|
||||
.map(|i| {
|
||||
make_result(
|
||||
&format!("https://site{i}.com/page"),
|
||||
&format!("Content for site {i} that is long enough to pass the filter"),
|
||||
i as f64,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let ranked = rank_and_deduplicate(results, 5);
|
||||
assert_eq!(ranked.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_empty_input_returns_empty() {
|
||||
let ranked = rank_and_deduplicate(vec![], 10);
|
||||
assert!(ranked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_all_filtered_returns_empty() {
|
||||
let results = vec![
|
||||
make_result("https://a.com", "", 10.0),
|
||||
make_result("https://b.com", "too short", 5.0),
|
||||
];
|
||||
let ranked = rank_and_deduplicate(results, 10);
|
||||
assert!(ranked.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,54 @@
|
||||
use crate::infrastructure::{
|
||||
auth_callback, auth_login, logout, PendingOAuthStore, UserState, UserStateInner,
|
||||
};
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::{middleware, Extension};
|
||||
use axum::Extension;
|
||||
use time::Duration;
|
||||
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer};
|
||||
|
||||
use crate::infrastructure::{
|
||||
auth_callback, auth_login,
|
||||
config::{KeycloakConfig, LlmProvidersConfig, ServiceUrls, SmtpConfig, StripeConfig},
|
||||
database::Database,
|
||||
logout, require_auth,
|
||||
server_state::{ServerState, ServerStateInner},
|
||||
PendingOAuthStore,
|
||||
};
|
||||
|
||||
/// Start the Axum server with Dioxus fullstack, session management,
|
||||
/// MongoDB, and Keycloak OAuth routes.
|
||||
///
|
||||
/// Loads all configuration from environment variables once, connects
|
||||
/// to MongoDB, and builds a [`ServerState`] shared across every request.
|
||||
/// and Keycloak OAuth routes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error` if the tokio runtime, config loading, DB connection,
|
||||
/// or TCP listener fails.
|
||||
/// 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 {
|
||||
// Load .env once at startup.
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
// ---- Load and leak config structs for 'static lifetime ----
|
||||
let keycloak: &'static KeycloakConfig = Box::leak(Box::new(KeycloakConfig::from_env()?));
|
||||
let smtp: &'static SmtpConfig = Box::leak(Box::new(SmtpConfig::from_env()?));
|
||||
let services: &'static ServiceUrls = Box::leak(Box::new(ServiceUrls::from_env()?));
|
||||
let stripe: &'static StripeConfig = Box::leak(Box::new(StripeConfig::from_env()?));
|
||||
let llm_providers: &'static LlmProvidersConfig =
|
||||
Box::leak(Box::new(LlmProvidersConfig::from_env()?));
|
||||
|
||||
tracing::info!("Configuration loaded");
|
||||
|
||||
// ---- Connect to MongoDB ----
|
||||
let mongo_uri =
|
||||
std::env::var("MONGODB_URI").unwrap_or_else(|_| "mongodb://localhost:27017".into());
|
||||
let mongo_db = std::env::var("MONGODB_DATABASE").unwrap_or_else(|_| "certifai".into());
|
||||
|
||||
let db = Database::connect(&mongo_uri, &mongo_db).await?;
|
||||
tracing::info!("Connected to MongoDB (database: {mongo_db})");
|
||||
|
||||
// ---- Build ServerState ----
|
||||
let server_state: ServerState = ServerStateInner {
|
||||
db,
|
||||
keycloak,
|
||||
smtp,
|
||||
services,
|
||||
stripe,
|
||||
llm_providers,
|
||||
let state: UserState = UserStateInner {
|
||||
access_token: "abcd".into(),
|
||||
sub: "abcd".into(),
|
||||
refresh_token: "abcd".into(),
|
||||
..Default::default()
|
||||
}
|
||||
.into();
|
||||
|
||||
// ---- Session layer ----
|
||||
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);
|
||||
|
||||
// ---- Build router ----
|
||||
let addr = dioxus_cli_config::fullstack_address_or_localhost();
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
|
||||
// Layers wrap in reverse order: session (outermost) -> auth
|
||||
// middleware -> extensions -> route handlers. The session layer
|
||||
// must be outermost so the `Session` extractor is available to
|
||||
// the auth middleware, which gates all `/api/` server function
|
||||
// routes (except `check-auth`).
|
||||
// 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(server_state))
|
||||
.layer(middleware::from_fn(require_auth))
|
||||
.layer(Extension(state))
|
||||
.layer(session);
|
||||
|
||||
tracing::info!("Serving at {addr}");
|
||||
info!("Serving at {addr}");
|
||||
axum::serve(listener, router.into_make_service()).await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
//! Application-wide server state available in both Axum handlers and
|
||||
//! Dioxus server functions via `extract()`.
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! // Inside a #[server] function:
|
||||
//! let state: ServerState = extract().await?;
|
||||
//! ```
|
||||
|
||||
use std::{ops::Deref, sync::Arc};
|
||||
|
||||
use super::{
|
||||
config::{KeycloakConfig, LlmProvidersConfig, ServiceUrls, SmtpConfig, StripeConfig},
|
||||
database::Database,
|
||||
Error,
|
||||
};
|
||||
|
||||
/// Cheap-to-clone handle to the shared server state.
|
||||
///
|
||||
/// Stored as an Axum `Extension` so it is accessible from both
|
||||
/// route handlers and Dioxus `#[server]` functions.
|
||||
#[derive(Clone)]
|
||||
pub struct ServerState(Arc<ServerStateInner>);
|
||||
|
||||
impl Deref for ServerState {
|
||||
type Target = ServerStateInner;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServerStateInner> for ServerState {
|
||||
fn from(value: ServerStateInner) -> Self {
|
||||
Self(Arc::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner struct holding all long-lived application resources.
|
||||
///
|
||||
/// Config references are `&'static` because they are `Box::leak`ed
|
||||
/// at startup -- they never change at runtime.
|
||||
pub struct ServerStateInner {
|
||||
/// MongoDB connection pool.
|
||||
pub db: Database,
|
||||
/// Keycloak / OAuth2 settings.
|
||||
pub keycloak: &'static KeycloakConfig,
|
||||
/// Outbound email settings.
|
||||
pub smtp: &'static SmtpConfig,
|
||||
/// URLs for Ollama, SearXNG, LangChain, S3, etc.
|
||||
pub services: &'static ServiceUrls,
|
||||
/// Stripe billing keys.
|
||||
pub stripe: &'static StripeConfig,
|
||||
/// Enabled LLM provider list.
|
||||
pub llm_providers: &'static LlmProvidersConfig,
|
||||
}
|
||||
|
||||
// `FromRequestParts` lets us `extract::<ServerState>()` inside
|
||||
// Dioxus server functions and regular Axum handlers alike.
|
||||
impl<S> axum::extract::FromRequestParts<S> for ServerState
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Error;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<ServerState>()
|
||||
.cloned()
|
||||
.ok_or(Error::StateError("ServerState extension not found".into()))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::{ops::Deref, sync::Arc};
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cheap-to-clone handle to per-session user data.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserState(Arc<UserStateInner>);
|
||||
|
||||
@@ -19,116 +19,39 @@ impl From<UserStateInner> for UserState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-session user data stored in the tower-sessions session store.
|
||||
///
|
||||
/// Persisted across requests for the lifetime of the session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UserStateInner {
|
||||
/// Subject identifier from Keycloak (unique user ID).
|
||||
/// Subject in Oauth
|
||||
pub sub: String,
|
||||
/// OAuth2 access token.
|
||||
/// Access Token
|
||||
pub access_token: String,
|
||||
/// OAuth2 refresh token.
|
||||
/// Refresh Token
|
||||
pub refresh_token: String,
|
||||
/// Basic user profile.
|
||||
/// User
|
||||
pub user: User,
|
||||
}
|
||||
|
||||
/// Basic user profile stored alongside the session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct User {
|
||||
/// Email address.
|
||||
/// Email
|
||||
pub email: String,
|
||||
/// Display name (preferred_username or full name from Keycloak).
|
||||
pub name: String,
|
||||
/// Avatar / profile picture URL.
|
||||
/// Avatar Url
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn user_state_inner_default_has_empty_strings() {
|
||||
let inner = UserStateInner::default();
|
||||
assert_eq!(inner.sub, "");
|
||||
assert_eq!(inner.access_token, "");
|
||||
assert_eq!(inner.refresh_token, "");
|
||||
assert_eq!(inner.user.email, "");
|
||||
assert_eq!(inner.user.name, "");
|
||||
assert_eq!(inner.user.avatar_url, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_default_has_empty_strings() {
|
||||
let user = User::default();
|
||||
assert_eq!(user.email, "");
|
||||
assert_eq!(user.name, "");
|
||||
assert_eq!(user.avatar_url, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_state_inner_serde_round_trip() {
|
||||
let inner = UserStateInner {
|
||||
sub: "user-123".into(),
|
||||
access_token: "tok-abc".into(),
|
||||
refresh_token: "ref-xyz".into(),
|
||||
user: User {
|
||||
email: "a@b.com".into(),
|
||||
name: "Alice".into(),
|
||||
avatar_url: "https://img.example.com/a.png".into(),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&inner).expect("serialize UserStateInner");
|
||||
let back: UserStateInner = serde_json::from_str(&json).expect("deserialize UserStateInner");
|
||||
assert_eq!(inner.sub, back.sub);
|
||||
assert_eq!(inner.access_token, back.access_token);
|
||||
assert_eq!(inner.refresh_token, back.refresh_token);
|
||||
assert_eq!(inner.user.email, back.user.email);
|
||||
assert_eq!(inner.user.name, back.user.name);
|
||||
assert_eq!(inner.user.avatar_url, back.user.avatar_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_state_from_inner_and_deref() {
|
||||
let inner = UserStateInner {
|
||||
sub: "sub-1".into(),
|
||||
access_token: "at".into(),
|
||||
refresh_token: "rt".into(),
|
||||
user: User {
|
||||
email: "e@e.com".into(),
|
||||
name: "Eve".into(),
|
||||
avatar_url: "".into(),
|
||||
},
|
||||
};
|
||||
let state = UserState::from(inner);
|
||||
// Deref should give access to inner fields
|
||||
assert_eq!(state.sub, "sub-1");
|
||||
assert_eq!(state.user.name, "Eve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_serde_round_trip() {
|
||||
let user = User {
|
||||
email: "bob@test.com".into(),
|
||||
name: "Bob".into(),
|
||||
avatar_url: "https://avatars.io/bob".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&user).expect("serialize User");
|
||||
let back: User = serde_json::from_str(&json).expect("deserialize User");
|
||||
assert_eq!(user.email, back.email);
|
||||
assert_eq!(user.name, back.name);
|
||||
assert_eq!(user.avatar_url, back.avatar_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_state_clone_is_cheap() {
|
||||
let inner = UserStateInner::default();
|
||||
let state = UserState::from(inner);
|
||||
let cloned = state.clone();
|
||||
// Both point to the same Arc allocation
|
||||
assert_eq!(state.sub, cloned.sub);
|
||||
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,12 +1,11 @@
|
||||
mod app;
|
||||
mod components;
|
||||
pub mod i18n;
|
||||
pub mod infrastructure;
|
||||
mod models;
|
||||
mod pages;
|
||||
|
||||
pub use app::*;
|
||||
pub use components::*;
|
||||
pub use i18n::*;
|
||||
|
||||
pub use models::*;
|
||||
pub use pages::*;
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
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,
|
||||
}
|
||||
|
||||
/// Namespace for grouping chat sessions in the sidebar.
|
||||
///
|
||||
/// Sessions are visually separated in the chat sidebar by namespace,
|
||||
/// with `News` sessions appearing under a dedicated "News Chats" header.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub enum ChatNamespace {
|
||||
/// General user-initiated chat conversations.
|
||||
#[default]
|
||||
General,
|
||||
/// Chats originating from news article follow-ups on the dashboard.
|
||||
News,
|
||||
}
|
||||
|
||||
/// 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 persisted chat session stored in MongoDB.
|
||||
///
|
||||
/// Messages are stored separately in the `chat_messages` collection
|
||||
/// and loaded on demand when the user opens a session.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - MongoDB document ID (hex string)
|
||||
/// * `user_sub` - Keycloak subject ID (session owner)
|
||||
/// * `title` - Display title (auto-generated or user-renamed)
|
||||
/// * `namespace` - Grouping for sidebar sections
|
||||
/// * `provider` - LLM provider used (e.g. "ollama", "openai")
|
||||
/// * `model` - Model ID used (e.g. "llama3.1:8b")
|
||||
/// * `created_at` - ISO 8601 creation timestamp
|
||||
/// * `updated_at` - ISO 8601 last-activity timestamp
|
||||
/// * `article_url` - Source article URL (for News namespace sessions)
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChatSession {
|
||||
#[serde(default, alias = "_id", skip_serializing_if = "String::is_empty")]
|
||||
pub id: String,
|
||||
pub user_sub: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub namespace: ChatNamespace,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub article_url: Option<String>,
|
||||
}
|
||||
|
||||
/// A single persisted message within a chat session.
|
||||
///
|
||||
/// Stored in the `chat_messages` MongoDB collection, linked to a
|
||||
/// `ChatSession` via `session_id`.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - MongoDB document ID (hex string)
|
||||
/// * `session_id` - Foreign key to `ChatSession.id`
|
||||
/// * `role` - Who sent this message
|
||||
/// * `content` - Message text content (may contain markdown)
|
||||
/// * `attachments` - File attachments (Phase 2, currently empty)
|
||||
/// * `timestamp` - ISO 8601 timestamp
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
#[serde(default, alias = "_id", skip_serializing_if = "String::is_empty")]
|
||||
pub id: String,
|
||||
pub session_id: String,
|
||||
pub role: ChatRole,
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn chat_namespace_default_is_general() {
|
||||
assert_eq!(ChatNamespace::default(), ChatNamespace::General);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_role_serde_round_trip() {
|
||||
for role in [ChatRole::User, ChatRole::Assistant, ChatRole::System] {
|
||||
let json =
|
||||
serde_json::to_string(&role).unwrap_or_else(|_| panic!("serialize {:?}", role));
|
||||
let back: ChatRole =
|
||||
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", role));
|
||||
assert_eq!(role, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_namespace_serde_round_trip() {
|
||||
for ns in [ChatNamespace::General, ChatNamespace::News] {
|
||||
let json = serde_json::to_string(&ns).unwrap_or_else(|_| panic!("serialize {:?}", ns));
|
||||
let back: ChatNamespace =
|
||||
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", ns));
|
||||
assert_eq!(ns, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_kind_serde_round_trip() {
|
||||
for kind in [
|
||||
AttachmentKind::Image,
|
||||
AttachmentKind::Document,
|
||||
AttachmentKind::Code,
|
||||
] {
|
||||
let json =
|
||||
serde_json::to_string(&kind).unwrap_or_else(|_| panic!("serialize {:?}", kind));
|
||||
let back: AttachmentKind =
|
||||
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", kind));
|
||||
assert_eq!(kind, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_serde_round_trip() {
|
||||
let att = Attachment {
|
||||
name: "photo.png".into(),
|
||||
kind: AttachmentKind::Image,
|
||||
size_bytes: 2048,
|
||||
};
|
||||
let json = serde_json::to_string(&att).expect("serialize Attachment");
|
||||
let back: Attachment = serde_json::from_str(&json).expect("deserialize Attachment");
|
||||
assert_eq!(att, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_session_serde_round_trip() {
|
||||
let session = ChatSession {
|
||||
id: "abc123".into(),
|
||||
user_sub: "user-1".into(),
|
||||
title: "Test Chat".into(),
|
||||
namespace: ChatNamespace::General,
|
||||
provider: "ollama".into(),
|
||||
model: "llama3.1:8b".into(),
|
||||
created_at: "2025-01-01T00:00:00Z".into(),
|
||||
updated_at: "2025-01-01T01:00:00Z".into(),
|
||||
article_url: None,
|
||||
};
|
||||
let json = serde_json::to_string(&session).expect("serialize ChatSession");
|
||||
let back: ChatSession = serde_json::from_str(&json).expect("deserialize ChatSession");
|
||||
assert_eq!(session, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_session_id_alias_deserialization() {
|
||||
// MongoDB returns `_id` instead of `id`
|
||||
let json = r#"{
|
||||
"_id": "mongo-id",
|
||||
"user_sub": "u1",
|
||||
"title": "t",
|
||||
"provider": "ollama",
|
||||
"model": "m",
|
||||
"created_at": "2025-01-01",
|
||||
"updated_at": "2025-01-01"
|
||||
}"#;
|
||||
let session: ChatSession = serde_json::from_str(json).expect("deserialize with _id");
|
||||
assert_eq!(session.id, "mongo-id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_session_empty_id_skips_serialization() {
|
||||
let session = ChatSession {
|
||||
id: String::new(),
|
||||
user_sub: "u1".into(),
|
||||
title: "t".into(),
|
||||
namespace: ChatNamespace::default(),
|
||||
provider: "ollama".into(),
|
||||
model: "m".into(),
|
||||
created_at: "2025-01-01".into(),
|
||||
updated_at: "2025-01-01".into(),
|
||||
article_url: None,
|
||||
};
|
||||
let json = serde_json::to_string(&session).expect("serialize");
|
||||
// `id` field should be absent when empty due to skip_serializing_if
|
||||
assert!(!json.contains("\"id\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_session_none_article_url_skips_serialization() {
|
||||
let session = ChatSession {
|
||||
id: "s1".into(),
|
||||
user_sub: "u1".into(),
|
||||
title: "t".into(),
|
||||
namespace: ChatNamespace::default(),
|
||||
provider: "ollama".into(),
|
||||
model: "m".into(),
|
||||
created_at: "2025-01-01".into(),
|
||||
updated_at: "2025-01-01".into(),
|
||||
article_url: None,
|
||||
};
|
||||
let json = serde_json::to_string(&session).expect("serialize");
|
||||
assert!(!json.contains("article_url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_serde_round_trip() {
|
||||
let msg = ChatMessage {
|
||||
id: "msg-1".into(),
|
||||
session_id: "s1".into(),
|
||||
role: ChatRole::User,
|
||||
content: "Hello AI".into(),
|
||||
attachments: vec![Attachment {
|
||||
name: "doc.pdf".into(),
|
||||
kind: AttachmentKind::Document,
|
||||
size_bytes: 4096,
|
||||
}],
|
||||
timestamp: "2025-01-01T00:00:00Z".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).expect("serialize ChatMessage");
|
||||
let back: ChatMessage = serde_json::from_str(&json).expect("deserialize ChatMessage");
|
||||
assert_eq!(msg, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_id_alias_deserialization() {
|
||||
let json = r#"{
|
||||
"_id": "mongo-msg-id",
|
||||
"session_id": "s1",
|
||||
"role": "User",
|
||||
"content": "hi",
|
||||
"timestamp": "2025-01-01"
|
||||
}"#;
|
||||
let msg: ChatMessage = serde_json::from_str(json).expect("deserialize with _id");
|
||||
assert_eq!(msg.id, "mongo-msg-id");
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
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,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn agent_entry_serde_round_trip() {
|
||||
let agent = AgentEntry {
|
||||
id: "a1".into(),
|
||||
name: "RAG Agent".into(),
|
||||
description: "Retrieval-augmented generation".into(),
|
||||
status: "running".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&agent).expect("serialize AgentEntry");
|
||||
let back: AgentEntry = serde_json::from_str(&json).expect("deserialize AgentEntry");
|
||||
assert_eq!(agent, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_entry_serde_round_trip() {
|
||||
let flow = FlowEntry {
|
||||
id: "f1".into(),
|
||||
name: "Data Pipeline".into(),
|
||||
node_count: 5,
|
||||
last_run: Some("2025-06-01T12:00:00Z".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&flow).expect("serialize FlowEntry");
|
||||
let back: FlowEntry = serde_json::from_str(&json).expect("deserialize FlowEntry");
|
||||
assert_eq!(flow, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_entry_with_none_last_run() {
|
||||
let flow = FlowEntry {
|
||||
id: "f2".into(),
|
||||
name: "New Flow".into(),
|
||||
node_count: 0,
|
||||
last_run: None,
|
||||
};
|
||||
let json = serde_json::to_string(&flow).expect("serialize");
|
||||
let back: FlowEntry = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(flow, back);
|
||||
assert_eq!(back.last_run, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytics_metric_negative_change_pct() {
|
||||
let metric = AnalyticsMetric {
|
||||
label: "Latency".into(),
|
||||
value: "120ms".into(),
|
||||
change_pct: -15.5,
|
||||
};
|
||||
let json = serde_json::to_string(&metric).expect("serialize AnalyticsMetric");
|
||||
let back: AnalyticsMetric =
|
||||
serde_json::from_str(&json).expect("deserialize AnalyticsMetric");
|
||||
assert_eq!(metric, back);
|
||||
assert!(back.change_pct < 0.0);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,3 @@
|
||||
mod chat;
|
||||
mod developer;
|
||||
mod news;
|
||||
mod organization;
|
||||
mod provider;
|
||||
mod user;
|
||||
|
||||
pub use chat::*;
|
||||
pub use developer::*;
|
||||
pub use news::*;
|
||||
pub use organization::*;
|
||||
pub use provider::*;
|
||||
pub use user::*;
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 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
|
||||
/// * `content` - Full content snippet from search results
|
||||
/// * `category` - Display label for the search topic (e.g. "AI", "Finance")
|
||||
/// * `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 content: String,
|
||||
pub category: String,
|
||||
pub url: String,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub published_at: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn news_card_serde_round_trip() {
|
||||
let card = NewsCard {
|
||||
title: "AI Breakthrough".into(),
|
||||
source: "techcrunch.com".into(),
|
||||
summary: "New model released".into(),
|
||||
content: "Full article content here".into(),
|
||||
category: "AI".into(),
|
||||
url: "https://example.com/article".into(),
|
||||
thumbnail_url: Some("https://example.com/thumb.jpg".into()),
|
||||
published_at: "2025-06-01".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&card).expect("serialize NewsCard");
|
||||
let back: NewsCard = serde_json::from_str(&json).expect("deserialize NewsCard");
|
||||
assert_eq!(card, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn news_card_thumbnail_none() {
|
||||
let card = NewsCard {
|
||||
title: "No Thumb".into(),
|
||||
source: "bbc.com".into(),
|
||||
summary: "Summary".into(),
|
||||
content: "Content".into(),
|
||||
category: "Tech".into(),
|
||||
url: "https://bbc.com/article".into(),
|
||||
thumbnail_url: None,
|
||||
published_at: "2025-06-01".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&card).expect("serialize");
|
||||
let back: NewsCard = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(card, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn news_card_thumbnail_some() {
|
||||
let card = NewsCard {
|
||||
title: "With Thumb".into(),
|
||||
source: "cnn.com".into(),
|
||||
summary: "Summary".into(),
|
||||
content: "Content".into(),
|
||||
category: "News".into(),
|
||||
url: "https://cnn.com/article".into(),
|
||||
thumbnail_url: Some("https://cnn.com/img.jpg".into()),
|
||||
published_at: "2025-06-01".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&card).expect("serialize");
|
||||
assert!(json.contains("img.jpg"));
|
||||
let back: NewsCard = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(card.thumbnail_url, back.thumbnail_url);
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
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,
|
||||
}
|
||||
|
||||
/// Organisation-level settings stored in MongoDB.
|
||||
///
|
||||
/// These complement Keycloak's Organizations feature with
|
||||
/// business-specific data (billing, feature flags).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OrgSettings {
|
||||
/// Keycloak organisation identifier.
|
||||
pub org_id: String,
|
||||
/// Active pricing plan identifier.
|
||||
pub plan_id: String,
|
||||
/// Feature flags toggled on for this organisation.
|
||||
pub enabled_features: Vec<String>,
|
||||
/// Stripe customer ID linked to this organisation.
|
||||
pub stripe_customer_id: String,
|
||||
}
|
||||
|
||||
/// A single billing cycle record stored in MongoDB.
|
||||
///
|
||||
/// Captures seat and token usage between two dates for
|
||||
/// invoicing and usage dashboards.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OrgBillingRecord {
|
||||
/// Keycloak organisation identifier.
|
||||
pub org_id: String,
|
||||
/// ISO 8601 start of the billing cycle.
|
||||
pub cycle_start: String,
|
||||
/// ISO 8601 end of the billing cycle.
|
||||
pub cycle_end: String,
|
||||
/// Number of seats consumed during this cycle.
|
||||
pub seats_used: u32,
|
||||
/// Number of tokens consumed during this cycle.
|
||||
pub tokens_used: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn member_role_label_admin() {
|
||||
assert_eq!(MemberRole::Admin.label(), "Admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_role_label_member() {
|
||||
assert_eq!(MemberRole::Member.label(), "Member");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_role_label_viewer() {
|
||||
assert_eq!(MemberRole::Viewer.label(), "Viewer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_role_all_returns_three_in_order() {
|
||||
let all = MemberRole::all();
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all[0], MemberRole::Admin);
|
||||
assert_eq!(all[1], MemberRole::Member);
|
||||
assert_eq!(all[2], MemberRole::Viewer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_role_serde_round_trip() {
|
||||
for role in MemberRole::all() {
|
||||
let json =
|
||||
serde_json::to_string(role).unwrap_or_else(|_| panic!("serialize {:?}", role));
|
||||
let back: MemberRole =
|
||||
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", role));
|
||||
assert_eq!(*role, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_member_serde_round_trip() {
|
||||
let member = OrgMember {
|
||||
id: "m1".into(),
|
||||
name: "Alice".into(),
|
||||
email: "alice@example.com".into(),
|
||||
role: MemberRole::Admin,
|
||||
joined_at: "2025-01-01T00:00:00Z".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&member).expect("serialize OrgMember");
|
||||
let back: OrgMember = serde_json::from_str(&json).expect("deserialize OrgMember");
|
||||
assert_eq!(member, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pricing_plan_with_max_seats() {
|
||||
let plan = PricingPlan {
|
||||
id: "team".into(),
|
||||
name: "Team".into(),
|
||||
price_eur: 49,
|
||||
features: vec!["SSO".into(), "Priority".into()],
|
||||
highlighted: true,
|
||||
max_seats: Some(25),
|
||||
};
|
||||
let json = serde_json::to_string(&plan).expect("serialize PricingPlan");
|
||||
let back: PricingPlan = serde_json::from_str(&json).expect("deserialize PricingPlan");
|
||||
assert_eq!(plan, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pricing_plan_without_max_seats() {
|
||||
let plan = PricingPlan {
|
||||
id: "enterprise".into(),
|
||||
name: "Enterprise".into(),
|
||||
price_eur: 199,
|
||||
features: vec!["Unlimited".into()],
|
||||
highlighted: false,
|
||||
max_seats: None,
|
||||
};
|
||||
let json = serde_json::to_string(&plan).expect("serialize PricingPlan");
|
||||
let back: PricingPlan = serde_json::from_str(&json).expect("deserialize PricingPlan");
|
||||
assert_eq!(plan, back);
|
||||
assert!(json.contains("null") || !json.contains("max_seats"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_usage_serde_round_trip() {
|
||||
let usage = BillingUsage {
|
||||
seats_used: 5,
|
||||
seats_total: 10,
|
||||
tokens_used: 1_000_000,
|
||||
tokens_limit: 5_000_000,
|
||||
billing_cycle_end: "2025-12-31".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&usage).expect("serialize BillingUsage");
|
||||
let back: BillingUsage = serde_json::from_str(&json).expect("deserialize BillingUsage");
|
||||
assert_eq!(usage, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_settings_default() {
|
||||
let settings = OrgSettings::default();
|
||||
assert_eq!(settings.org_id, "");
|
||||
assert_eq!(settings.plan_id, "");
|
||||
assert!(settings.enabled_features.is_empty());
|
||||
assert_eq!(settings.stripe_customer_id, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_billing_record_default() {
|
||||
let record = OrgBillingRecord::default();
|
||||
assert_eq!(record.org_id, "");
|
||||
assert_eq!(record.cycle_start, "");
|
||||
assert_eq!(record.cycle_end, "");
|
||||
assert_eq!(record.seats_used, 0);
|
||||
assert_eq!(record.tokens_used, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
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,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn llm_provider_label_ollama() {
|
||||
assert_eq!(LlmProvider::Ollama.label(), "Ollama");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_provider_label_hugging_face() {
|
||||
assert_eq!(LlmProvider::HuggingFace.label(), "Hugging Face");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_provider_label_openai() {
|
||||
assert_eq!(LlmProvider::OpenAi.label(), "OpenAI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_provider_label_anthropic() {
|
||||
assert_eq!(LlmProvider::Anthropic.label(), "Anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_provider_serde_round_trip() {
|
||||
for variant in [
|
||||
LlmProvider::Ollama,
|
||||
LlmProvider::HuggingFace,
|
||||
LlmProvider::OpenAi,
|
||||
LlmProvider::Anthropic,
|
||||
] {
|
||||
let json = serde_json::to_string(&variant)
|
||||
.unwrap_or_else(|_| panic!("serialize {:?}", variant));
|
||||
let back: LlmProvider =
|
||||
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", variant));
|
||||
assert_eq!(variant, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_entry_serde_round_trip() {
|
||||
let entry = ModelEntry {
|
||||
id: "llama3.1:8b".into(),
|
||||
name: "Llama 3.1 8B".into(),
|
||||
provider: LlmProvider::Ollama,
|
||||
context_window: 8192,
|
||||
};
|
||||
let json = serde_json::to_string(&entry).expect("serialize ModelEntry");
|
||||
let back: ModelEntry = serde_json::from_str(&json).expect("deserialize ModelEntry");
|
||||
assert_eq!(entry, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_entry_serde_round_trip() {
|
||||
let entry = EmbeddingEntry {
|
||||
id: "nomic-embed".into(),
|
||||
name: "Nomic Embed".into(),
|
||||
provider: LlmProvider::HuggingFace,
|
||||
dimensions: 768,
|
||||
};
|
||||
let json = serde_json::to_string(&entry).expect("serialize EmbeddingEntry");
|
||||
let back: EmbeddingEntry = serde_json::from_str(&json).expect("deserialize EmbeddingEntry");
|
||||
assert_eq!(entry, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_serde_round_trip() {
|
||||
let cfg = ProviderConfig {
|
||||
provider: LlmProvider::Anthropic,
|
||||
selected_model: "claude-3".into(),
|
||||
selected_embedding: "embed-v1".into(),
|
||||
api_key_set: true,
|
||||
};
|
||||
let json = serde_json::to_string(&cfg).expect("serialize ProviderConfig");
|
||||
let back: ProviderConfig = serde_json::from_str(&json).expect("deserialize ProviderConfig");
|
||||
assert_eq!(cfg, back);
|
||||
}
|
||||
}
|
||||
@@ -1,150 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Basic user display data used by frontend components.
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct UserData {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Authentication information returned by the `check_auth` server function.
|
||||
///
|
||||
/// The frontend uses this to determine whether the user is logged in
|
||||
/// and to display their profile (name, email, avatar).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AuthInfo {
|
||||
/// Whether the user has a valid session
|
||||
pub authenticated: bool,
|
||||
/// Keycloak subject identifier (unique user ID)
|
||||
pub sub: String,
|
||||
/// User email address
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoggedInState {
|
||||
pub access_token: String,
|
||||
pub email: String,
|
||||
/// User display name
|
||||
pub name: String,
|
||||
/// Avatar URL (from Keycloak picture claim)
|
||||
pub avatar_url: String,
|
||||
/// LibreChat instance URL for the sidebar chat link
|
||||
pub librechat_url: String,
|
||||
}
|
||||
|
||||
/// Per-user LLM provider configuration stored in MongoDB.
|
||||
///
|
||||
/// Controls which provider and model the user's chat sessions default
|
||||
/// to, and stores API keys for non-Ollama providers.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UserProviderConfig {
|
||||
/// Default provider name (e.g. "ollama", "openai")
|
||||
pub default_provider: String,
|
||||
/// Default model ID (e.g. "llama3.1:8b", "gpt-4o")
|
||||
pub default_model: String,
|
||||
/// OpenAI API key (empty if not configured)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub openai_api_key: Option<String>,
|
||||
/// Anthropic API key
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub anthropic_api_key: Option<String>,
|
||||
/// HuggingFace API key
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub huggingface_api_key: Option<String>,
|
||||
/// Custom Ollama URL override (empty = use server default)
|
||||
pub ollama_url_override: String,
|
||||
}
|
||||
|
||||
/// Per-user preferences stored in MongoDB.
|
||||
///
|
||||
/// Keyed by `sub` (Keycloak subject) and optionally scoped to an org.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UserPreferences {
|
||||
/// Keycloak subject identifier
|
||||
pub sub: String,
|
||||
/// Organization ID (from Keycloak Organizations)
|
||||
pub org_id: String,
|
||||
/// User-selected news/search topics
|
||||
pub custom_topics: Vec<String>,
|
||||
/// Per-user Ollama URL override (empty = use server default)
|
||||
pub ollama_url_override: String,
|
||||
/// Per-user Ollama model override (empty = use server default)
|
||||
pub ollama_model_override: String,
|
||||
/// Recently searched queries for quick access
|
||||
pub recent_searches: Vec<String>,
|
||||
/// LLM provider configuration
|
||||
#[serde(default)]
|
||||
pub provider_config: UserProviderConfig,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn user_data_default() {
|
||||
let ud = UserData::default();
|
||||
assert_eq!(ud.name, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_info_default_not_authenticated() {
|
||||
let info = AuthInfo::default();
|
||||
assert!(!info.authenticated);
|
||||
assert_eq!(info.sub, "");
|
||||
assert_eq!(info.email, "");
|
||||
assert_eq!(info.name, "");
|
||||
assert_eq!(info.avatar_url, "");
|
||||
assert_eq!(info.librechat_url, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_info_serde_round_trip() {
|
||||
let info = AuthInfo {
|
||||
authenticated: true,
|
||||
sub: "sub-123".into(),
|
||||
email: "test@example.com".into(),
|
||||
name: "Test User".into(),
|
||||
avatar_url: "https://example.com/avatar.png".into(),
|
||||
librechat_url: "https://chat.example.com".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&info).expect("serialize AuthInfo");
|
||||
let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo");
|
||||
assert_eq!(info, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_preferences_default() {
|
||||
let prefs = UserPreferences::default();
|
||||
assert_eq!(prefs.sub, "");
|
||||
assert_eq!(prefs.org_id, "");
|
||||
assert!(prefs.custom_topics.is_empty());
|
||||
assert!(prefs.recent_searches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_provider_config_optional_keys_skip_none() {
|
||||
let cfg = UserProviderConfig {
|
||||
default_provider: "ollama".into(),
|
||||
default_model: "llama3.1:8b".into(),
|
||||
openai_api_key: None,
|
||||
anthropic_api_key: None,
|
||||
huggingface_api_key: None,
|
||||
ollama_url_override: String::new(),
|
||||
};
|
||||
let json = serde_json::to_string(&cfg).expect("serialize UserProviderConfig");
|
||||
assert!(!json.contains("openai_api_key"));
|
||||
assert!(!json.contains("anthropic_api_key"));
|
||||
assert!(!json.contains("huggingface_api_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_provider_config_serde_round_trip_with_keys() {
|
||||
let cfg = UserProviderConfig {
|
||||
default_provider: "openai".into(),
|
||||
default_model: "gpt-4o".into(),
|
||||
openai_api_key: Some("sk-test".into()),
|
||||
anthropic_api_key: Some("ak-test".into()),
|
||||
huggingface_api_key: None,
|
||||
ollama_url_override: "http://custom:11434".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&cfg).expect("serialize");
|
||||
let back: UserProviderConfig = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(cfg, back);
|
||||
impl LoggedInState {
|
||||
pub fn new(access_token: String, email: String) -> Self {
|
||||
Self {
|
||||
access_token,
|
||||
email,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,512 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_sdk::storage::use_persistent;
|
||||
|
||||
use crate::components::{ArticleDetail, DashboardSidebar, NewsCardView, PageHeader};
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::infrastructure::chat::{create_chat_session, save_chat_message};
|
||||
use crate::infrastructure::llm::FollowUpMessage;
|
||||
use crate::models::NewsCard;
|
||||
|
||||
/// Maximum number of recent searches to retain in localStorage.
|
||||
const MAX_RECENT_SEARCHES: usize = 10;
|
||||
|
||||
/// Default search topics shown on the dashboard, inspired by Perplexica.
|
||||
const DEFAULT_TOPICS: &[&str] = &[
|
||||
"AI",
|
||||
"Technology",
|
||||
"Science",
|
||||
"Finance",
|
||||
"Writing",
|
||||
"Research",
|
||||
];
|
||||
|
||||
/// Dashboard page displaying AI news from SearXNG with topic-based filtering,
|
||||
/// a split-view article detail panel, and LLM-powered summarization.
|
||||
///
|
||||
/// State is persisted across sessions using localStorage:
|
||||
/// - `certifai_topics`: custom user-defined search topics
|
||||
/// - `certifai_ollama_url`: Ollama instance URL for summarization
|
||||
/// - `certifai_ollama_model`: Ollama model ID for summarization
|
||||
#[component]
|
||||
pub fn DashboardPage() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
// Persistent state stored in localStorage
|
||||
let mut custom_topics = use_persistent("certifai_topics".to_string(), Vec::<String>::new);
|
||||
// Default to empty so the server functions use OLLAMA_URL / OLLAMA_MODEL
|
||||
// from .env. Only stores a non-empty value when the user explicitly saves
|
||||
// an override via the Settings panel.
|
||||
let mut ollama_url = use_persistent("certifai_ollama_url".to_string(), String::new);
|
||||
let mut ollama_model = use_persistent("certifai_ollama_model".to_string(), String::new);
|
||||
|
||||
// Reactive signals for UI state
|
||||
let mut active_topic = use_signal(|| "AI".to_string());
|
||||
let mut selected_card = use_signal(|| Option::<NewsCard>::None);
|
||||
let mut summary = use_signal(|| Option::<String>::None);
|
||||
let mut is_summarizing = use_signal(|| false);
|
||||
let mut show_add_input = use_signal(|| false);
|
||||
let mut new_topic_text = use_signal(String::new);
|
||||
let mut show_settings = use_signal(|| false);
|
||||
let mut settings_url = use_signal(String::new);
|
||||
let mut settings_model = use_signal(String::new);
|
||||
// Chat follow-up state
|
||||
let mut chat_messages = use_signal(Vec::<FollowUpMessage>::new);
|
||||
let mut is_chatting = use_signal(|| false);
|
||||
// Stores the article text context for the chat system message
|
||||
let mut article_context = use_signal(String::new);
|
||||
// MongoDB session ID for persisting News chat (created on first follow-up)
|
||||
let mut news_session_id: Signal<Option<String>> = use_signal(|| None);
|
||||
|
||||
// Recent search history, persisted in localStorage (capped at MAX_RECENT_SEARCHES)
|
||||
let mut recent_searches =
|
||||
use_persistent("certifai_recent_searches".to_string(), Vec::<String>::new);
|
||||
|
||||
// Build the complete topic list: defaults + custom
|
||||
let all_topics: Vec<String> = {
|
||||
let custom = custom_topics.read();
|
||||
let mut topics: Vec<String> = DEFAULT_TOPICS.iter().map(|s| (*s).to_string()).collect();
|
||||
for t in custom.iter() {
|
||||
if !topics.contains(t) {
|
||||
topics.push(t.clone());
|
||||
}
|
||||
}
|
||||
topics
|
||||
};
|
||||
|
||||
// Fetch trending topics once on mount (no signal deps = runs once).
|
||||
// use_resource handles deduplication and won't re-fetch on re-renders.
|
||||
let trending_resource = use_resource(|| async {
|
||||
match crate::infrastructure::searxng::get_trending_topics().await {
|
||||
Ok(topics) => topics,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch trending topics: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Push a topic to the front of recent searches (deduplicating, capped).
|
||||
// Defined as a closure so it can be called from multiple click handlers.
|
||||
let mut record_search = move |topic: &str| {
|
||||
let mut searches = recent_searches.read().clone();
|
||||
searches.retain(|t| t != topic);
|
||||
searches.insert(0, topic.to_string());
|
||||
searches.truncate(MAX_RECENT_SEARCHES);
|
||||
*recent_searches.write() = searches;
|
||||
};
|
||||
|
||||
// Fetch news reactively when active_topic changes.
|
||||
// use_resource tracks the signal read inside the closure and only
|
||||
// re-fetches when active_topic actually changes -- unlike use_effect
|
||||
// which can re-fire on unrelated re-renders.
|
||||
let search_resource = use_resource(move || {
|
||||
let topic = active_topic.read().clone();
|
||||
async move { crate::infrastructure::searxng::search_topic(topic).await }
|
||||
});
|
||||
|
||||
// Check if an article is selected for split view
|
||||
let has_selection = selected_card.read().is_some();
|
||||
let container_class = if has_selection {
|
||||
"dashboard-split"
|
||||
} else {
|
||||
"dashboard-with-sidebar"
|
||||
};
|
||||
|
||||
// Resolve trending from resource (empty while loading / on error)
|
||||
let trending_topics: Vec<String> = trending_resource
|
||||
.read()
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Resolve search state from resource
|
||||
let search_state = search_resource.read();
|
||||
let is_loading = search_state.is_none();
|
||||
let search_error: Option<String> = search_state
|
||||
.as_ref()
|
||||
.and_then(|r| r.as_ref().err().map(|e| format!("Search failed: {e}")));
|
||||
let news_cards: Vec<NewsCard> = match search_state.as_ref() {
|
||||
Some(Ok(c)) => c.clone(),
|
||||
Some(Err(_)) => crate::components::news_card::mock_news(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
// Drop the borrow before entering rsx! so signals can be written in handlers
|
||||
drop(search_state);
|
||||
|
||||
rsx! {
|
||||
section { class: "dashboard-page",
|
||||
PageHeader {
|
||||
title: t(l, "dashboard.title"),
|
||||
subtitle: t(l, "dashboard.subtitle"),
|
||||
}
|
||||
|
||||
// Topic tabs row
|
||||
div { class: "dashboard-filters",
|
||||
for topic in &all_topics {
|
||||
{
|
||||
let is_active = *active_topic.read() == *topic;
|
||||
let class_name = if is_active {
|
||||
"filter-tab filter-tab--active"
|
||||
} else {
|
||||
"filter-tab"
|
||||
};
|
||||
let is_custom = !DEFAULT_TOPICS.contains(&topic.as_str());
|
||||
let topic_click = topic.clone();
|
||||
let topic_remove = topic.clone();
|
||||
rsx! {
|
||||
div { class: "topic-tab-wrapper",
|
||||
button {
|
||||
class: "{class_name}",
|
||||
onclick: move |_| {
|
||||
record_search(&topic_click);
|
||||
active_topic.set(topic_click.clone());
|
||||
selected_card.set(None);
|
||||
summary.set(None);
|
||||
},
|
||||
"{topic}"
|
||||
}
|
||||
if is_custom {
|
||||
button {
|
||||
class: "topic-remove",
|
||||
onclick: move |_| {
|
||||
let mut topics = custom_topics.read().clone();
|
||||
topics.retain(|t| *t != topic_remove);
|
||||
*custom_topics.write() = topics;
|
||||
// If we removed the active topic, reset
|
||||
if *active_topic.read() == topic_remove {
|
||||
active_topic.set("AI".to_string());
|
||||
}
|
||||
},
|
||||
"x"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add topic button / inline input
|
||||
if *show_add_input.read() {
|
||||
div { class: "topic-input-wrapper",
|
||||
input {
|
||||
class: "topic-input",
|
||||
r#type: "text",
|
||||
placeholder: "{t(l, \"dashboard.topic_placeholder\")}",
|
||||
value: "{new_topic_text}",
|
||||
oninput: move |e| new_topic_text.set(e.value()),
|
||||
onkeypress: move |e| {
|
||||
if e.key() == Key::Enter {
|
||||
let val = new_topic_text.read().trim().to_string();
|
||||
if !val.is_empty() {
|
||||
let mut topics = custom_topics.read().clone();
|
||||
if !topics.contains(&val) && !DEFAULT_TOPICS.contains(&val.as_str()) {
|
||||
topics.push(val.clone());
|
||||
*custom_topics.write() = topics;
|
||||
record_search(&val);
|
||||
active_topic.set(val);
|
||||
}
|
||||
}
|
||||
new_topic_text.set(String::new());
|
||||
show_add_input.set(false);
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "topic-cancel-btn",
|
||||
onclick: move |_| {
|
||||
show_add_input.set(false);
|
||||
new_topic_text.set(String::new());
|
||||
},
|
||||
"{t(l, \"common.cancel\")}"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
button {
|
||||
class: "topic-add-btn",
|
||||
onclick: move |_| show_add_input.set(true),
|
||||
"+"
|
||||
}
|
||||
}
|
||||
|
||||
// Settings toggle
|
||||
button {
|
||||
class: "filter-tab settings-toggle",
|
||||
onclick: move |_| {
|
||||
let currently_shown = *show_settings.read();
|
||||
if !currently_shown {
|
||||
settings_url.set(ollama_url.read().clone());
|
||||
settings_model.set(ollama_model.read().clone());
|
||||
}
|
||||
show_settings.set(!currently_shown);
|
||||
},
|
||||
"{t(l, \"common.settings\")}"
|
||||
}
|
||||
}
|
||||
|
||||
// Settings panel (collapsible)
|
||||
if *show_settings.read() {
|
||||
div { class: "settings-panel",
|
||||
h4 { class: "settings-panel-title", "{t(l, \"dashboard.ollama_settings\")}" }
|
||||
p { class: "settings-hint",
|
||||
"{t(l, \"dashboard.settings_hint\")}"
|
||||
}
|
||||
div { class: "settings-field",
|
||||
label { "{t(l, \"dashboard.ollama_url\")}" }
|
||||
input {
|
||||
class: "settings-input",
|
||||
r#type: "text",
|
||||
placeholder: "{t(l, \"dashboard.ollama_url_placeholder\")}",
|
||||
value: "{settings_url}",
|
||||
oninput: move |e| settings_url.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "settings-field",
|
||||
label { "{t(l, \"dashboard.model\")}" }
|
||||
input {
|
||||
class: "settings-input",
|
||||
r#type: "text",
|
||||
placeholder: "{t(l, \"dashboard.model_placeholder\")}",
|
||||
value: "{settings_model}",
|
||||
oninput: move |e| settings_model.set(e.value()),
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| {
|
||||
*ollama_url.write() = settings_url.read().trim().to_string();
|
||||
*ollama_model.write() = settings_model.read().trim().to_string();
|
||||
show_settings.set(false);
|
||||
},
|
||||
"{t(l, \"common.save\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading / error state
|
||||
if is_loading {
|
||||
div { class: "dashboard-loading", "{t(l, \"dashboard.searching\")}" }
|
||||
}
|
||||
if let Some(ref err) = search_error {
|
||||
div { class: "settings-hint", "{err}" }
|
||||
}
|
||||
|
||||
// Main content area: grid + optional detail panel
|
||||
div { class: "{container_class}",
|
||||
// Left: news grid
|
||||
div { class: if has_selection { "dashboard-left" } else { "dashboard-full-grid" },
|
||||
div { class: if has_selection { "news-grid news-grid--compact" } else { "news-grid" },
|
||||
for card in news_cards.iter() {
|
||||
{
|
||||
let is_selected = selected_card
|
||||
|
||||
// Auto-summarize on card selection
|
||||
.read()
|
||||
// Store context for follow-up chat
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.url == card.url && s.title == card.title);
|
||||
rsx! {
|
||||
NewsCardView {
|
||||
key: "{card.title}-{card.url}",
|
||||
card: card.clone(),
|
||||
selected: is_selected,
|
||||
on_click: move |c: NewsCard| {
|
||||
let snippet = c.content.clone();
|
||||
let article_url = c.url.clone();
|
||||
selected_card.set(Some(c));
|
||||
summary.set(None);
|
||||
chat_messages.set(Vec::new());
|
||||
article_context.set(String::new());
|
||||
news_session_id.set(None);
|
||||
|
||||
|
||||
let oll_url = ollama_url.read().clone();
|
||||
let mdl = ollama_model.read().clone();
|
||||
spawn(async move {
|
||||
is_summarizing.set(true);
|
||||
match crate::infrastructure::llm::summarize_article(
|
||||
snippet.clone(),
|
||||
article_url,
|
||||
oll_url,
|
||||
mdl,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(text) => {
|
||||
article_context
|
||||
.set(
|
||||
format!(
|
||||
"Article content:\n{snippet}\n\n\
|
||||
AI Summary:\n{text}",
|
||||
),
|
||||
);
|
||||
summary.set(Some(text));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Summarization failed: {e}");
|
||||
summary.set(Some(format!("Summarization failed: {e}")));
|
||||
}
|
||||
}
|
||||
is_summarizing.set(false);
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right: article detail panel (when card selected)
|
||||
if let Some(ref card) = *selected_card.read() {
|
||||
div { class: "dashboard-right",
|
||||
ArticleDetail {
|
||||
card: card.clone(),
|
||||
on_close: move |_| {
|
||||
selected_card.set(None);
|
||||
summary.set(None);
|
||||
chat_messages.set(Vec::new());
|
||||
news_session_id.set(None);
|
||||
},
|
||||
summary: summary.read().clone(),
|
||||
is_summarizing: *is_summarizing.read(),
|
||||
chat_messages: chat_messages.read().clone(),
|
||||
is_chatting: *is_chatting.read(),
|
||||
on_chat_send: move |question: String| {
|
||||
let oll_url = ollama_url.read().clone();
|
||||
let mdl = ollama_model.read().clone();
|
||||
let ctx = article_context.read().clone();
|
||||
// Capture article info for News session creation
|
||||
let card_title = selected_card
|
||||
.read()
|
||||
.as_ref()
|
||||
.map(|c| c.title.clone())
|
||||
.unwrap_or_default();
|
||||
let card_url = selected_card
|
||||
.read()
|
||||
.as_ref()
|
||||
.map(|c| c.url.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Append user message to local chat
|
||||
chat_messages.write().push(FollowUpMessage {
|
||||
role: "user".into(),
|
||||
content: question.clone(),
|
||||
});
|
||||
|
||||
// Build full message history for Ollama
|
||||
let system_msg = format!(
|
||||
"You are a helpful assistant. The user is reading \
|
||||
a news article. Use the following context to answer \
|
||||
their questions. Do NOT comment on the source, \
|
||||
dates, URLs, or formatting.\n\n{ctx}",
|
||||
);
|
||||
let msgs = {
|
||||
let history = chat_messages.read();
|
||||
let mut all = vec![FollowUpMessage {
|
||||
role: "system".into(),
|
||||
content: system_msg.clone(),
|
||||
}];
|
||||
all.extend(history.iter().cloned());
|
||||
all
|
||||
};
|
||||
|
||||
spawn(async move {
|
||||
is_chatting.set(true);
|
||||
|
||||
// Create News session on first follow-up message
|
||||
let existing_sid = news_session_id.read().clone();
|
||||
let sid = if let Some(id) = existing_sid {
|
||||
id
|
||||
} else {
|
||||
match create_chat_session(
|
||||
card_title,
|
||||
"News".to_string(),
|
||||
"ollama".to_string(),
|
||||
mdl.clone(),
|
||||
card_url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(session) => {
|
||||
let id = session.id.clone();
|
||||
news_session_id.set(Some(id.clone()));
|
||||
// Persist system context as first message
|
||||
let _ = save_chat_message(
|
||||
id.clone(),
|
||||
"system".to_string(),
|
||||
system_msg,
|
||||
)
|
||||
.await;
|
||||
id
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create News session: {e}");
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Persist user message
|
||||
if !sid.is_empty() {
|
||||
let _ = save_chat_message(
|
||||
sid.clone(),
|
||||
"user".to_string(),
|
||||
question,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
match crate::infrastructure::llm::chat_followup(
|
||||
msgs, oll_url, mdl,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reply) => {
|
||||
// Persist assistant message
|
||||
if !sid.is_empty() {
|
||||
let _ = save_chat_message(
|
||||
sid,
|
||||
"assistant".to_string(),
|
||||
reply.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
chat_messages.write().push(FollowUpMessage {
|
||||
role: "assistant".into(),
|
||||
content: reply,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Chat failed: {e}");
|
||||
chat_messages.write().push(FollowUpMessage {
|
||||
role: "assistant".into(),
|
||||
content: format!("Error: {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
is_chatting.set(false);
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right: sidebar (when no card selected)
|
||||
if !has_selection {
|
||||
DashboardSidebar {
|
||||
ollama_url: ollama_url.read().clone(),
|
||||
trending: trending_topics.clone(),
|
||||
recent_searches: recent_searches.read().clone(),
|
||||
on_topic_click: move |topic: String| {
|
||||
record_search(&topic);
|
||||
active_topic.set(topic);
|
||||
selected_card.set(None);
|
||||
summary.set(None);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
|
||||
/// 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 {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "A" }
|
||||
h2 { "{t(l, \"developer.agents_title\")}" }
|
||||
p { class: "placeholder-desc",
|
||||
"{t(l, \"developer.agents_desc\")}"
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_agents\")}" }
|
||||
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
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 locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
let metrics = mock_metrics(l);
|
||||
|
||||
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 { "{t(l, \"developer.analytics_title\")}" }
|
||||
p { class: "placeholder-desc",
|
||||
"{t(l, \"developer.analytics_desc\")}"
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_analytics\")}" }
|
||||
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock analytics metrics for the stats bar.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `locale` - The current locale for translating metric labels
|
||||
fn mock_metrics(locale: Locale) -> Vec<AnalyticsMetric> {
|
||||
vec![
|
||||
AnalyticsMetric {
|
||||
label: t(locale, "developer.total_requests"),
|
||||
value: "12,847".into(),
|
||||
change_pct: 14.2,
|
||||
},
|
||||
AnalyticsMetric {
|
||||
label: t(locale, "developer.avg_latency"),
|
||||
value: "245ms".into(),
|
||||
change_pct: -8.5,
|
||||
},
|
||||
AnalyticsMetric {
|
||||
label: t(locale, "developer.tokens_used"),
|
||||
value: "2.4M".into(),
|
||||
change_pct: 22.1,
|
||||
},
|
||||
AnalyticsMetric {
|
||||
label: t(locale, "developer.error_rate"),
|
||||
value: "0.3%".into(),
|
||||
change_pct: -12.0,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
|
||||
/// 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 {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "F" }
|
||||
h2 { "{t(l, \"developer.flow_title\")}" }
|
||||
p { class: "placeholder-desc",
|
||||
"{t(l, \"developer.flow_desc\")}"
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_flow\")}" }
|
||||
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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};
|
||||
use crate::i18n::{t, Locale};
|
||||
|
||||
/// 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 locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
let tabs = vec![
|
||||
SubNavItem {
|
||||
label: t(l, "nav.agents"),
|
||||
route: Route::AgentsPage {},
|
||||
},
|
||||
SubNavItem {
|
||||
label: t(l, "nav.flow"),
|
||||
route: Route::FlowPage {},
|
||||
},
|
||||
SubNavItem {
|
||||
label: t(l, "nav.analytics"),
|
||||
route: Route::AnalyticsPage {},
|
||||
},
|
||||
];
|
||||
|
||||
rsx! {
|
||||
div { class: "developer-shell",
|
||||
SubNav { items: tabs }
|
||||
div { class: "shell-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::Route;
|
||||
|
||||
/// Impressum (legal notice) page required by German/EU law.
|
||||
@@ -11,9 +10,6 @@ use crate::Route;
|
||||
/// accessible without authentication.
|
||||
#[component]
|
||||
pub fn ImpressumPage() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
rsx! {
|
||||
div { class: "legal-page",
|
||||
nav { class: "legal-nav",
|
||||
@@ -25,53 +21,53 @@ pub fn ImpressumPage() -> Element {
|
||||
}
|
||||
}
|
||||
main { class: "legal-content",
|
||||
h1 { "{t(l, \"impressum.title\")}" }
|
||||
h1 { "Impressum" }
|
||||
|
||||
h2 { "{t(l, \"impressum.info_tmg\")}" }
|
||||
h2 { "Information according to 5 TMG" }
|
||||
p {
|
||||
"{t(l, \"impressum.company\")}"
|
||||
"CERTifAI GmbH"
|
||||
br {}
|
||||
"{t(l, \"impressum.address_street\")}"
|
||||
"Musterstrasse 1"
|
||||
br {}
|
||||
"{t(l, \"impressum.address_city\")}"
|
||||
"10115 Berlin"
|
||||
br {}
|
||||
"{t(l, \"impressum.address_country\")}"
|
||||
"Germany"
|
||||
}
|
||||
|
||||
h2 { "{t(l, \"impressum.represented_by\")}" }
|
||||
p { "{t(l, \"impressum.managing_director\")}" }
|
||||
h2 { "Represented by" }
|
||||
p { "Managing Director: [Name]" }
|
||||
|
||||
h2 { "{t(l, \"impressum.contact\")}" }
|
||||
h2 { "Contact" }
|
||||
p {
|
||||
"{t(l, \"impressum.email\")}"
|
||||
"Email: info@certifai.example"
|
||||
br {}
|
||||
"{t(l, \"impressum.phone\")}"
|
||||
"Phone: +49 (0) 30 1234567"
|
||||
}
|
||||
|
||||
h2 { "{t(l, \"impressum.commercial_register\")}" }
|
||||
h2 { "Commercial Register" }
|
||||
p {
|
||||
"{t(l, \"impressum.registered_at\")}"
|
||||
"Registered at: Amtsgericht Berlin-Charlottenburg"
|
||||
br {}
|
||||
"{t(l, \"impressum.registration_number\")}"
|
||||
"Registration number: HRB XXXXXX"
|
||||
}
|
||||
|
||||
h2 { "{t(l, \"impressum.vat_id\")}" }
|
||||
p { "{t(l, \"impressum.vat_number\")}" }
|
||||
h2 { "VAT ID" }
|
||||
p { "VAT identification number according to 27a UStG: DE XXXXXXXXX" }
|
||||
|
||||
h2 { "{t(l, \"impressum.responsible_content\")}" }
|
||||
h2 { "Responsible for content according to 55 Abs. 2 RStV" }
|
||||
p {
|
||||
"[Name]"
|
||||
br {}
|
||||
"{t(l, \"impressum.company\")}"
|
||||
"CERTifAI GmbH"
|
||||
br {}
|
||||
"{t(l, \"impressum.address_street\")}"
|
||||
"Musterstrasse 1"
|
||||
br {}
|
||||
"{t(l, \"impressum.address_city\")}"
|
||||
"10115 Berlin"
|
||||
}
|
||||
}
|
||||
footer { class: "legal-footer",
|
||||
Link { to: Route::LandingPage {}, "{t(l, \"common.back_to_home\")}" }
|
||||
Link { to: Route::PrivacyPage {}, "{t(l, \"common.privacy_policy\")}" }
|
||||
Link { to: Route::LandingPage {}, "Back to Home" }
|
||||
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use dioxus_free_icons::icons::bs_icons::{
|
||||
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::Route;
|
||||
|
||||
/// Public landing page for the CERTifAI platform.
|
||||
@@ -31,9 +30,6 @@ pub fn LandingPage() -> Element {
|
||||
/// Sticky top navigation bar with logo, nav links, and CTA buttons.
|
||||
#[component]
|
||||
fn LandingNav() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
rsx! {
|
||||
nav { class: "landing-nav",
|
||||
div { class: "landing-nav-inner",
|
||||
@@ -44,24 +40,20 @@ fn LandingNav() -> Element {
|
||||
span { "CERTifAI" }
|
||||
}
|
||||
div { class: "landing-nav-links",
|
||||
a { href: "#features", {t(l, "common.features")} }
|
||||
a { href: "#how-it-works", {t(l, "common.how_it_works")} }
|
||||
a { href: "#pricing", {t(l, "nav.pricing")} }
|
||||
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(),
|
||||
},
|
||||
to: Route::Login { redirect_url: "/dashboard".into() },
|
||||
class: "btn btn-ghost btn-sm",
|
||||
{t(l, "common.log_in")}
|
||||
"Log In"
|
||||
}
|
||||
Link {
|
||||
to: Route::Login {
|
||||
redirect_url: "/dashboard".into(),
|
||||
},
|
||||
to: Route::Login { redirect_url: "/dashboard".into() },
|
||||
class: "btn btn-primary btn-sm",
|
||||
{t(l, "common.get_started")}
|
||||
"Get Started"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,32 +64,33 @@ fn LandingNav() -> Element {
|
||||
/// Hero section with headline, subtitle, and CTA buttons.
|
||||
#[component]
|
||||
fn HeroSection() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
rsx! {
|
||||
section { class: "hero-section",
|
||||
div { class: "hero-content",
|
||||
div { class: "hero-badge badge badge-outline", {t(l, "landing.badge")} }
|
||||
div { class: "hero-badge badge badge-outline",
|
||||
"Privacy-First GenAI Infrastructure"
|
||||
}
|
||||
h1 { class: "hero-title",
|
||||
{t(l, "landing.hero_title_1")}
|
||||
"Your AI. Your Data."
|
||||
br {}
|
||||
span { class: "hero-title-accent", {t(l, "landing.hero_title_2")} }
|
||||
span { class: "hero-title-accent", "Your Infrastructure." }
|
||||
}
|
||||
p { class: "hero-subtitle",
|
||||
{t(l, "landing.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(),
|
||||
},
|
||||
to: Route::Login { redirect_url: "/dashboard".into() },
|
||||
class: "btn btn-primary btn-lg",
|
||||
{t(l, "common.get_started")}
|
||||
"Get Started"
|
||||
Icon { icon: BsArrowRight, width: 18, height: 18 }
|
||||
}
|
||||
a { href: "#features", class: "btn btn-outline btn-lg",
|
||||
{t(l, "landing.learn_more")}
|
||||
a {
|
||||
href: "#features",
|
||||
class: "btn btn-outline btn-lg",
|
||||
"Learn More"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,44 +105,27 @@ fn HeroSection() -> Element {
|
||||
defs {
|
||||
linearGradient {
|
||||
id: "grad1",
|
||||
x1: "0%",
|
||||
y1: "0%",
|
||||
x2: "100%",
|
||||
y2: "100%",
|
||||
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%",
|
||||
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)",
|
||||
}
|
||||
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)",
|
||||
}
|
||||
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 \
|
||||
@@ -169,98 +145,36 @@ fn HeroSection() -> Element {
|
||||
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",
|
||||
}
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
x1: "200", y1: "180", x2: "270", y2: "170",
|
||||
stroke: "#91a4d2", stroke_width: "1", opacity: "0.3",
|
||||
}
|
||||
// Checkmark inside shield center
|
||||
path {
|
||||
@@ -280,34 +194,31 @@ fn HeroSection() -> Element {
|
||||
/// Social proof / trust indicator strip.
|
||||
#[component]
|
||||
fn SocialProof() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
rsx! {
|
||||
section { class: "social-proof",
|
||||
p { class: "social-proof-text",
|
||||
{t(l, "landing.social_proof")}
|
||||
span { class: "social-proof-highlight", {t(l, "landing.data_sovereignty")} }
|
||||
"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", {t(l, "landing.on_premise")} }
|
||||
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", {t(l, "landing.compliant")} }
|
||||
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", {t(l, "landing.data_residency")} }
|
||||
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", {t(l, "landing.third_party")} }
|
||||
span { class: "proof-stat-label", "Third-Party Sharing" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,57 +228,48 @@ fn SocialProof() -> Element {
|
||||
/// Feature cards grid section.
|
||||
#[component]
|
||||
fn FeaturesGrid() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
rsx! {
|
||||
section { id: "features", class: "features-section",
|
||||
h2 { class: "section-title", {t(l, "landing.features_title")} }
|
||||
h2 { class: "section-title", "Everything You Need" }
|
||||
p { class: "section-subtitle",
|
||||
{t(l, "landing.features_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: t(l, "landing.feat_infra_title"),
|
||||
description: t(l, "landing.feat_infra_desc"),
|
||||
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: t(l, "landing.feat_gdpr_title"),
|
||||
description: t(l, "landing.feat_gdpr_desc"),
|
||||
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: t(l, "landing.feat_llm_title"),
|
||||
description: t(l, "landing.feat_llm_desc"),
|
||||
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: t(l, "landing.feat_agent_title"),
|
||||
description: t(l, "landing.feat_agent_desc"),
|
||||
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: t(l, "landing.feat_mcp_title"),
|
||||
description: t(l, "landing.feat_mcp_desc"),
|
||||
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: t(l, "landing.feat_api_title"),
|
||||
description: t(l, "landing.feat_api_desc"),
|
||||
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.",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,10 +281,10 @@ fn FeaturesGrid() -> Element {
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `icon` - The icon element to display
|
||||
/// * `title` - Feature title (owned String from translation lookup)
|
||||
/// * `description` - Feature description text (owned String from translation lookup)
|
||||
/// * `title` - Feature title
|
||||
/// * `description` - Feature description text
|
||||
#[component]
|
||||
fn FeatureCard(icon: Element, title: String, description: String) -> Element {
|
||||
fn FeatureCard(icon: Element, title: &'static str, description: &'static str) -> Element {
|
||||
rsx! {
|
||||
div { class: "card feature-card",
|
||||
div { class: "feature-card-icon", {icon} }
|
||||
@@ -395,28 +297,33 @@ fn FeatureCard(icon: Element, title: String, description: String) -> Element {
|
||||
/// Three-step "How It Works" section.
|
||||
#[component]
|
||||
fn HowItWorks() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
rsx! {
|
||||
section { id: "how-it-works", class: "how-it-works-section",
|
||||
h2 { class: "section-title", {t(l, "landing.how_title")} }
|
||||
p { class: "section-subtitle", {t(l, "landing.how_subtitle")} }
|
||||
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: t(l, "landing.step_deploy"),
|
||||
description: t(l, "landing.step_deploy_desc"),
|
||||
title: "Deploy",
|
||||
description: "Install CERTifAI on your infrastructure \
|
||||
with a single command. Supports Docker, Kubernetes, \
|
||||
and bare metal.",
|
||||
}
|
||||
StepCard {
|
||||
number: "02",
|
||||
title: t(l, "landing.step_configure"),
|
||||
description: t(l, "landing.step_configure_desc"),
|
||||
title: "Configure",
|
||||
description: "Connect your identity provider, select \
|
||||
your models, and set up team permissions through \
|
||||
the admin dashboard.",
|
||||
}
|
||||
StepCard {
|
||||
number: "03",
|
||||
title: t(l, "landing.step_scale"),
|
||||
description: t(l, "landing.step_scale_desc"),
|
||||
title: "Scale",
|
||||
description: "Add users, deploy more models, and \
|
||||
integrate with your existing tools via API keys \
|
||||
and MCP servers.",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,10 +335,10 @@ fn HowItWorks() -> Element {
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `number` - Step number string (e.g. "01")
|
||||
/// * `title` - Step title (owned String from translation lookup)
|
||||
/// * `description` - Step description text (owned String from translation lookup)
|
||||
/// * `title` - Step title
|
||||
/// * `description` - Step description text
|
||||
#[component]
|
||||
fn StepCard(number: &'static str, title: String, description: String) -> Element {
|
||||
fn StepCard(number: &'static str, title: &'static str, description: &'static str) -> Element {
|
||||
rsx! {
|
||||
div { class: "step-card",
|
||||
span { class: "step-number", "{number}" }
|
||||
@@ -444,30 +351,25 @@ fn StepCard(number: &'static str, title: String, description: String) -> Element
|
||||
/// Call-to-action banner before the footer.
|
||||
#[component]
|
||||
fn CtaBanner() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
rsx! {
|
||||
section { class: "cta-banner",
|
||||
h2 { class: "cta-title", {t(l, "landing.cta_title")} }
|
||||
h2 { class: "cta-title",
|
||||
"Ready to take control of your AI infrastructure?"
|
||||
}
|
||||
p { class: "cta-subtitle",
|
||||
{t(l, "landing.cta_subtitle")}
|
||||
"Start deploying sovereign GenAI today. No credit card required."
|
||||
}
|
||||
div { class: "cta-actions",
|
||||
Link {
|
||||
to: Route::Login {
|
||||
redirect_url: "/dashboard".into(),
|
||||
},
|
||||
to: Route::Login { redirect_url: "/dashboard".into() },
|
||||
class: "btn btn-primary btn-lg",
|
||||
{t(l, "landing.get_started_free")}
|
||||
"Get Started Free"
|
||||
Icon { icon: BsArrowRight, width: 18, height: 18 }
|
||||
}
|
||||
Link {
|
||||
to: Route::Login {
|
||||
redirect_url: "/dashboard".into(),
|
||||
},
|
||||
to: Route::Login { redirect_url: "/dashboard".into() },
|
||||
class: "btn btn-outline btn-lg",
|
||||
{t(l, "common.log_in")}
|
||||
"Log In"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,9 +379,6 @@ fn CtaBanner() -> Element {
|
||||
/// Landing page footer with links and copyright.
|
||||
#[component]
|
||||
fn LandingFooter() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
rsx! {
|
||||
footer { class: "landing-footer",
|
||||
div { class: "landing-footer-inner",
|
||||
@@ -490,28 +389,30 @@ fn LandingFooter() -> Element {
|
||||
}
|
||||
span { "CERTifAI" }
|
||||
}
|
||||
p { class: "footer-tagline", {t(l, "landing.footer_tagline")} }
|
||||
p { class: "footer-tagline",
|
||||
"Sovereign GenAI infrastructure for enterprises."
|
||||
}
|
||||
}
|
||||
div { class: "footer-links-group",
|
||||
h4 { class: "footer-links-heading", {t(l, "landing.product")} }
|
||||
a { href: "#features", {t(l, "common.features")} }
|
||||
a { href: "#how-it-works", {t(l, "common.how_it_works")} }
|
||||
a { href: "#pricing", {t(l, "nav.pricing")} }
|
||||
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", {t(l, "landing.legal")} }
|
||||
Link { to: Route::ImpressumPage {}, {t(l, "common.impressum")} }
|
||||
Link { to: Route::PrivacyPage {}, {t(l, "common.privacy_policy")} }
|
||||
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", {t(l, "landing.resources")} }
|
||||
a { href: "#", {t(l, "landing.documentation")} }
|
||||
a { href: "#", {t(l, "landing.api_reference")} }
|
||||
a { href: "#", {t(l, "landing.support")} }
|
||||
h4 { class: "footer-links-heading", "Resources" }
|
||||
a { href: "#", "Documentation" }
|
||||
a { href: "#", "API Reference" }
|
||||
a { href: "#", "Support" }
|
||||
}
|
||||
}
|
||||
div { class: "footer-bottom",
|
||||
p { {t(l, "landing.copyright")} }
|
||||
p { "2026 CERTifAI. All rights reserved." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
mod dashboard;
|
||||
pub mod developer;
|
||||
mod impressum;
|
||||
mod landing;
|
||||
pub mod organization;
|
||||
mod overview;
|
||||
mod privacy;
|
||||
mod providers;
|
||||
|
||||
pub use dashboard::*;
|
||||
pub use developer::*;
|
||||
pub use impressum::*;
|
||||
pub use landing::*;
|
||||
pub use organization::*;
|
||||
pub use overview::*;
|
||||
pub use privacy::*;
|
||||
pub use providers::*;
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{MemberRow, PageHeader};
|
||||
use crate::i18n::{t, tw, Locale};
|
||||
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 locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
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: t(l, "org.title"),
|
||||
subtitle: t(l, "org.subtitle"),
|
||||
actions: rsx! {
|
||||
button { class: "btn-primary", onclick: move |_| show_invite.set(true), {t(l, "org.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", {t(l, "org.seats_used")} }
|
||||
}
|
||||
div { class: "org-stat",
|
||||
span { class: "org-stat-value", "{tokens_display}" }
|
||||
span { class: "org-stat-label", {tw(l, "org.of_tokens", &[("limit", &tokens_limit_display)])} }
|
||||
}
|
||||
div { class: "org-stat",
|
||||
span { class: "org-stat-value", "{usage.billing_cycle_end}" }
|
||||
span { class: "org-stat-label", {t(l, "org.cycle_ends")} }
|
||||
}
|
||||
}
|
||||
|
||||
// Members table
|
||||
div { class: "org-table-wrapper",
|
||||
table { class: "org-table",
|
||||
thead {
|
||||
tr {
|
||||
th { {t(l, "org.name")} }
|
||||
th { {t(l, "org.email")} }
|
||||
th { {t(l, "org.role")} }
|
||||
th { {t(l, "org.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 { {t(l, "org.invite_title")} }
|
||||
div { class: "form-group",
|
||||
label { {t(l, "org.email_address")} }
|
||||
input {
|
||||
class: "form-input",
|
||||
r#type: "email",
|
||||
placeholder: t(l, "org.email_placeholder"),
|
||||
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),
|
||||
{t(l, "common.cancel")}
|
||||
}
|
||||
button {
|
||||
class: "btn-primary",
|
||||
onclick: move |_| show_invite.set(false),
|
||||
{t(l, "org.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(),
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
mod dashboard;
|
||||
mod pricing;
|
||||
|
||||
pub use dashboard::*;
|
||||
pub use pricing::*;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::sub_nav::{SubNav, SubNavItem};
|
||||
use crate::i18n::{t, Locale};
|
||||
|
||||
/// 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 locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
let tabs = vec![
|
||||
SubNavItem {
|
||||
label: t(l, "nav.pricing"),
|
||||
route: Route::OrgPricingPage {},
|
||||
},
|
||||
SubNavItem {
|
||||
label: t(l, "nav.dashboard"),
|
||||
route: Route::OrgDashboardPage {},
|
||||
},
|
||||
];
|
||||
|
||||
rsx! {
|
||||
div { class: "org-shell",
|
||||
SubNav { items: tabs }
|
||||
div { class: "shell-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::{PageHeader, PricingCard};
|
||||
use crate::i18n::{t, tw, Locale};
|
||||
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 locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
let navigator = use_navigator();
|
||||
let plans = mock_plans(l);
|
||||
|
||||
rsx! {
|
||||
section { class: "pricing-page",
|
||||
PageHeader {
|
||||
title: t(l, "org.pricing_title"),
|
||||
subtitle: t(l, "org.pricing_subtitle"),
|
||||
}
|
||||
div { class: "pricing-grid",
|
||||
for plan in plans {
|
||||
PricingCard {
|
||||
key: "{plan.id}",
|
||||
plan,
|
||||
on_select: move |_| {
|
||||
navigator.push(Route::OrgDashboardPage {});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock pricing plans with translated names and features.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `l` - The active locale for translating user-facing plan text
|
||||
fn mock_plans(l: Locale) -> Vec<PricingPlan> {
|
||||
vec![
|
||||
PricingPlan {
|
||||
id: "starter".into(),
|
||||
name: t(l, "pricing.starter"),
|
||||
price_eur: 49,
|
||||
features: vec![
|
||||
tw(l, "pricing.up_to_users", &[("n", "5")]),
|
||||
t(l, "pricing.llm_provider_1"),
|
||||
t(l, "pricing.tokens_100k"),
|
||||
t(l, "pricing.community_support"),
|
||||
t(l, "pricing.basic_analytics"),
|
||||
],
|
||||
highlighted: false,
|
||||
max_seats: Some(5),
|
||||
},
|
||||
PricingPlan {
|
||||
id: "team".into(),
|
||||
name: t(l, "pricing.team"),
|
||||
price_eur: 199,
|
||||
features: vec![
|
||||
tw(l, "pricing.up_to_users", &[("n", "25")]),
|
||||
t(l, "pricing.all_providers"),
|
||||
t(l, "pricing.tokens_1m"),
|
||||
t(l, "pricing.priority_support"),
|
||||
t(l, "pricing.advanced_analytics"),
|
||||
t(l, "pricing.custom_mcp"),
|
||||
t(l, "pricing.sso"),
|
||||
],
|
||||
highlighted: true,
|
||||
max_seats: Some(25),
|
||||
},
|
||||
PricingPlan {
|
||||
id: "enterprise".into(),
|
||||
name: t(l, "pricing.enterprise"),
|
||||
price_eur: 499,
|
||||
features: vec![
|
||||
t(l, "pricing.unlimited_users"),
|
||||
t(l, "pricing.all_providers"),
|
||||
t(l, "pricing.unlimited_tokens"),
|
||||
t(l, "pricing.dedicated_support"),
|
||||
t(l, "pricing.full_observability"),
|
||||
t(l, "pricing.custom_integrations"),
|
||||
t(l, "pricing.sla"),
|
||||
t(l, "pricing.on_premise"),
|
||||
],
|
||||
highlighted: false,
|
||||
max_seats: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
102
src/pages/overview.rs
Normal file
102
src/pages/overview.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::BsBook;
|
||||
use dioxus_free_icons::icons::fa_solid_icons::{FaChartLine, FaCubes, FaGears};
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::components::DashboardCard;
|
||||
use crate::Route;
|
||||
|
||||
/// Overview dashboard page rendered inside the `AppShell` layout.
|
||||
///
|
||||
/// Displays a welcome heading and a grid of quick-access cards
|
||||
/// for the main GenAI platform tools.
|
||||
#[component]
|
||||
pub fn OverviewPage() -> Element {
|
||||
// Check authentication status on mount via a server function.
|
||||
let auth_check = use_resource(check_auth);
|
||||
let navigator = use_navigator();
|
||||
|
||||
// Once the server responds, redirect unauthenticated users to /auth.
|
||||
use_effect(move || {
|
||||
if let Some(Ok(false)) = auth_check() {
|
||||
navigator.push(NavigationTarget::<Route>::External(
|
||||
"/auth?redirect_url=/dashboard".into(),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
match auth_check() {
|
||||
// Still waiting for the server to respond.
|
||||
None => rsx! {},
|
||||
// Not authenticated -- render nothing while the redirect fires.
|
||||
Some(Ok(false)) => rsx! {},
|
||||
// Authenticated -- render the overview dashboard.
|
||||
Some(Ok(true)) => rsx! {
|
||||
section { class: "overview-page",
|
||||
h1 { class: "overview-heading", "GenAI Dashboard" }
|
||||
div { class: "dashboard-grid",
|
||||
DashboardCard {
|
||||
title: "Documentation".to_string(),
|
||||
description: "Guides & API Reference".to_string(),
|
||||
href: "#".to_string(),
|
||||
icon: rsx! {
|
||||
Icon { icon: BsBook, width: 28, height: 28 }
|
||||
},
|
||||
}
|
||||
DashboardCard {
|
||||
title: "Langfuse".to_string(),
|
||||
description: "Observability & Analytics".to_string(),
|
||||
href: "#".to_string(),
|
||||
icon: rsx! {
|
||||
Icon { icon: FaChartLine, width: 28, height: 28 }
|
||||
},
|
||||
}
|
||||
DashboardCard {
|
||||
title: "Langchain".to_string(),
|
||||
description: "Agent Framework".to_string(),
|
||||
href: "#".to_string(),
|
||||
icon: rsx! {
|
||||
Icon { icon: FaGears, width: 28, height: 28 }
|
||||
},
|
||||
}
|
||||
DashboardCard {
|
||||
title: "Hugging Face".to_string(),
|
||||
description: "Browse Models".to_string(),
|
||||
href: "#".to_string(),
|
||||
icon: rsx! {
|
||||
Icon { icon: FaCubes, width: 28, height: 28 }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Server error -- surface it so it is not silently swallowed.
|
||||
Some(Err(err)) => rsx! {
|
||||
p { "Error: {err}" }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the current request has an active logged-in session.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` if the session contains a logged-in user, `false` otherwise.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the session cannot be extracted from the request.
|
||||
#[server]
|
||||
async fn check_auth() -> Result<bool, ServerFnError> {
|
||||
use crate::infrastructure::{UserStateInner, LOGGED_IN_USER_SESS_KEY};
|
||||
use tower_sessions::Session;
|
||||
|
||||
// Extract the tower_sessions::Session from the Axum request.
|
||||
let session: Session = FullstackContext::extract().await?;
|
||||
let user: Option<UserStateInner> = session
|
||||
.get(LOGGED_IN_USER_SESS_KEY)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
|
||||
|
||||
Ok(user.is_some())
|
||||
}
|
||||
@@ -2,7 +2,6 @@ use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::i18n::{t, Locale};
|
||||
use crate::Route;
|
||||
|
||||
/// Privacy Policy page.
|
||||
@@ -11,9 +10,6 @@ use crate::Route;
|
||||
/// without authentication.
|
||||
#[component]
|
||||
pub fn PrivacyPage() -> Element {
|
||||
let locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
rsx! {
|
||||
div { class: "legal-page",
|
||||
nav { class: "legal-nav",
|
||||
@@ -25,66 +21,89 @@ pub fn PrivacyPage() -> Element {
|
||||
}
|
||||
}
|
||||
main { class: "legal-content",
|
||||
h1 { "{t(l, \"privacy.title\")}" }
|
||||
p { class: "legal-updated", "{t(l, \"privacy.last_updated\")}" }
|
||||
h1 { "Privacy Policy" }
|
||||
p { class: "legal-updated",
|
||||
"Last updated: February 2026"
|
||||
}
|
||||
|
||||
h2 { "{t(l, \"privacy.intro_title\")}" }
|
||||
p { "{t(l, \"privacy.intro_text\")}" }
|
||||
|
||||
h2 { "{t(l, \"privacy.controller_title\")}" }
|
||||
h2 { "1. Introduction" }
|
||||
p {
|
||||
"{t(l, \"impressum.company\")}"
|
||||
"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 {}
|
||||
"{t(l, \"privacy.controller_address\")}"
|
||||
"Musterstrasse 1, 10115 Berlin, Germany"
|
||||
br {}
|
||||
"{t(l, \"privacy.controller_email\")}"
|
||||
"Email: privacy@certifai.example"
|
||||
}
|
||||
|
||||
h2 { "{t(l, \"privacy.data_title\")}" }
|
||||
p { "{t(l, \"privacy.data_intro\")}" }
|
||||
h2 { "3. Data We Collect" }
|
||||
p {
|
||||
"We collect only the minimum data necessary to provide "
|
||||
"our services:"
|
||||
}
|
||||
ul {
|
||||
li {
|
||||
strong { "{t(l, \"privacy.data_account_label\")}" }
|
||||
"{t(l, \"privacy.data_account_text\")}"
|
||||
strong { "Account data: " }
|
||||
"Name, email address, and organization details "
|
||||
"provided during registration."
|
||||
}
|
||||
li {
|
||||
strong { "{t(l, \"privacy.data_usage_label\")}" }
|
||||
"{t(l, \"privacy.data_usage_text\")}"
|
||||
strong { "Usage data: " }
|
||||
"API call logs, token counts, and feature usage "
|
||||
"metrics for billing and analytics."
|
||||
}
|
||||
li {
|
||||
strong { "{t(l, \"privacy.data_technical_label\")}" }
|
||||
"{t(l, \"privacy.data_technical_text\")}"
|
||||
strong { "Technical data: " }
|
||||
"IP addresses, browser type, and session identifiers "
|
||||
"for security and platform stability."
|
||||
}
|
||||
}
|
||||
|
||||
h2 { "{t(l, \"privacy.use_title\")}" }
|
||||
h2 { "4. How We Use Your Data" }
|
||||
ul {
|
||||
li { "{t(l, \"privacy.use_1\")}" }
|
||||
li { "{t(l, \"privacy.use_2\")}" }
|
||||
li { "{t(l, \"privacy.use_3\")}" }
|
||||
li { "{t(l, \"privacy.use_4\")}" }
|
||||
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 { "{t(l, \"privacy.storage_title\")}" }
|
||||
p { "{t(l, \"privacy.storage_text\")}" }
|
||||
|
||||
h2 { "{t(l, \"privacy.rights_title\")}" }
|
||||
p { "{t(l, \"privacy.rights_intro\")}" }
|
||||
ul {
|
||||
li { "{t(l, \"privacy.rights_access\")}" }
|
||||
li { "{t(l, \"privacy.rights_rectify\")}" }
|
||||
li { "{t(l, \"privacy.rights_erasure\")}" }
|
||||
li { "{t(l, \"privacy.rights_restrict\")}" }
|
||||
li { "{t(l, \"privacy.rights_portability\")}" }
|
||||
li { "{t(l, \"privacy.rights_complaint\")}" }
|
||||
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 { "{t(l, \"privacy.contact_title\")}" }
|
||||
p { "{t(l, \"privacy.contact_text\")}" }
|
||||
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 {}, "{t(l, \"common.back_to_home\")}" }
|
||||
Link { to: Route::ImpressumPage {}, "{t(l, \"common.impressum\")}" }
|
||||
Link { to: Route::LandingPage {}, "Back to Home" }
|
||||
Link { to: Route::ImpressumPage {}, "Impressum" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::PageHeader;
|
||||
use crate::i18n::{t, Locale};
|
||||
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 locale = use_context::<Signal<Locale>>();
|
||||
let l = *locale.read();
|
||||
|
||||
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: t(l, "providers.title"),
|
||||
subtitle: t(l, "providers.subtitle"),
|
||||
}
|
||||
div { class: "providers-layout",
|
||||
div { class: "providers-form",
|
||||
div { class: "form-group",
|
||||
label { "{t(l, \"providers.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 { "{t(l, \"providers.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 { "{t(l, \"providers.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 { "{t(l, \"providers.api_key\")}" }
|
||||
input {
|
||||
class: "form-input",
|
||||
r#type: "password",
|
||||
placeholder: "{t(l, \"providers.api_key_placeholder\")}",
|
||||
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),
|
||||
"{t(l, \"providers.save_config\")}"
|
||||
}
|
||||
if *saved.read() {
|
||||
p { class: "form-success", "{t(l, \"providers.config_saved\")}" }
|
||||
}
|
||||
}
|
||||
div { class: "providers-status",
|
||||
h3 { "{t(l, \"providers.active_config\")}" }
|
||||
div { class: "status-card",
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "{t(l, \"providers.provider\")}" }
|
||||
span { class: "status-value", "{active_config.provider.label()}" }
|
||||
}
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "{t(l, \"providers.model\")}" }
|
||||
span { class: "status-value", "{active_config.selected_model}" }
|
||||
}
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "{t(l, \"providers.embedding\")}" }
|
||||
span { class: "status-value", "{active_config.selected_embedding}" }
|
||||
}
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "{t(l, \"providers.api_key\")}" }
|
||||
span { class: "status-value",
|
||||
if active_config.api_key_set {
|
||||
"{t(l, \"common.set\")}"
|
||||
} else {
|
||||
"{t(l, \"common.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,
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user