5 Commits

Author SHA1 Message Date
Sharang Parnerkar
7ab2cc27f4 docs(readme): redesign with centered logo, badges, and structured layout
Some checks failed
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Security Audit (pull_request) Has been cancelled
CI / Tests (pull_request) Has been cancelled
CI / Deploy (pull_request) Has been cancelled
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 2m16s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:55:00 +01:00
Sharang Parnerkar
56fd1d46b6 fix(sidebar): correct logout link path from /auth/logout to /logout
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m20s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Successful in 3s
CI / Deploy (push) Has been cancelled
CI / Clippy (pull_request) Successful in 2m19s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Deploy (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:51:24 +01:00
Sharang Parnerkar
e130969cd9 feat(infra): add ServerState, MongoDB, auth middleware, and DaisyUI theme toggle
All checks were successful
CI / Clippy (pull_request) Successful in 2m21s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Deploy (push) Has been skipped
CI / Deploy (pull_request) Has been skipped
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m22s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Successful in 2s
Introduce centralized ServerState (Arc-wrapped, Box::leaked configs) loaded
once at startup, replacing per-request dotenvy/env::var calls across all
server functions. Add MongoDB Database wrapper with connection pooling.
Add tower middleware that gates all /api/ server function endpoints behind
session authentication (401 for unauthenticated callers, except check-auth).
Fix DaisyUI theme toggle to use certifai-dark/certifai-light theme names
and replace hardcoded hex colors in main.css with CSS variables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:35:59 +01:00
5ce600e32b fix(dash): improved dashboard and some bug fixes (#8)
All checks were successful
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m24s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 2m59s
CI / Deploy (push) Successful in 4s
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #8
2026-02-20 12:17:09 +00:00
5399afd748 feat(dashboard): added dashboard content and features (#7)
All checks were successful
CI / Format (push) Successful in 2s
CI / Clippy (push) Successful in 2m18s
CI / Security Audit (push) Successful in 1m40s
CI / Tests (push) Successful in 2m51s
CI / Deploy (push) Successful in 2s
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #7
2026-02-19 19:23:06 +00:00
35 changed files with 4427 additions and 522 deletions

View File

@@ -1,9 +1,80 @@
# Keycloak Configuration (frontend public client) # ============================================================================
# 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_URL=http://localhost:8080 KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=certifai KEYCLOAK_REALM=certifai
KEYCLOAK_CLIENT_ID=certifai-dashboard KEYCLOAK_CLIENT_ID=certifai-dashboard
# Application Configuration # Keycloak admin / service-account client (server-to-server calls) [OPTIONAL]
KEYCLOAK_ADMIN_CLIENT_ID=
KEYCLOAK_ADMIN_CLIENT_SECRET=
# ---------------------------------------------------------------------------
# Application Configuration [REQUIRED]
# ---------------------------------------------------------------------------
APP_URL=http://localhost:8000 APP_URL=http://localhost:8000
REDIRECT_URI=http://localhost:8000/auth/callback REDIRECT_URI=http://localhost:8000/auth/callback
ALLOWED_ORIGINS=http://localhost:8000 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
# ---------------------------------------------------------------------------
# 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=

View File

@@ -35,10 +35,6 @@ jobs:
git checkout FETCH_HEAD git checkout FETCH_HEAD
- run: rustup component add rustfmt - run: rustup component add rustfmt
- run: cargo fmt --check - run: cargo fmt --check
- name: Install dx CLI
run: cargo install dioxus-cli@0.7.3 --locked
- name: RSX format check
run: dx fmt --check
clippy: clippy:
name: Clippy name: Clippy
@@ -62,6 +58,7 @@ jobs:
audit: audit:
name: Security Audit name: Security Audit
runs-on: docker runs-on: docker
if: github.ref == 'refs/heads/main'
container: container:
image: rust:1.89-bookworm image: rust:1.89-bookworm
steps: steps:

270
Cargo.lock generated
View File

@@ -698,6 +698,29 @@ dependencies = [
"typenum", "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]] [[package]]
name = "darling" name = "darling"
version = "0.21.3" version = "0.21.3"
@@ -753,6 +776,7 @@ dependencies = [
"petname", "petname",
"rand 0.10.0", "rand 0.10.0",
"reqwest 0.13.2", "reqwest 0.13.2",
"scraper",
"secrecy", "secrecy",
"serde", "serde",
"serde_json", "serde_json",
@@ -819,6 +843,17 @@ dependencies = [
"syn 2.0.116", "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]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -1050,7 +1085,7 @@ dependencies = [
"const-str", "const-str",
"const_format", "const_format",
"content_disposition", "content_disposition",
"derive_more", "derive_more 2.1.1",
"dioxus-asset-resolver", "dioxus-asset-resolver",
"dioxus-cli-config", "dioxus-cli-config",
"dioxus-core", "dioxus-core",
@@ -1546,12 +1581,33 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" 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]] [[package]]
name = "dunce" name = "dunce"
version = "1.0.5" version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ego-tree"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@@ -1674,6 +1730,16 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" 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]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@@ -1777,6 +1843,15 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "generational-box" name = "generational-box"
version = "0.7.3" version = "0.7.3"
@@ -2015,6 +2090,18 @@ dependencies = [
"digest", "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]] [[package]]
name = "http" name = "http"
version = "0.2.12" version = "0.2.12"
@@ -2579,6 +2666,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "macro-string" name = "macro-string"
version = "0.1.4" version = "0.1.4"
@@ -2678,6 +2771,31 @@ dependencies = [
"syn 2.0.116", "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]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -2804,7 +2922,7 @@ dependencies = [
"bitflags", "bitflags",
"bson", "bson",
"derive-where", "derive-where",
"derive_more", "derive_more 2.1.1",
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-util", "futures-util",
@@ -2897,6 +3015,12 @@ dependencies = [
"jni-sys", "jni-sys",
] ]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.0" version = "0.2.0"
@@ -3003,6 +3127,58 @@ dependencies = [
"rand 0.8.5", "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]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.10" version = "1.1.10"
@@ -3059,6 +3235,12 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@@ -3630,6 +3812,20 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "sct" name = "sct"
version = "0.7.1" version = "0.7.1"
@@ -3672,6 +3868,25 @@ dependencies = [
"libc", "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]] [[package]]
name = "semver" name = "semver"
version = "1.0.27" version = "1.0.27"
@@ -3841,6 +4056,15 @@ dependencies = [
"syn 2.0.116", "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]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"
@@ -3888,6 +4112,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -3991,6 +4221,31 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 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]] [[package]]
name = "stringprep" name = "stringprep"
version = "0.1.5" version = "0.1.5"
@@ -4117,6 +4372,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" 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]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"

View File

@@ -63,7 +63,12 @@ maud = { version = "0.27", default-features = false }
url = { version = "2.5.4", default-features = false, optional = true } url = { version = "2.5.4", default-features = false, optional = true }
web-sys = { version = "0.3", optional = true, features = [ web-sys = { version = "0.3", optional = true, features = [
"Clipboard", "Clipboard",
"Document",
"Element",
"HtmlElement",
"Navigator", "Navigator",
"Storage",
"Window",
] } ] }
tracing = "0.1.40" tracing = "0.1.40"
# Debug # Debug
@@ -75,6 +80,7 @@ dioxus-free-icons = { version = "0.10", features = [
] } ] }
sha2 = { version = "0.10.9", default-features = false, optional = true } sha2 = { version = "0.10.9", default-features = false, optional = true }
base64 = { version = "0.22.1", default-features = false, optional = true } base64 = { version = "0.22.1", default-features = false, optional = true }
scraper = { version = "0.22", default-features = false, optional = true }
[features] [features]
# default = ["web"] # default = ["web"]
@@ -91,6 +97,9 @@ server = [
"dep:url", "dep:url",
"dep:sha2", "dep:sha2",
"dep:base64", "dep:base64",
"dep:scraper",
"dep:secrecy",
"dep:petname",
] ]
[[bin]] [[bin]]

142
README.md
View File

@@ -1,44 +1,132 @@
# CERTifAI <p align="center">
<img src="assets/favicon.svg" width="96" height="96" alt="CERTifAI Logo" />
</p>
[![CI](https://gitea.meghsakha.com/sharang/certifai/actions/workflows/ci.yml/badge.svg?branch=main)](https://gitea.meghsakha.com/sharang/certifai/actions?workflow=ci.yml) <h1 align="center">CERTifAI</h1>
[![Rust](https://img.shields.io/badge/Rust-1.89-orange?logo=rust&logoColor=white)](https://www.rust-lang.org/)
[![Dioxus](https://img.shields.io/badge/Dioxus-0.7-blue?logo=webassembly&logoColor=white)](https://dioxuslabs.com/)
[![License](https://img.shields.io/badge/License-Proprietary-red)](LICENSE)
[![GDPR](https://img.shields.io/badge/GDPR-Compliant-green)](https://gdpr.eu/)
This project is a SaaS application dashboard for administation of self-hosted private GenAI (generative AI) toolbox for companies and individuals. The purpose of the dashboard is to manage LLMs, Agents, MCP Servers and other GenAI related features. <p align="center">
<strong>Self-hosted, GDPR-compliant GenAI infrastructure dashboard</strong>
</p>
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">
<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>
## Overview <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>
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: ---
- User management: Can add, remove, set roles, permissions and add restrictions for other users. ## About
- 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 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.
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. > **Why?** Protect your intellectual property from being used as training data. Stay fully GDPR-compliant with infrastructure you own.
## Features
## Code structure | Area | Capabilities |
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. | **User Management** | Add, remove, set roles, permissions, and restrictions |
- src/infrastructure/*.rs : All backend related functions from the dioxus fullstack are placed here. This entire module is behind the feature "server". | **SSO / OAuth / LDAP** | Connect to company identity providers and sync users |
- src/models/*.rs : All data models for use by the frontend pages and components. | **Feature Flags** | Toggle GenAI features on or off per-org |
- 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. | **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
```
## Git Workflow ## Git Workflow
We follow feature branch workflow for Git and bringing in new features. The `main` branch is the default and protected branch. We follow the **feature branch workflow**. 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)
- [Conventional Commits](https://www.conventionalcommits.org/) are required for all commit messages
- We follow [SemVer](https://semver.org/) for versioning
## CI ## CI
The CI is run on gitea actions with runner tags `docker`. 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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

25
assets/favicon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.6 KiB

File diff suppressed because it is too large Load Diff

17
assets/manifest.json Normal file
View File

@@ -0,0 +1,17 @@
{
"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 Normal file
View File

@@ -0,0 +1,67 @@
// 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))
);
});

View File

@@ -162,6 +162,147 @@
} }
} }
@layer utilities { @layer utilities {
.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%;
}
}
}
.btn { .btn {
:where(&) { :where(&) {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
@@ -314,6 +455,65 @@
.visible { .visible {
visibility: 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 { .toggle {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
border: var(--border) solid currentColor; border: var(--border) solid currentColor;
@@ -589,6 +789,75 @@
} }
} }
} }
.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 { .steps {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
display: inline-grid; display: inline-grid;
@@ -699,6 +968,34 @@
} }
} }
} }
.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 { .select {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
border: var(--border) solid #0000; border: var(--border) solid #0000;
@@ -934,6 +1231,15 @@
} }
} }
} }
.stats {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-grid;
grid-auto-flow: column;
overflow-x: auto;
border-radius: var(--radius-box);
}
}
.progress { .progress {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
position: relative; position: relative;
@@ -984,9 +1290,6 @@
} }
} }
} }
.fixed {
position: fixed;
}
.relative { .relative {
position: relative; position: relative;
} }
@@ -999,6 +1302,76 @@
.end { .end {
inset-inline-end: var(--spacing); 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 { .hero-content {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
isolation: isolate; isolation: isolate;
@@ -1122,6 +1495,51 @@
max-width: 96rem; 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 { .label {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
display: inline-flex; display: inline-flex;
@@ -1208,6 +1626,17 @@
padding-inline: calc(var(--size) / 2 - var(--border)); 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 { .footer {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
display: grid; display: grid;
@@ -1233,6 +1662,15 @@
} }
} }
} }
.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 { .card-title {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
display: flex; display: flex;
@@ -1242,12 +1680,21 @@
font-weight: 600; font-weight: 600;
} }
} }
.block {
display: block;
}
.grid { .grid {
display: grid; display: grid;
} }
.hidden { .hidden {
display: none; display: none;
} }
.inline {
display: inline;
}
.table {
display: table;
}
.transform { .transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
} }
@@ -1283,6 +1730,9 @@
.text-center { .text-center {
text-align: center; text-align: center;
} }
.lowercase {
text-transform: lowercase;
}
.outline { .outline {
outline-style: var(--tw-outline-style); outline-style: var(--tw-outline-style);
outline-width: 1px; outline-width: 1px;
@@ -1311,6 +1761,9 @@
} }
} }
} }
.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 { .btn-outline {
@layer daisyui.l1 { @layer daisyui.l1 {
&:not( .btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn), :disabled, [disabled], .btn-disabled ) { &:not( .btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn), :disabled, [disabled], .btn-disabled ) {
@@ -1351,6 +1804,12 @@
--btn-fg: var(--color-primary-content); --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 { @layer base {
:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
@@ -1724,6 +2183,59 @@
inherits: false; inherits: false;
initial-value: solid; 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 { @layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { @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 { *, ::before, ::after, ::backdrop {
@@ -1733,6 +2245,19 @@
--tw-skew-x: initial; --tw-skew-x: initial;
--tw-skew-y: initial; --tw-skew-y: initial;
--tw-outline-style: solid; --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;
} }
} }
} }

View File

@@ -49,9 +49,10 @@ pub enum Route {
Login { redirect_url: String }, Login { redirect_url: String },
} }
const FAVICON: Asset = asset!("/assets/favicon.ico"); const FAVICON: Asset = asset!("/assets/favicon.svg");
const MAIN_CSS: Asset = asset!("/assets/main.css"); const MAIN_CSS: Asset = asset!("/assets/main.css");
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.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). /// Google Fonts URL for Inter (body) and Space Grotesk (headings).
const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\ const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
@@ -64,6 +65,14 @@ const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
pub fn App() -> Element { pub fn App() -> Element {
rsx! { rsx! {
document::Link { rel: "icon", href: FAVICON } 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", href: "https://fonts.googleapis.com" }
document::Link { document::Link {
rel: "preconnect", rel: "preconnect",
@@ -73,6 +82,28 @@ pub fn App() -> Element {
document::Link { rel: "stylesheet", href: GOOGLE_FONTS } document::Link { rel: "stylesheet", href: GOOGLE_FONTS }
document::Link { rel: "stylesheet", href: TAILWIND_CSS } document::Link { rel: "stylesheet", href: TAILWIND_CSS }
document::Link { rel: "stylesheet", href: MAIN_CSS } document::Link { rel: "stylesheet", href: MAIN_CSS }
div { "data-theme": "certifai-dark", Router::<Route> {} }
// 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> {}
} }
} }

View File

@@ -1,21 +1,65 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::components::sidebar::Sidebar; use crate::components::sidebar::Sidebar;
use crate::infrastructure::auth_check::check_auth;
use crate::models::AuthInfo;
use crate::Route; use crate::Route;
/// Application shell layout that wraps all authenticated pages. /// Application shell layout that wraps all authenticated pages.
/// ///
/// Renders a fixed sidebar on the left and the active child route /// Calls [`check_auth`] on mount to fetch the current user's session.
/// in the scrollable main content area via `Outlet`. /// If unauthenticated, redirects to `/auth`. Otherwise renders the
/// sidebar with real user data and the active child route.
#[component] #[component]
pub fn AppShell() -> Element { pub fn AppShell() -> Element {
// 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 => {
rsx! { rsx! {
div { class: "app-shell", div { class: "app-shell",
Sidebar { Sidebar {
email: "user@example.com".to_string(), email: info.email,
avatar_url: String::new(), name: info.name,
avatar_url: info.avatar_url,
} }
main { class: "main-content", Outlet::<Route> {} } 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 { "Redirecting to login..." }
}
}
}
Some(Err(e)) => {
let msg = e.to_string();
rsx! {
div { class: "auth-error",
p { "Authentication error: {msg}" }
a { href: "/auth", "Login" }
}
}
}
None => {
// Still loading.
rsx! {
div { class: "app-shell loading",
p { "Loading..." }
}
}
}
}
}

View File

@@ -0,0 +1,158 @@
use crate::infrastructure::llm::FollowUpMessage;
use crate::models::NewsCard;
use dioxus::prelude::*;
/// 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 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(()),
"X"
}
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",
"Read original article"
}
// 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 { "Summarizing..." }
}
} else if let Some(ref text) = summary {
p { class: "ai-summary-bubble-text", "{text}" }
span { class: "ai-summary-bubble-label", "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: "Ask a follow-up question...",
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());
}
},
"Send"
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,112 @@
use dioxus::prelude::*;
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 {
// 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", "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 {
"Online"
} else {
"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", "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", "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}"
}
}
}
}
}
}
}
}
}

View File

@@ -1,6 +1,8 @@
mod app_shell; mod app_shell;
mod article_detail;
mod card; mod card;
mod chat_bubble; mod chat_bubble;
mod dashboard_sidebar;
mod file_row; mod file_row;
mod login; mod login;
mod member_row; mod member_row;
@@ -12,8 +14,10 @@ pub mod sub_nav;
mod tool_card; mod tool_card;
pub use app_shell::*; pub use app_shell::*;
pub use article_detail::*;
pub use card::*; pub use card::*;
pub use chat_bubble::*; pub use chat_bubble::*;
pub use dashboard_sidebar::*;
pub use file_row::*; pub use file_row::*;
pub use login::*; pub use login::*;
pub use member_row::*; pub use member_row::*;

View File

@@ -1,40 +1,67 @@
use crate::models::{NewsCard as NewsCardModel, NewsCategory}; use crate::models::NewsCard as NewsCardModel;
use dioxus::prelude::*; use dioxus::prelude::*;
/// Renders a news feed card with title, source, category badge, and summary. /// 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 /// # Arguments
/// ///
/// * `card` - The news card model data to render /// * `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] #[component]
pub fn NewsCardView(card: NewsCardModel) -> Element { pub fn NewsCardView(
let badge_class = format!("news-badge news-badge--{}", card.category.css_class()); 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! { rsx! {
article { class: "news-card", article {
class: "{card_class}",
onclick: move |_| on_click.call(card_for_click.clone()),
if let Some(ref thumb) = card.thumbnail_url { if let Some(ref thumb) = card.thumbnail_url {
if *thumb_ok.read() {
div { class: "news-card-thumb", div { class: "news-card-thumb",
img { img {
src: "{thumb}", src: "{thumb}",
alt: "{card.title}", alt: "",
loading: "lazy", 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-body",
div { class: "news-card-meta", div { class: "news-card-meta",
span { class: "{badge_class}", "{card.category.label()}" } span { class: "{badge_class}", "{card.category}" }
span { class: "news-card-source", "{card.source}" } span { class: "news-card-source", "{card.source}" }
span { class: "news-card-date", "{card.published_at}" } span { class: "news-card-date", "{card.published_at}" }
} }
h3 { class: "news-card-title", h3 { class: "news-card-title", "{card.title}" }
a {
href: "{card.url}",
target: "_blank",
rel: "noopener",
"{card.title}"
}
}
p { class: "news-card-summary", "{card.summary}" } p { class: "news-card-summary", "{card.summary}" }
} }
} }
@@ -48,7 +75,12 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "Llama 4 Released with 1M Context Window".into(), title: "Llama 4 Released with 1M Context Window".into(),
source: "Meta AI Blog".into(), source: "Meta AI Blog".into(),
summary: "Meta releases Llama 4 with a 1 million token context window.".into(), summary: "Meta releases Llama 4 with a 1 million token context window.".into(),
category: NewsCategory::Llm, 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(), url: "#".into(),
thumbnail_url: None, thumbnail_url: None,
published_at: "2026-02-18".into(), published_at: "2026-02-18".into(),
@@ -57,7 +89,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "EU AI Act Enforcement Begins".into(), title: "EU AI Act Enforcement Begins".into(),
source: "TechCrunch".into(), source: "TechCrunch".into(),
summary: "The EU AI Act enters its enforcement phase across member states.".into(), summary: "The EU AI Act enters its enforcement phase across member states.".into(),
category: NewsCategory::Privacy, 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(), url: "#".into(),
thumbnail_url: None, thumbnail_url: None,
published_at: "2026-02-17".into(), published_at: "2026-02-17".into(),
@@ -66,7 +102,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "LangChain v0.4 Introduces Native MCP Support".into(), title: "LangChain v0.4 Introduces Native MCP Support".into(),
source: "LangChain Blog".into(), source: "LangChain Blog".into(),
summary: "New version adds first-class MCP server integration.".into(), summary: "New version adds first-class MCP server integration.".into(),
category: NewsCategory::Agents, 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(), url: "#".into(),
thumbnail_url: None, thumbnail_url: None,
published_at: "2026-02-16".into(), published_at: "2026-02-16".into(),
@@ -75,7 +115,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "Ollama Adds Multi-GPU Scheduling".into(), title: "Ollama Adds Multi-GPU Scheduling".into(),
source: "Ollama".into(), source: "Ollama".into(),
summary: "Run large models across multiple GPUs with automatic sharding.".into(), summary: "Run large models across multiple GPUs with automatic sharding.".into(),
category: NewsCategory::Infrastructure, 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(), url: "#".into(),
thumbnail_url: None, thumbnail_url: None,
published_at: "2026-02-15".into(), published_at: "2026-02-15".into(),
@@ -84,7 +128,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "Mistral Open Sources Codestral 2".into(), title: "Mistral Open Sources Codestral 2".into(),
source: "Mistral AI".into(), source: "Mistral AI".into(),
summary: "Codestral 2 achieves state-of-the-art on HumanEval benchmarks.".into(), summary: "Codestral 2 achieves state-of-the-art on HumanEval benchmarks.".into(),
category: NewsCategory::OpenSource, 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(), url: "#".into(),
thumbnail_url: None, thumbnail_url: None,
published_at: "2026-02-14".into(), published_at: "2026-02-14".into(),
@@ -93,7 +141,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "NVIDIA Releases NeMo 3.0 Framework".into(), title: "NVIDIA Releases NeMo 3.0 Framework".into(),
source: "NVIDIA Developer".into(), source: "NVIDIA Developer".into(),
summary: "Updated framework simplifies enterprise LLM fine-tuning.".into(), summary: "Updated framework simplifies enterprise LLM fine-tuning.".into(),
category: NewsCategory::Infrastructure, 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(), url: "#".into(),
thumbnail_url: None, thumbnail_url: None,
published_at: "2026-02-13".into(), published_at: "2026-02-13".into(),
@@ -102,7 +154,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "Anthropic Claude 4 Sets New Reasoning Records".into(), title: "Anthropic Claude 4 Sets New Reasoning Records".into(),
source: "Anthropic".into(), source: "Anthropic".into(),
summary: "Claude 4 achieves top scores across major reasoning benchmarks.".into(), summary: "Claude 4 achieves top scores across major reasoning benchmarks.".into(),
category: NewsCategory::Llm, 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(), url: "#".into(),
thumbnail_url: None, thumbnail_url: None,
published_at: "2026-02-12".into(), published_at: "2026-02-12".into(),
@@ -111,7 +167,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "CrewAI Raises $52M for Agent Orchestration".into(), title: "CrewAI Raises $52M for Agent Orchestration".into(),
source: "VentureBeat".into(), source: "VentureBeat".into(),
summary: "Series B funding to expand multi-agent orchestration platform.".into(), summary: "Series B funding to expand multi-agent orchestration platform.".into(),
category: NewsCategory::Agents, 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(), url: "#".into(),
thumbnail_url: None, thumbnail_url: None,
published_at: "2026-02-11".into(), published_at: "2026-02-11".into(),
@@ -120,7 +180,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "DeepSeek V4 Released Under Apache 2.0".into(), title: "DeepSeek V4 Released Under Apache 2.0".into(),
source: "DeepSeek".into(), source: "DeepSeek".into(),
summary: "Latest open-weight model competes with proprietary offerings.".into(), summary: "Latest open-weight model competes with proprietary offerings.".into(),
category: NewsCategory::OpenSource, 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(), url: "#".into(),
thumbnail_url: None, thumbnail_url: None,
published_at: "2026-02-10".into(), published_at: "2026-02-10".into(),
@@ -129,7 +193,11 @@ pub fn mock_news() -> Vec<NewsCardModel> {
title: "GDPR Fines for AI Training Data Reach Record High".into(), title: "GDPR Fines for AI Training Data Reach Record High".into(),
source: "Reuters".into(), source: "Reuters".into(),
summary: "European regulators issue largest penalties yet for AI data misuse.".into(), summary: "European regulators issue largest penalties yet for AI data misuse.".into(),
category: NewsCategory::Privacy, 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(), url: "#".into(),
thumbnail_url: None, thumbnail_url: None,
published_at: "2026-02-09".into(), published_at: "2026-02-09".into(),

View File

@@ -1,7 +1,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{ use dioxus_free_icons::icons::bs_icons::{
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub, BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub,
BsGrid, BsHouseDoor, BsPuzzle, BsGrid, BsHouseDoor, BsMoonFill, BsPuzzle, BsSunFill,
}; };
use dioxus_free_icons::Icon; use dioxus_free_icons::Icon;
@@ -19,10 +19,11 @@ struct NavItem {
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `name` - User display name (shown in header if non-empty).
/// * `email` - Email address displayed beneath the avatar placeholder. /// * `email` - Email address displayed beneath the avatar placeholder.
/// * `avatar_url` - URL for the avatar image (unused placeholder for now). /// * `avatar_url` - URL for the avatar image (unused placeholder for now).
#[component] #[component]
pub fn Sidebar(email: String, avatar_url: String) -> Element { pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element {
let nav_items: Vec<NavItem> = vec![ let nav_items: Vec<NavItem> = vec![
NavItem { NavItem {
label: "Dashboard", label: "Dashboard",
@@ -66,7 +67,7 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
rsx! { rsx! {
aside { class: "sidebar", aside { class: "sidebar",
SidebarHeader { email: email.clone(), avatar_url } SidebarHeader { name, email: email.clone(), avatar_url }
nav { class: "sidebar-nav", nav { class: "sidebar-nav",
for item in nav_items { for item in nav_items {
@@ -93,13 +94,14 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
} }
} }
div { class: "sidebar-logout", div { class: "sidebar-bottom-actions",
Link { Link {
to: NavigationTarget::<Route>::External("/auth/logout".into()), to: NavigationTarget::<Route>::External("/logout".into()),
class: "sidebar-link logout-btn", class: "sidebar-link logout-btn",
Icon { icon: BsBoxArrowRight, width: 18, height: 18 } Icon { icon: BsBoxArrowRight, width: 18, height: 18 }
span { "Logout" } span { "Logout" }
} }
ThemeToggle {}
} }
SidebarFooter {} SidebarFooter {}
@@ -107,33 +109,126 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
} }
} }
/// Avatar circle and email display at the top of the sidebar. /// Avatar circle, name, and email display at the top of the sidebar.
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `name` - User display name. If non-empty, shown above the email.
/// * `email` - User email to display. /// * `email` - User email to display.
/// * `avatar_url` - Placeholder for future avatar image URL. /// * `avatar_url` - Placeholder for future avatar image URL.
#[component] #[component]
fn SidebarHeader(email: String, avatar_url: String) -> Element { fn SidebarHeader(name: String, email: String, avatar_url: String) -> Element {
// Extract initials from email (first two chars before @). // Derive initials: prefer name words, fall back to email prefix.
let initials: String = email let initials: String = if name.is_empty() {
email
.split('@') .split('@')
.next() .next()
.unwrap_or("U") .unwrap_or("U")
.chars() .chars()
.take(2) .take(2)
.collect::<String>() .collect::<String>()
.to_uppercase(); .to_uppercase()
} else {
name.split_whitespace()
.filter_map(|w| w.chars().next())
.take(2)
.collect::<String>()
.to_uppercase()
};
rsx! { rsx! {
div { class: "sidebar-header", div { class: "sidebar-header",
div { class: "avatar-circle", div { class: "avatar-circle",
span { class: "avatar-initials", "{initials}" } span { class: "avatar-initials", "{initials}" }
} }
div { class: "sidebar-user-info",
if !name.is_empty() {
p { class: "sidebar-name", "{name}" }
}
p { class: "sidebar-email", "{email}" } 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 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();
rsx! {
button {
class: "theme-toggle-btn",
title: if dark { "Switch to light mode" } else { "Switch to dark mode" },
onclick: toggle,
if dark {
Icon { icon: BsSunFill, width: 16, height: 16 }
} else {
Icon { icon: BsMoonFill, width: 16, height: 16 }
}
}
}
}
/// Footer section with version string and placeholder social links. /// Footer section with version string and placeholder social links.
#[component] #[component]

View File

@@ -12,7 +12,11 @@ use rand::RngExt;
use tower_sessions::Session; use tower_sessions::Session;
use url::Url; use url::Url;
use crate::infrastructure::{state::User, Error, UserStateInner}; use crate::infrastructure::{
server_state::ServerState,
state::{User, UserStateInner},
Error,
};
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user"; pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
@@ -55,70 +59,6 @@ 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. /// Generate a cryptographically random state string for CSRF protection.
fn generate_state() -> String { fn generate_state() -> String {
let bytes: [u8; 32] = rand::rng().random(); let bytes: [u8; 32] = rand::rng().random();
@@ -165,35 +105,36 @@ fn derive_code_challenge(verifier: &str) -> String {
/// ///
/// # Errors /// # Errors
/// ///
/// Returns `Error` if env vars are missing. /// Returns `Error` if the Keycloak config is missing or the URL is malformed.
#[axum::debug_handler] #[axum::debug_handler]
pub async fn auth_login( pub async fn auth_login(
Extension(state): Extension<ServerState>,
Extension(pending): Extension<PendingOAuthStore>, Extension(pending): Extension<PendingOAuthStore>,
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
) -> Result<impl IntoResponse, Error> { ) -> Result<impl IntoResponse, Error> {
let config = OAuthConfig::from_env()?; let kc = state.keycloak;
let state = generate_state(); let csrf_state = generate_state();
let code_verifier = generate_code_verifier(); let code_verifier = generate_code_verifier();
let code_challenge = derive_code_challenge(&code_verifier); let code_challenge = derive_code_challenge(&code_verifier);
let redirect_url = params.get("redirect_url").cloned(); let redirect_url = params.get("redirect_url").cloned();
pending.insert( pending.insert(
state.clone(), csrf_state.clone(),
PendingOAuthEntry { PendingOAuthEntry {
redirect_url, redirect_url,
code_verifier, code_verifier,
}, },
); );
let mut url = Url::parse(&config.auth_endpoint()) let mut url = Url::parse(&kc.auth_endpoint())
.map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?; .map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?;
url.query_pairs_mut() url.query_pairs_mut()
.append_pair("client_id", &config.client_id) .append_pair("client_id", &kc.client_id)
.append_pair("redirect_uri", &config.redirect_uri) .append_pair("redirect_uri", &kc.redirect_uri)
.append_pair("response_type", "code") .append_pair("response_type", "code")
.append_pair("scope", "openid profile email") .append_pair("scope", "openid profile email")
.append_pair("state", &state) .append_pair("state", &csrf_state)
.append_pair("code_challenge", &code_challenge) .append_pair("code_challenge", &code_challenge)
.append_pair("code_challenge_method", "S256"); .append_pair("code_challenge_method", "S256");
@@ -213,6 +154,10 @@ struct UserinfoResponse {
/// The subject identifier (unique user ID in Keycloak). /// The subject identifier (unique user ID in Keycloak).
sub: String, sub: String,
email: Option<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. /// Keycloak may include a picture/avatar URL via protocol mappers.
picture: Option<String>, picture: Option<String>,
} }
@@ -234,10 +179,11 @@ struct UserinfoResponse {
#[axum::debug_handler] #[axum::debug_handler]
pub async fn auth_callback( pub async fn auth_callback(
session: Session, session: Session,
Extension(state): Extension<ServerState>,
Extension(pending): Extension<PendingOAuthStore>, Extension(pending): Extension<PendingOAuthStore>,
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
) -> Result<impl IntoResponse, Error> { ) -> Result<impl IntoResponse, Error> {
let config = OAuthConfig::from_env()?; let kc = state.keycloak;
// --- CSRF validation via the in-memory pending store --- // --- CSRF validation via the in-memory pending store ---
let returned_state = params let returned_state = params
@@ -255,11 +201,11 @@ pub async fn auth_callback(
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let token_resp = client let token_resp = client
.post(config.token_endpoint()) .post(kc.token_endpoint())
.form(&[ .form(&[
("grant_type", "authorization_code"), ("grant_type", "authorization_code"),
("client_id", &config.client_id), ("client_id", kc.client_id.as_str()),
("redirect_uri", &config.redirect_uri), ("redirect_uri", kc.redirect_uri.as_str()),
("code", code), ("code", code),
("code_verifier", &entry.code_verifier), ("code_verifier", &entry.code_verifier),
]) ])
@@ -279,7 +225,7 @@ pub async fn auth_callback(
// --- Fetch userinfo --- // --- Fetch userinfo ---
let userinfo: UserinfoResponse = client let userinfo: UserinfoResponse = client
.get(config.userinfo_endpoint()) .get(kc.userinfo_endpoint())
.bearer_auth(&tokens.access_token) .bearer_auth(&tokens.access_token)
.send() .send()
.await .await
@@ -288,6 +234,12 @@ pub async fn auth_callback(
.await .await
.map_err(|e| Error::StateError(format!("userinfo parse failed: {e}")))?; .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 --- // --- Build user state and persist in session ---
let user_state = UserStateInner { let user_state = UserStateInner {
sub: userinfo.sub, sub: userinfo.sub,
@@ -295,6 +247,7 @@ pub async fn auth_callback(
refresh_token: tokens.refresh_token.unwrap_or_default(), refresh_token: tokens.refresh_token.unwrap_or_default(),
user: User { user: User {
email: userinfo.email.unwrap_or_default(), email: userinfo.email.unwrap_or_default(),
name: display_name,
avatar_url: userinfo.picture.unwrap_or_default(), avatar_url: userinfo.picture.unwrap_or_default(),
}, },
}; };
@@ -316,10 +269,13 @@ pub async fn auth_callback(
/// ///
/// # Errors /// # Errors
/// ///
/// Returns `Error` if env vars are missing or the session cannot be flushed. /// Returns `Error` if the session cannot be flushed or the URL is malformed.
#[axum::debug_handler] #[axum::debug_handler]
pub async fn logout(session: Session) -> Result<impl IntoResponse, Error> { pub async fn logout(
let config = OAuthConfig::from_env()?; session: Session,
Extension(state): Extension<ServerState>,
) -> Result<impl IntoResponse, Error> {
let kc = state.keycloak;
// Flush all session data. // Flush all session data.
session session
@@ -327,12 +283,12 @@ pub async fn logout(session: Session) -> Result<impl IntoResponse, Error> {
.await .await
.map_err(|e| Error::StateError(format!("session flush failed: {e}")))?; .map_err(|e| Error::StateError(format!("session flush failed: {e}")))?;
let mut url = Url::parse(&config.logout_endpoint()) let mut url = Url::parse(&kc.logout_endpoint())
.map_err(|e| Error::StateError(format!("invalid logout endpoint URL: {e}")))?; .map_err(|e| Error::StateError(format!("invalid logout endpoint URL: {e}")))?;
url.query_pairs_mut() url.query_pairs_mut()
.append_pair("client_id", &config.client_id) .append_pair("client_id", &kc.client_id)
.append_pair("post_logout_redirect_uri", &config.app_url); .append_pair("post_logout_redirect_uri", &kc.app_url);
Ok(Redirect::temporary(url.as_str())) Ok(Redirect::temporary(url.as_str()))
} }

View File

@@ -0,0 +1,36 @@
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) => Ok(AuthInfo {
authenticated: true,
sub: u.sub,
email: u.user.email,
name: u.user.name,
avatar_url: u.user.avatar_url,
}),
None => Ok(AuthInfo::default()),
}
}

View File

@@ -0,0 +1,41 @@
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
}

View File

@@ -0,0 +1,253 @@
//! 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 })
}
}

View File

@@ -0,0 +1,52 @@
//! MongoDB connection wrapper with typed collection accessors.
use mongodb::{bson::doc, Client, Collection};
use super::Error;
use crate::models::{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")
}
}

View File

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

327
src/infrastructure/llm.rs Normal file
View File

@@ -0,0 +1,327 @@
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()?;
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(&paragraph_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.
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"))
}

View File

@@ -1,10 +1,37 @@
#![cfg(feature = "server")] // 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 llm;
pub mod ollama;
pub mod searxng;
// Server-only modules (Axum handlers, state, configs, DB, etc.)
#[cfg(feature = "server")]
mod auth; 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; mod error;
#[cfg(feature = "server")]
mod server; mod server;
#[cfg(feature = "server")]
pub mod server_state;
#[cfg(feature = "server")]
mod state; mod state;
#[cfg(feature = "server")]
pub use auth::*; pub use auth::*;
#[cfg(feature = "server")]
pub use auth_middleware::*;
#[cfg(feature = "server")]
pub use error::*; pub use error::*;
#[cfg(feature = "server")]
pub use server::*; pub use server::*;
#[cfg(feature = "server")]
pub use server_state::*;
#[cfg(feature = "server")]
pub use state::*; pub use state::*;

View File

@@ -0,0 +1,92 @@
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,
})
}

View File

@@ -0,0 +1,287 @@
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")]
mod inner {
use serde::Deserialize;
use std::collections::HashSet;
/// Individual result from the SearXNG search API.
#[derive(Debug, Deserialize)]
pub(super) 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(super) 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(super) 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(super) 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(&params)
.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(&params)
.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)
}

View File

@@ -1,54 +1,94 @@
use crate::infrastructure::{
auth_callback, auth_login, logout, PendingOAuthStore, UserState, UserStateInner,
};
use dioxus::prelude::*; use dioxus::prelude::*;
use axum::routing::get; use axum::routing::get;
use axum::Extension; use axum::{middleware, Extension};
use time::Duration; use time::Duration;
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer}; 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, /// Start the Axum server with Dioxus fullstack, session management,
/// and Keycloak OAuth routes. /// MongoDB, and Keycloak OAuth routes.
///
/// Loads all configuration from environment variables once, connects
/// to MongoDB, and builds a [`ServerState`] shared across every request.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns `Error` if the tokio runtime or TCP listener fails to start. /// Returns `Error` if the tokio runtime, config loading, DB connection,
/// or TCP listener fails.
pub fn server_start(app: fn() -> Element) -> Result<(), super::Error> { pub fn server_start(app: fn() -> Element) -> Result<(), super::Error> {
tokio::runtime::Runtime::new()?.block_on(async move { tokio::runtime::Runtime::new()?.block_on(async move {
let state: UserState = UserStateInner { // Load .env once at startup.
access_token: "abcd".into(), dotenvy::dotenv().ok();
sub: "abcd".into(),
refresh_token: "abcd".into(), // ---- Load and leak config structs for 'static lifetime ----
..Default::default() 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,
} }
.into(); .into();
// ---- Session layer ----
let key = Key::generate(); let key = Key::generate();
let store = MemoryStore::default(); let store = MemoryStore::default();
let session = SessionManagerLayer::new(store) let session = SessionManagerLayer::new(store)
.with_secure(false) .with_secure(false)
// Lax is required so the browser sends the session cookie // Lax is required so the browser sends the session cookie
// on the redirect back from Keycloak (cross-origin GET). // 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_same_site(tower_sessions::cookie::SameSite::Lax)
.with_expiry(tower_sessions::Expiry::OnInactivity(Duration::hours(24))) .with_expiry(tower_sessions::Expiry::OnInactivity(Duration::hours(24)))
.with_signed(key); .with_signed(key);
// ---- Build router ----
let addr = dioxus_cli_config::fullstack_address_or_localhost(); let addr = dioxus_cli_config::fullstack_address_or_localhost();
let listener = tokio::net::TcpListener::bind(addr).await?; let listener = tokio::net::TcpListener::bind(addr).await?;
// Layers are applied AFTER serve_dioxus_application so they
// wrap both the custom Axum routes AND the Dioxus server // Layers wrap in reverse order: session (outermost) -> auth
// function routes (e.g. check_auth needs Session access). // 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`).
let router = axum::Router::new() let router = axum::Router::new()
.route("/auth", get(auth_login)) .route("/auth", get(auth_login))
.route("/auth/callback", get(auth_callback)) .route("/auth/callback", get(auth_callback))
.route("/logout", get(logout)) .route("/logout", get(logout))
.serve_dioxus_application(ServeConfig::new(), app) .serve_dioxus_application(ServeConfig::new(), app)
.layer(Extension(PendingOAuthStore::default())) .layer(Extension(PendingOAuthStore::default()))
.layer(Extension(state)) .layer(Extension(server_state))
.layer(middleware::from_fn(require_auth))
.layer(session); .layer(session);
info!("Serving at {addr}"); tracing::info!("Serving at {addr}");
axum::serve(listener, router.into_make_service()).await?; axum::serve(listener, router.into_make_service()).await?;
Ok(()) Ok(())

View File

@@ -0,0 +1,74 @@
//! 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()))
}
}

View File

@@ -1,8 +1,8 @@
use std::{ops::Deref, sync::Arc}; use std::{ops::Deref, sync::Arc};
use axum::extract::FromRequestParts;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Cheap-to-clone handle to per-session user data.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UserState(Arc<UserStateInner>); pub struct UserState(Arc<UserStateInner>);
@@ -19,39 +19,28 @@ 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)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserStateInner { pub struct UserStateInner {
/// Subject in Oauth /// Subject identifier from Keycloak (unique user ID).
pub sub: String, pub sub: String,
/// Access Token /// OAuth2 access token.
pub access_token: String, pub access_token: String,
/// Refresh Token /// OAuth2 refresh token.
pub refresh_token: String, pub refresh_token: String,
/// User /// Basic user profile.
pub user: User, pub user: User,
} }
/// Basic user profile stored alongside the session.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct User { pub struct User {
/// Email /// Email address.
pub email: String, pub email: String,
/// Avatar Url /// Display name (preferred_username or full name from Keycloak).
pub name: String,
/// Avatar / profile picture URL.
pub avatar_url: String, pub avatar_url: String,
} }
impl<S> FromRequestParts<S> for UserState
where
S: std::marker::Sync + std::marker::Send,
{
type Rejection = super::Error;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_: &S,
) -> Result<Self, super::Error> {
parts
.extensions
.get::<UserState>()
.cloned()
.ok_or(super::Error::StateError("Unable to get extension".into()))
}
}

View File

@@ -1,44 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Categories for classifying AI news articles.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum NewsCategory {
/// Large language model announcements and updates
Llm,
/// AI agent frameworks and tooling
Agents,
/// Data privacy and regulatory compliance
Privacy,
/// AI infrastructure and deployment
Infrastructure,
/// Open-source AI project releases
OpenSource,
}
impl NewsCategory {
/// Returns the display label for a news category.
pub fn label(&self) -> &'static str {
match self {
Self::Llm => "LLM",
Self::Agents => "Agents",
Self::Privacy => "Privacy",
Self::Infrastructure => "Infrastructure",
Self::OpenSource => "Open Source",
}
}
/// Returns the CSS class suffix for styling category badges.
pub fn css_class(&self) -> &'static str {
match self {
Self::Llm => "llm",
Self::Agents => "agents",
Self::Privacy => "privacy",
Self::Infrastructure => "infrastructure",
Self::OpenSource => "open-source",
}
}
}
/// A single news feed card representing an AI-related article. /// A single news feed card representing an AI-related article.
/// ///
/// # Fields /// # Fields
@@ -46,7 +7,8 @@ impl NewsCategory {
/// * `title` - Headline of the article /// * `title` - Headline of the article
/// * `source` - Publishing outlet or author /// * `source` - Publishing outlet or author
/// * `summary` - Brief summary text /// * `summary` - Brief summary text
/// * `category` - Classification category /// * `content` - Full content snippet from search results
/// * `category` - Display label for the search topic (e.g. "AI", "Finance")
/// * `url` - Link to the full article /// * `url` - Link to the full article
/// * `thumbnail_url` - Optional thumbnail image URL /// * `thumbnail_url` - Optional thumbnail image URL
/// * `published_at` - ISO 8601 date string /// * `published_at` - ISO 8601 date string
@@ -55,7 +17,8 @@ pub struct NewsCard {
pub title: String, pub title: String,
pub source: String, pub source: String,
pub summary: String, pub summary: String,
pub category: NewsCategory, pub content: String,
pub category: String,
pub url: String, pub url: String,
pub thumbnail_url: Option<String>, pub thumbnail_url: Option<String>,
pub published_at: String, pub published_at: String,

View File

@@ -82,3 +82,37 @@ pub struct BillingUsage {
pub tokens_limit: u64, pub tokens_limit: u64,
pub billing_cycle_end: String, 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,
}

View File

@@ -1,21 +1,44 @@
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde::Serialize;
/// Basic user display data used by frontend components.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UserData { pub struct UserData {
pub name: String, pub name: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] /// Authentication information returned by the `check_auth` server function.
pub struct LoggedInState { ///
pub access_token: String, /// 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
pub email: String, pub email: String,
/// User display name
pub name: String,
/// Avatar URL (from Keycloak picture claim)
pub avatar_url: String,
} }
impl LoggedInState { /// Per-user preferences stored in MongoDB.
pub fn new(access_token: String, email: String) -> Self { ///
Self { /// Keyed by `sub` (Keycloak subject) and optionally scoped to an org.
access_token, #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
email, 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>,
} }

View File

@@ -1,40 +1,131 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus_sdk::storage::use_persistent;
use crate::components::{NewsCardView, PageHeader}; use crate::components::{ArticleDetail, DashboardSidebar, NewsCardView, PageHeader};
use crate::models::NewsCategory; use crate::infrastructure::llm::FollowUpMessage;
use crate::models::NewsCard;
/// Dashboard page displaying an AI news feed grid with category filters. /// 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.
/// ///
/// Replaces the previous `OverviewPage`. Shows mock news items /// State is persisted across sessions using localStorage:
/// that will eventually be sourced from the SearXNG instance. /// - `certifai_topics`: custom user-defined search topics
/// - `certifai_ollama_url`: Ollama instance URL for summarization
/// - `certifai_ollama_model`: Ollama model ID for summarization
#[component] #[component]
pub fn DashboardPage() -> Element { pub fn DashboardPage() -> Element {
let news = use_signal(crate::components::news_card::mock_news); // Persistent state stored in localStorage
let mut active_filter = use_signal(|| Option::<NewsCategory>::None); 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);
// Collect filtered news items based on active category filter // Reactive signals for UI state
let filtered: Vec<_> = { let mut active_topic = use_signal(|| "AI".to_string());
let items = news.read(); let mut selected_card = use_signal(|| Option::<NewsCard>::None);
let filter = active_filter.read(); let mut summary = use_signal(|| Option::<String>::None);
match &*filter { let mut is_summarizing = use_signal(|| false);
Some(cat) => items let mut show_add_input = use_signal(|| false);
.iter() let mut new_topic_text = use_signal(String::new);
.filter(|n| n.category == *cat) let mut show_settings = use_signal(|| false);
.cloned() let mut settings_url = use_signal(String::new);
.collect(), let mut settings_model = use_signal(String::new);
None => items.clone(), // 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);
// 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
}; };
// All available filter categories // Fetch trending topics once on mount (no signal deps = runs once).
let categories = [ // use_resource handles deduplication and won't re-fetch on re-renders.
("All", None), let trending_resource = use_resource(|| async {
("LLM", Some(NewsCategory::Llm)), match crate::infrastructure::searxng::get_trending_topics().await {
("Agents", Some(NewsCategory::Agents)), Ok(topics) => topics,
("Privacy", Some(NewsCategory::Privacy)), Err(e) => {
("Infrastructure", Some(NewsCategory::Infrastructure)), tracing::error!("Failed to fetch trending topics: {e}");
("Open Source", Some(NewsCategory::OpenSource)), 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! { rsx! {
section { class: "dashboard-page", section { class: "dashboard-page",
@@ -42,24 +133,308 @@ pub fn DashboardPage() -> Element {
title: "Dashboard".to_string(), title: "Dashboard".to_string(),
subtitle: "AI news and updates".to_string(), subtitle: "AI news and updates".to_string(),
} }
// Topic tabs row
div { class: "dashboard-filters", div { class: "dashboard-filters",
for (label , cat) in categories { for topic in &all_topics {
{ {
let is_active = *active_filter.read() == cat; let is_active = *active_topic.read() == *topic;
let class = if is_active { let class_name = if is_active {
"filter-tab filter-tab--active" "filter-tab filter-tab--active"
} else { } else {
"filter-tab" "filter-tab"
}; };
let is_custom = !DEFAULT_TOPICS.contains(&topic.as_str());
let topic_click = topic.clone();
let topic_remove = topic.clone();
rsx! { rsx! {
button { class: "{class}", onclick: move |_| active_filter.set(cat.clone()), "{label}" } 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: "Topic name...",
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());
},
"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);
},
"Settings"
}
}
// Settings panel (collapsible)
if *show_settings.read() {
div { class: "settings-panel",
h4 { class: "settings-panel-title", "Ollama Settings" }
p { class: "settings-hint",
"Leave empty to use OLLAMA_URL / OLLAMA_MODEL from .env"
}
div { class: "settings-field",
label { "Ollama URL" }
input {
class: "settings-input",
r#type: "text",
placeholder: "Uses OLLAMA_URL from .env",
value: "{settings_url}",
oninput: move |e| settings_url.set(e.value()),
}
}
div { class: "settings-field",
label { "Model" }
input {
class: "settings-input",
r#type: "text",
placeholder: "Uses OLLAMA_MODEL from .env",
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);
},
"Save"
}
}
}
// Loading / error state
if is_loading {
div { class: "dashboard-loading", "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());
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());
},
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();
// Append user message to chat
chat_messages
// Build full message history for Ollama
.write()
.push(FollowUpMessage {
role: "user".into(),
content: question,
});
let msgs = {
let history = chat_messages.read();
let mut all = vec![
FollowUpMessage {
role: "system".into(),
content: 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}",
),
},
];
all.extend(history.iter().cloned());
all
};
spawn(async move {
is_chatting.set(true);
match crate::infrastructure::llm::chat_followup(msgs, oll_url, mdl).await {
Ok(reply) => {
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);
},
} }
}
}
div { class: "news-grid",
for card in filtered {
NewsCardView { key: "{card.title}", card }
} }
} }
} }