Compare commits
5 Commits
dda163fcc8
...
feat/CAI-4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80faa4fa86 | ||
|
|
e0a4d2d888 | ||
| f699976f4d | |||
| 0673f7867c | |||
| 6d3e99220c |
171
.gitea/workflows/ci.yml
Normal file
171
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: "-D warnings"
|
||||||
|
|
||||||
|
# Cancel in-progress runs for the same branch/PR
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 1: Code quality checks (run in parallel)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
fmt:
|
||||||
|
name: Format
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: rust:1.89-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
- run: rustup component add rustfmt
|
||||||
|
- run: cargo fmt --check
|
||||||
|
- name: Install dx CLI
|
||||||
|
run: cargo install dioxus-cli@0.7.3 --locked
|
||||||
|
- name: RSX format check
|
||||||
|
run: dx fmt --check
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
name: Clippy
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: rust:1.89-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
- run: rustup component add clippy
|
||||||
|
# Lint both feature sets independently
|
||||||
|
- name: Clippy (server)
|
||||||
|
run: cargo clippy --features server --no-default-features -- -D warnings
|
||||||
|
- name: Clippy (web)
|
||||||
|
run: cargo clippy --features web --no-default-features -- -D warnings
|
||||||
|
|
||||||
|
audit:
|
||||||
|
name: Security Audit
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: rust:1.89-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
- run: cargo install cargo-audit
|
||||||
|
- run: cargo audit
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 2: Tests (only after all quality checks pass)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
test:
|
||||||
|
name: Tests
|
||||||
|
runs-on: docker
|
||||||
|
needs: [fmt, clippy, audit]
|
||||||
|
container:
|
||||||
|
image: rust:1.89-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
- name: Run tests (server)
|
||||||
|
run: cargo test --features server --no-default-features
|
||||||
|
- name: Run tests (web)
|
||||||
|
run: cargo test --features web --no-default-features
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 3: Build Docker image and push to registry
|
||||||
|
# Only on main and release/* branches
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
build-and-push:
|
||||||
|
name: Build & Push Image
|
||||||
|
runs-on: docker
|
||||||
|
needs: [test]
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'push' &&
|
||||||
|
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Determine image tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
BRANCH="${GITHUB_REF#refs/heads/}"
|
||||||
|
# Replace / with - for valid Docker tags (e.g. release/1.0 -> release-1.0)
|
||||||
|
BRANCH_SAFE=$(echo "$BRANCH" | tr '/' '-')
|
||||||
|
SHA=$(echo "$GITHUB_SHA" | head -c 8)
|
||||||
|
echo "tag=${BRANCH_SAFE}-${SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to container registry
|
||||||
|
run: >-
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}"
|
||||||
|
| docker login https://registry.meghsakha.com
|
||||||
|
-u "${{ secrets.REGISTRY_USERNAME }}"
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: >-
|
||||||
|
docker build
|
||||||
|
-t registry.meghsakha.com/certifai/dashboard:${{ steps.tag.outputs.tag }}
|
||||||
|
-t registry.meghsakha.com/certifai/dashboard:latest
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: |
|
||||||
|
docker push registry.meghsakha.com/certifai/dashboard:${{ steps.tag.outputs.tag }}
|
||||||
|
docker push registry.meghsakha.com/certifai/dashboard:latest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 3b: Generate changelog from conventional commits
|
||||||
|
# Only on main and release/* branches
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
changelog:
|
||||||
|
name: Changelog
|
||||||
|
runs-on: docker
|
||||||
|
needs: [test]
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'push' &&
|
||||||
|
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))
|
||||||
|
container:
|
||||||
|
image: rust:1.89-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Checkout (full history)
|
||||||
|
run: |
|
||||||
|
git clone "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" .
|
||||||
|
git checkout "${GITHUB_SHA}"
|
||||||
|
- name: Install git-cliff
|
||||||
|
run: cargo install git-cliff --locked
|
||||||
|
- name: Generate changelog
|
||||||
|
run: git cliff --output CHANGELOG.md
|
||||||
|
- name: Upload changelog artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: changelog
|
||||||
|
path: CHANGELOG.md
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -12,5 +12,9 @@
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Keycloak data
|
# Keycloak runtime data (but keep realm-export.json)
|
||||||
keycloak/
|
keycloak/*
|
||||||
|
!keycloak/realm-export.json
|
||||||
|
|
||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -739,6 +739,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stripe",
|
"async-stripe",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dioxus",
|
"dioxus",
|
||||||
"dioxus-cli-config",
|
"dioxus-cli-config",
|
||||||
@@ -755,6 +756,7 @@ dependencies = [
|
|||||||
"secrecy",
|
"secrecy",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ dioxus-free-icons = { version = "0.10", features = [
|
|||||||
"bootstrap",
|
"bootstrap",
|
||||||
"font-awesome-solid",
|
"font-awesome-solid",
|
||||||
] }
|
] }
|
||||||
|
sha2 = { version = "0.10.9", default-features = false, optional = true }
|
||||||
|
base64 = { version = "0.22.1", default-features = false, optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# default = ["web"]
|
# default = ["web"]
|
||||||
@@ -87,6 +89,8 @@ server = [
|
|||||||
"dep:time",
|
"dep:time",
|
||||||
"dep:rand",
|
"dep:rand",
|
||||||
"dep:url",
|
"dep:url",
|
||||||
|
"dep:sha2",
|
||||||
|
"dep:base64",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
57
Dockerfile
Normal file
57
Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Stage 1: Generate dependency recipe for caching
|
||||||
|
FROM rust:1.89-bookworm AS chef
|
||||||
|
RUN cargo install cargo-chef
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM chef AS planner
|
||||||
|
COPY . .
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
# Stage 2: Build dependencies + application
|
||||||
|
FROM chef AS builder
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config libssl-dev curl unzip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install bun (for Tailwind CSS build step)
|
||||||
|
RUN curl -fsSL https://bun.sh/install | bash
|
||||||
|
ENV PATH="/root/.bun/bin:$PATH"
|
||||||
|
|
||||||
|
# Install dx CLI from source (binstall binaries require GLIBC >= 2.38)
|
||||||
|
RUN cargo install dioxus-cli@0.7.3 --locked
|
||||||
|
|
||||||
|
# Cook dependencies from recipe (cached layer)
|
||||||
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Install frontend dependencies (DaisyUI, Tailwind) for the build.rs CSS step
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Bundle the fullstack application
|
||||||
|
RUN dx bundle --release --fullstack
|
||||||
|
|
||||||
|
# Stage 3: Minimal runtime image
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates libssl3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN useradd --create-home --shell /bin/bash app
|
||||||
|
USER app
|
||||||
|
WORKDIR /home/app
|
||||||
|
|
||||||
|
# Copy the bundled output from builder
|
||||||
|
COPY --from=builder --chown=app:app /app/target/dx/dashboard/release/web/ ./
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENV IP=0.0.0.0
|
||||||
|
ENV PORT=8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["./dashboard"]
|
||||||
25
assets/logo.svg
Normal file
25
assets/logo.svg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<!-- Shield body -->
|
||||||
|
<path d="M32 4L8 16v16c0 14.4 10.24 27.2 24 32 13.76-4.8 24-17.6 24-32V16L32 4z"
|
||||||
|
fill="#4B3FE0" fill-opacity="0.12" stroke="#4B3FE0" stroke-width="2"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
<!-- Inner shield highlight -->
|
||||||
|
<path d="M32 10L14 19v11c0 11.6 7.68 22 18 26 10.32-4 18-14.4 18-26V19L32 10z"
|
||||||
|
fill="none" stroke="#4B3FE0" stroke-width="1" stroke-opacity="0.3"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
<!-- Neural network nodes -->
|
||||||
|
<circle cx="32" cy="24" r="3.5" fill="#38B2AC"/>
|
||||||
|
<circle cx="22" cy="36" r="3" fill="#38B2AC"/>
|
||||||
|
<circle cx="42" cy="36" r="3" fill="#38B2AC"/>
|
||||||
|
<circle cx="27" cy="48" r="2.5" fill="#38B2AC" fill-opacity="0.7"/>
|
||||||
|
<circle cx="37" cy="48" r="2.5" fill="#38B2AC" fill-opacity="0.7"/>
|
||||||
|
<!-- Neural network edges -->
|
||||||
|
<line x1="32" y1="24" x2="22" y2="36" stroke="#38B2AC" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||||
|
<line x1="32" y1="24" x2="42" y2="36" stroke="#38B2AC" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||||
|
<line x1="22" y1="36" x2="27" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
|
||||||
|
<line x1="22" y1="36" x2="37" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
|
||||||
|
<line x1="42" y1="36" x2="27" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
|
||||||
|
<line x1="42" y1="36" x2="37" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
|
||||||
|
<!-- Cross edge for connectivity -->
|
||||||
|
<line x1="22" y1="36" x2="42" y2="36" stroke="#38B2AC" stroke-width="0.8" stroke-opacity="0.3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
550
assets/main.css
550
assets/main.css
@@ -211,3 +211,553 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
color: #8892a8;
|
color: #8892a8;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Landing Page ===== */
|
||||||
|
.landing {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Landing Nav -- */
|
||||||
|
.landing-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background-color: rgba(15, 17, 22, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid #1e222d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-nav-inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-logo-icon {
|
||||||
|
color: #91a4d2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-nav-links a {
|
||||||
|
color: #8892a8;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-nav-links a:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-nav-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Hero Section -- */
|
||||||
|
.hero-section {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 80px 32px 60px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 64px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #91a4d2;
|
||||||
|
border-color: rgba(145, 164, 210, 0.3);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 52px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title-accent {
|
||||||
|
background: linear-gradient(135deg, #91a4d2, #6d85c6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #8892a8;
|
||||||
|
margin: 0 0 36px;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-graphic {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Social Proof -- */
|
||||||
|
.social-proof {
|
||||||
|
border-top: 1px solid #1e222d;
|
||||||
|
border-bottom: 1px solid #1e222d;
|
||||||
|
padding: 40px 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-proof-text {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #8892a8;
|
||||||
|
margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-proof-highlight {
|
||||||
|
color: #91a4d2;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-proof-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 40px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-stat-value {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5a6478;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 40px;
|
||||||
|
background-color: #1e222d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Section Titles -- */
|
||||||
|
.section-title {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #8892a8;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Features Section -- */
|
||||||
|
.features-section {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 80px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
background-color: #1a1d26;
|
||||||
|
border: 1px solid #2a2f3d;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
border-color: #91a4d2;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-icon {
|
||||||
|
color: #91a4d2;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #8892a8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- How It Works -- */
|
||||||
|
.how-it-works-section {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 80px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, #91a4d2, #6d85c6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-desc {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #8892a8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- CTA Banner -- */
|
||||||
|
.cta-banner {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto 80px;
|
||||||
|
padding: 64px 48px;
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(145, 164, 210, 0.08),
|
||||||
|
rgba(109, 133, 198, 0.04)
|
||||||
|
);
|
||||||
|
border: 1px solid rgba(145, 164, 210, 0.15);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-subtitle {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #8892a8;
|
||||||
|
margin: 0 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Landing Footer -- */
|
||||||
|
.landing-footer {
|
||||||
|
border-top: 1px solid #1e222d;
|
||||||
|
padding: 60px 32px 0;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-footer-inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||||
|
gap: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-tagline {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #5a6478;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links-heading {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #8892a8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links-group a {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #5a6478;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links-group a:hover {
|
||||||
|
color: #91a4d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 48px auto 0;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-top: 1px solid #1e222d;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #3d4556;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Legal Pages (Impressum, Privacy) ===== */
|
||||||
|
.legal-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-nav {
|
||||||
|
padding: 20px 32px;
|
||||||
|
border-bottom: 1px solid #1e222d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 32px 80px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content h1 {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 40px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content p {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #8892a8;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content ul {
|
||||||
|
padding-left: 24px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content li {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #8892a8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-updated {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #5a6478;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-footer {
|
||||||
|
padding: 20px 32px;
|
||||||
|
border-top: 1px solid #1e222d;
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-footer a {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #5a6478;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-footer a:hover {
|
||||||
|
color: #91a4d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Responsive: Landing Page ===== */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.hero-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 60px 24px 40px;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-graphic {
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
order: -1;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-footer-inner {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.landing-nav-links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid,
|
||||||
|
.steps-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-proof-stats {
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-banner {
|
||||||
|
margin: 0 16px 60px;
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-footer-inner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1533
assets/tailwind.css
1533
assets/tailwind.css
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
|||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Init logger
|
// Init logger
|
||||||
dioxus_logger::init(tracing::Level::DEBUG).expect("Failed to init logger");
|
dioxus_logger::init(tracing::Level::DEBUG).expect("Failed to init logger");
|
||||||
|
|||||||
15
build.rs
15
build.rs
@@ -1,8 +1,9 @@
|
|||||||
#[allow(clippy::expect_used)]
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
println!("cargo:rerun-if-changed=./styles/input.css");
|
println!("cargo:rerun-if-changed=./styles/input.css");
|
||||||
Command::new("bunx")
|
|
||||||
|
// Tailwind build is optional - skip gracefully in CI or environments without bun
|
||||||
|
match Command::new("bunx")
|
||||||
.args([
|
.args([
|
||||||
"@tailwindcss/cli",
|
"@tailwindcss/cli",
|
||||||
"-i",
|
"-i",
|
||||||
@@ -11,7 +12,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"./assets/tailwind.css",
|
"./assets/tailwind.css",
|
||||||
])
|
])
|
||||||
.status()
|
.status()
|
||||||
.expect("could not run tailwind");
|
{
|
||||||
|
Ok(status) if !status.success() => {
|
||||||
|
println!("cargo:warning=tailwind build exited with {status}, skipping CSS generation");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("cargo:warning=bunx not found ({e}), skipping tailwind CSS generation");
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
33
bun.lock
Normal file
33
bun.lock
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "certifai",
|
||||||
|
"dependencies": {
|
||||||
|
"daisyui": "^5.5.18",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||||
|
|
||||||
|
"daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
52
cliff.toml
Normal file
52
cliff.toml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
[changelog]
|
||||||
|
header = """
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
"""
|
||||||
|
body = """
|
||||||
|
{%- macro remote_url() -%}
|
||||||
|
https://gitea.meghsakha.com/{{ remote.github.owner }}/{{ remote.github.repo }}
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{% if version -%}
|
||||||
|
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||||
|
{% else -%}
|
||||||
|
## [Unreleased]
|
||||||
|
{% endif -%}
|
||||||
|
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | striptags | trim | upper_first }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }}\
|
||||||
|
{% if commit.breaking %} [**breaking**]{% endif %}\
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}\n
|
||||||
|
"""
|
||||||
|
footer = """
|
||||||
|
---
|
||||||
|
*Generated by [git-cliff](https://git-cliff.org)*
|
||||||
|
"""
|
||||||
|
trim = true
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = true
|
||||||
|
split_commits = false
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Features" },
|
||||||
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
|
{ message = "^doc", group = "Documentation" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^refactor", group = "Refactoring" },
|
||||||
|
{ message = "^style", group = "Styling" },
|
||||||
|
{ message = "^test", group = "Testing" },
|
||||||
|
{ message = "^ci", group = "CI/CD" },
|
||||||
|
{ message = "^chore", group = "Miscellaneous" },
|
||||||
|
{ message = "^build", group = "Build" },
|
||||||
|
]
|
||||||
|
protect_breaking_commits = false
|
||||||
|
filter_commits = false
|
||||||
|
tag_pattern = "v[0-9].*"
|
||||||
|
sort_commits = "oldest"
|
||||||
246
keycloak/realm-export.json
Normal file
246
keycloak/realm-export.json
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
{
|
||||||
|
"id": "certifai",
|
||||||
|
"realm": "certifai",
|
||||||
|
"displayName": "CERTifAI",
|
||||||
|
"enabled": true,
|
||||||
|
"sslRequired": "none",
|
||||||
|
"registrationAllowed": true,
|
||||||
|
"registrationEmailAsUsername": true,
|
||||||
|
"loginWithEmailAllowed": true,
|
||||||
|
"duplicateEmailsAllowed": false,
|
||||||
|
"resetPasswordAllowed": true,
|
||||||
|
"editUsernameAllowed": false,
|
||||||
|
"bruteForceProtected": true,
|
||||||
|
"permanentLockout": false,
|
||||||
|
"maxFailureWaitSeconds": 900,
|
||||||
|
"minimumQuickLoginWaitSeconds": 60,
|
||||||
|
"waitIncrementSeconds": 60,
|
||||||
|
"quickLoginCheckMilliSeconds": 1000,
|
||||||
|
"maxDeltaTimeSeconds": 43200,
|
||||||
|
"failureFactor": 5,
|
||||||
|
"defaultSignatureAlgorithm": "RS256",
|
||||||
|
"accessTokenLifespan": 300,
|
||||||
|
"ssoSessionIdleTimeout": 1800,
|
||||||
|
"ssoSessionMaxLifespan": 36000,
|
||||||
|
"offlineSessionIdleTimeout": 2592000,
|
||||||
|
"accessCodeLifespan": 60,
|
||||||
|
"accessCodeLifespanUserAction": 300,
|
||||||
|
"accessCodeLifespanLogin": 1800,
|
||||||
|
"roles": {
|
||||||
|
"realm": [
|
||||||
|
{
|
||||||
|
"name": "admin",
|
||||||
|
"description": "CERTifAI administrator with full access",
|
||||||
|
"composite": false,
|
||||||
|
"clientRole": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user",
|
||||||
|
"description": "Standard CERTifAI user",
|
||||||
|
"composite": false,
|
||||||
|
"clientRole": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"defaultRoles": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "certifai-dashboard",
|
||||||
|
"name": "CERTifAI Dashboard",
|
||||||
|
"description": "CERTifAI administration dashboard",
|
||||||
|
"enabled": true,
|
||||||
|
"publicClient": true,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"rootUrl": "http://localhost:8000",
|
||||||
|
"baseUrl": "http://localhost:8000",
|
||||||
|
"redirectUris": [
|
||||||
|
"http://localhost:8000/auth/callback"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"http://localhost:8000"
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"post.logout.redirect.uris": "http://localhost:8000",
|
||||||
|
"pkce.code.challenge.method": "S256"
|
||||||
|
},
|
||||||
|
"defaultClientScopes": [
|
||||||
|
"openid",
|
||||||
|
"profile",
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"optionalClientScopes": [
|
||||||
|
"offline_access"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clientScopes": [
|
||||||
|
{
|
||||||
|
"name": "openid",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "false"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "sub",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-sub-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profile",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "User profile information"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "full name",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-full-name-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "given name",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"user.attribute": "firstName",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"claim.name": "given_name",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "family name",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"user.attribute": "lastName",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"claim.name": "family_name",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "picture",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"user.attribute": "picture",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"claim.name": "picture",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Email address"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"user.attribute": "email",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"claim.name": "email",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email verified",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"user.attribute": "emailVerified",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"claim.name": "email_verified",
|
||||||
|
"jsonType.label": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "admin@certifai.local",
|
||||||
|
"email": "admin@certifai.local",
|
||||||
|
"firstName": "Admin",
|
||||||
|
"lastName": "User",
|
||||||
|
"enabled": true,
|
||||||
|
"emailVerified": true,
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"value": "admin",
|
||||||
|
"temporary": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"realmRoles": [
|
||||||
|
"admin",
|
||||||
|
"user"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "user@certifai.local",
|
||||||
|
"email": "user@certifai.local",
|
||||||
|
"firstName": "Test",
|
||||||
|
"lastName": "User",
|
||||||
|
"enabled": true,
|
||||||
|
"emailVerified": true,
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"value": "user",
|
||||||
|
"temporary": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"realmRoles": [
|
||||||
|
"user"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "certifai",
|
||||||
|
"module": "index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"daisyui": "^5.5.18",
|
||||||
|
"tailwindcss": "^4.1.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/app.rs
18
src/app.rs
@@ -3,14 +3,20 @@ use dioxus::prelude::*;
|
|||||||
|
|
||||||
/// Application routes.
|
/// Application routes.
|
||||||
///
|
///
|
||||||
/// `OverviewPage` is wrapped in the `AppShell` layout so the sidebar
|
/// Public pages (`LandingPage`, `ImpressumPage`, `PrivacyPage`) live
|
||||||
/// renders around every authenticated page. The `/login` route remains
|
/// outside the `AppShell` layout. Authenticated pages like `OverviewPage`
|
||||||
/// outside the shell (unauthenticated).
|
/// are wrapped in `AppShell` which renders the sidebar.
|
||||||
#[derive(Debug, Clone, Routable, PartialEq)]
|
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
|
#[route("/")]
|
||||||
|
LandingPage {},
|
||||||
|
#[route("/impressum")]
|
||||||
|
ImpressumPage {},
|
||||||
|
#[route("/privacy")]
|
||||||
|
PrivacyPage {},
|
||||||
#[layout(AppShell)]
|
#[layout(AppShell)]
|
||||||
#[route("/")]
|
#[route("/dashboard")]
|
||||||
OverviewPage {},
|
OverviewPage {},
|
||||||
#[end_layout]
|
#[end_layout]
|
||||||
#[route("/login?:redirect_url")]
|
#[route("/login?:redirect_url")]
|
||||||
@@ -39,8 +45,8 @@ pub fn App() -> Element {
|
|||||||
crossorigin: "anonymous",
|
crossorigin: "anonymous",
|
||||||
}
|
}
|
||||||
document::Link { rel: "stylesheet", href: GOOGLE_FONTS }
|
document::Link { rel: "stylesheet", href: GOOGLE_FONTS }
|
||||||
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
|
||||||
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
||||||
Router::<Route> {}
|
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||||
|
div { "data-theme": "certifai-dark", Router::<Route> {} }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ pub fn AppShell() -> Element {
|
|||||||
email: "user@example.com".to_string(),
|
email: "user@example.com".to_string(),
|
||||||
avatar_url: String::new(),
|
avatar_url: String::new(),
|
||||||
}
|
}
|
||||||
main { class: "main-content",
|
main { class: "main-content", Outlet::<Route> {} }
|
||||||
Outlet::<Route> {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,7 @@ use dioxus::prelude::*;
|
|||||||
/// * `href` - URL the card links to when clicked.
|
/// * `href` - URL the card links to when clicked.
|
||||||
/// * `icon` - Element rendered as the card icon (typically a `dioxus_free_icons::Icon`).
|
/// * `icon` - Element rendered as the card icon (typically a `dioxus_free_icons::Icon`).
|
||||||
#[component]
|
#[component]
|
||||||
pub fn DashboardCard(
|
pub fn DashboardCard(title: String, description: String, href: String, icon: Element) -> Element {
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
href: String,
|
|
||||||
icon: Element,
|
|
||||||
) -> Element {
|
|
||||||
rsx! {
|
rsx! {
|
||||||
a { class: "dashboard-card", href: "{href}",
|
a { class: "dashboard-card", href: "{href}",
|
||||||
div { class: "card-icon", {icon} }
|
div { class: "card-icon", {icon} }
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
use crate::Route;
|
use crate::Route;
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Login redirect component.
|
||||||
|
///
|
||||||
|
/// Redirects the user to the external OAuth authentication endpoint.
|
||||||
|
/// If no `redirect_url` is provided, defaults to `/dashboard`.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `redirect_url` - URL to redirect to after successful authentication
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Login(redirect_url: String) -> Element {
|
pub fn Login(redirect_url: String) -> Element {
|
||||||
let navigator = use_navigator();
|
let navigator = use_navigator();
|
||||||
|
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let target = format!("/auth?redirect_url={}", redirect_url);
|
// Default to /dashboard when redirect_url is empty.
|
||||||
|
let destination = if redirect_url.is_empty() {
|
||||||
|
"/dashboard".to_string()
|
||||||
|
} else {
|
||||||
|
redirect_url.clone()
|
||||||
|
};
|
||||||
|
let target = format!("/auth?redirect_url={destination}");
|
||||||
navigator.push(NavigationTarget::<Route>::External(target));
|
navigator.push(NavigationTarget::<Route>::External(target));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use dioxus_free_icons::icons::bs_icons::{
|
use dioxus_free_icons::icons::bs_icons::{
|
||||||
BsBoxArrowRight, BsFileEarmarkText, BsGear, BsGithub, BsGrid,
|
BsBoxArrowRight, BsFileEarmarkText, BsGear, BsGithub, BsGrid, BsHouseDoor, BsRobot,
|
||||||
BsHouseDoor, BsRobot,
|
|
||||||
};
|
};
|
||||||
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
|
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
|
||||||
use dioxus_free_icons::Icon;
|
use dioxus_free_icons::Icon;
|
||||||
@@ -66,15 +65,9 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
|||||||
{
|
{
|
||||||
// Simple active check: highlight Overview only when on `/`.
|
// Simple active check: highlight Overview only when on `/`.
|
||||||
let is_active = item.route == current_route;
|
let is_active = item.route == current_route;
|
||||||
let cls = if is_active {
|
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
|
||||||
"sidebar-link active"
|
|
||||||
} else {
|
|
||||||
"sidebar-link"
|
|
||||||
};
|
|
||||||
rsx! {
|
rsx! {
|
||||||
Link {
|
Link { to: item.route, class: cls,
|
||||||
to: item.route,
|
|
||||||
class: cls,
|
|
||||||
{item.icon}
|
{item.icon}
|
||||||
span { "{item.label}" }
|
span { "{item.label}" }
|
||||||
}
|
}
|
||||||
@@ -135,16 +128,10 @@ fn SidebarFooter() -> Element {
|
|||||||
rsx! {
|
rsx! {
|
||||||
footer { class: "sidebar-footer",
|
footer { class: "sidebar-footer",
|
||||||
div { class: "sidebar-social",
|
div { class: "sidebar-social",
|
||||||
a {
|
a { href: "#", class: "social-link", title: "GitHub",
|
||||||
href: "#",
|
|
||||||
class: "social-link",
|
|
||||||
title: "GitHub",
|
|
||||||
Icon { icon: BsGithub, width: 16, height: 16 }
|
Icon { icon: BsGithub, width: 16, height: 16 }
|
||||||
}
|
}
|
||||||
a {
|
a { href: "#", class: "social-link", title: "Impressum",
|
||||||
href: "#",
|
|
||||||
class: "social-link",
|
|
||||||
title: "Impressum",
|
|
||||||
Icon { icon: BsGrid, width: 16, height: 16 }
|
Icon { icon: BsGrid, width: 16, height: 16 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,28 +16,37 @@ use crate::infrastructure::{state::User, Error, UserStateInner};
|
|||||||
|
|
||||||
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
|
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
|
||||||
|
|
||||||
/// In-memory store for pending OAuth states and their associated redirect
|
/// Data stored alongside each pending OAuth state. Holds the optional
|
||||||
/// URLs. Keyed by the random state string. This avoids dependence on the
|
/// post-login redirect URL and the PKCE code verifier needed for the
|
||||||
/// session cookie surviving the Keycloak redirect round-trip (the `dx serve`
|
/// token exchange.
|
||||||
/// proxy can drop `Set-Cookie` headers on 307 responses).
|
#[derive(Debug, Clone)]
|
||||||
|
struct PendingOAuthEntry {
|
||||||
|
redirect_url: Option<String>,
|
||||||
|
code_verifier: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory store for pending OAuth states. Keyed by the random state
|
||||||
|
/// string. This avoids dependence on the session cookie surviving the
|
||||||
|
/// Keycloak redirect round-trip (the `dx serve` proxy can drop
|
||||||
|
/// `Set-Cookie` headers on 307 responses).
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, Option<String>>>>);
|
pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
|
||||||
|
|
||||||
impl PendingOAuthStore {
|
impl PendingOAuthStore {
|
||||||
/// Insert a pending state with an optional post-login redirect URL.
|
/// Insert a pending state with an optional redirect URL and PKCE verifier.
|
||||||
fn insert(&self, state: String, redirect_url: Option<String>) {
|
fn insert(&self, state: String, entry: PendingOAuthEntry) {
|
||||||
// RwLock::write only panics if the lock is poisoned, which
|
// RwLock::write only panics if the lock is poisoned, which
|
||||||
// indicates a prior panic -- propagating is acceptable here.
|
// indicates a prior panic -- propagating is acceptable here.
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
self.0
|
self.0
|
||||||
.write()
|
.write()
|
||||||
.expect("pending oauth store lock poisoned")
|
.expect("pending oauth store lock poisoned")
|
||||||
.insert(state, redirect_url);
|
.insert(state, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove and return the redirect URL if the state was pending.
|
/// Remove and return the entry if the state was pending.
|
||||||
/// Returns `None` if the state was never stored (CSRF failure).
|
/// Returns `None` if the state was never stored (CSRF failure).
|
||||||
fn take(&self, state: &str) -> Option<Option<String>> {
|
fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
self.0
|
self.0
|
||||||
.write()
|
.write()
|
||||||
@@ -122,6 +131,28 @@ fn generate_state() -> String {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate a PKCE code verifier (43-128 char URL-safe random string).
|
||||||
|
///
|
||||||
|
/// Uses 32 random bytes encoded as base64url (no padding) to produce
|
||||||
|
/// a 43-character verifier per RFC 7636.
|
||||||
|
fn generate_code_verifier() -> String {
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
|
||||||
|
let bytes: [u8; 32] = rand::rng().random();
|
||||||
|
URL_SAFE_NO_PAD.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the S256 code challenge from a code verifier per RFC 7636.
|
||||||
|
///
|
||||||
|
/// `code_challenge = BASE64URL(SHA256(code_verifier))`
|
||||||
|
fn derive_code_challenge(verifier: &str) -> String {
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
let digest = Sha256::digest(verifier.as_bytes());
|
||||||
|
URL_SAFE_NO_PAD.encode(digest)
|
||||||
|
}
|
||||||
|
|
||||||
/// Redirect the user to Keycloak's authorization page.
|
/// Redirect the user to Keycloak's authorization page.
|
||||||
///
|
///
|
||||||
/// Generates a random CSRF state, stores it (along with the optional
|
/// Generates a random CSRF state, stores it (along with the optional
|
||||||
@@ -142,9 +173,17 @@ pub async fn auth_login(
|
|||||||
) -> Result<impl IntoResponse, Error> {
|
) -> Result<impl IntoResponse, Error> {
|
||||||
let config = OAuthConfig::from_env()?;
|
let config = OAuthConfig::from_env()?;
|
||||||
let state = generate_state();
|
let state = generate_state();
|
||||||
|
let code_verifier = generate_code_verifier();
|
||||||
|
let code_challenge = derive_code_challenge(&code_verifier);
|
||||||
|
|
||||||
let redirect_url = params.get("redirect_url").cloned();
|
let redirect_url = params.get("redirect_url").cloned();
|
||||||
pending.insert(state.clone(), redirect_url);
|
pending.insert(
|
||||||
|
state.clone(),
|
||||||
|
PendingOAuthEntry {
|
||||||
|
redirect_url,
|
||||||
|
code_verifier,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let mut url = Url::parse(&config.auth_endpoint())
|
let mut url = Url::parse(&config.auth_endpoint())
|
||||||
.map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?;
|
.map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?;
|
||||||
@@ -154,7 +193,9 @@ pub async fn auth_login(
|
|||||||
.append_pair("redirect_uri", &config.redirect_uri)
|
.append_pair("redirect_uri", &config.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", &state)
|
||||||
|
.append_pair("code_challenge", &code_challenge)
|
||||||
|
.append_pair("code_challenge_method", "S256");
|
||||||
|
|
||||||
Ok(Redirect::temporary(url.as_str()))
|
Ok(Redirect::temporary(url.as_str()))
|
||||||
}
|
}
|
||||||
@@ -203,23 +244,24 @@ pub async fn auth_callback(
|
|||||||
.get("state")
|
.get("state")
|
||||||
.ok_or_else(|| Error::StateError("missing state parameter".into()))?;
|
.ok_or_else(|| Error::StateError("missing state parameter".into()))?;
|
||||||
|
|
||||||
let redirect_url = pending
|
let entry = pending
|
||||||
.take(returned_state)
|
.take(returned_state)
|
||||||
.ok_or_else(|| Error::StateError("unknown or expired oauth state".into()))?;
|
.ok_or_else(|| Error::StateError("unknown or expired oauth state".into()))?;
|
||||||
|
|
||||||
// --- Exchange code for tokens ---
|
// --- Exchange code for tokens (with PKCE code_verifier) ---
|
||||||
let code = params
|
let code = params
|
||||||
.get("code")
|
.get("code")
|
||||||
.ok_or_else(|| Error::StateError("missing code parameter".into()))?;
|
.ok_or_else(|| Error::StateError("missing code parameter".into()))?;
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let token_resp = client
|
let token_resp = client
|
||||||
.post(&config.token_endpoint())
|
.post(config.token_endpoint())
|
||||||
.form(&[
|
.form(&[
|
||||||
("grant_type", "authorization_code"),
|
("grant_type", "authorization_code"),
|
||||||
("client_id", &config.client_id),
|
("client_id", &config.client_id),
|
||||||
("redirect_uri", &config.redirect_uri),
|
("redirect_uri", &config.redirect_uri),
|
||||||
("code", code),
|
("code", code),
|
||||||
|
("code_verifier", &entry.code_verifier),
|
||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -237,7 +279,7 @@ pub async fn auth_callback(
|
|||||||
|
|
||||||
// --- Fetch userinfo ---
|
// --- Fetch userinfo ---
|
||||||
let userinfo: UserinfoResponse = client
|
let userinfo: UserinfoResponse = client
|
||||||
.get(&config.userinfo_endpoint())
|
.get(config.userinfo_endpoint())
|
||||||
.bearer_auth(&tokens.access_token)
|
.bearer_auth(&tokens.access_token)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -259,7 +301,8 @@ pub async fn auth_callback(
|
|||||||
|
|
||||||
set_login_session(session, user_state).await?;
|
set_login_session(session, user_state).await?;
|
||||||
|
|
||||||
let target = redirect_url
|
let target = entry
|
||||||
|
.redirect_url
|
||||||
.filter(|u| !u.is_empty())
|
.filter(|u| !u.is_empty())
|
||||||
.unwrap_or_else(|| "/".into());
|
.unwrap_or_else(|| "/".into());
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
use std::{
|
use std::{ops::Deref, sync::Arc};
|
||||||
ops::{Deref, DerefMut},
|
|
||||||
sync::Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use axum::extract::FromRequestParts;
|
use axum::extract::FromRequestParts;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::debug;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct UserState(Arc<UserStateInner>);
|
pub struct UserState(Arc<UserStateInner>);
|
||||||
|
|||||||
74
src/pages/impressum.rs
Normal file
74
src/pages/impressum.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
|
use crate::Route;
|
||||||
|
|
||||||
|
/// Impressum (legal notice) page required by German/EU law.
|
||||||
|
///
|
||||||
|
/// Displays placeholder company information. This page is publicly
|
||||||
|
/// accessible without authentication.
|
||||||
|
#[component]
|
||||||
|
pub fn ImpressumPage() -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "legal-page",
|
||||||
|
nav { class: "legal-nav",
|
||||||
|
Link { to: Route::LandingPage {}, class: "landing-logo",
|
||||||
|
span { class: "landing-logo-icon",
|
||||||
|
Icon { icon: BsShieldCheck, width: 20, height: 20 }
|
||||||
|
}
|
||||||
|
span { "CERTifAI" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main { class: "legal-content",
|
||||||
|
h1 { "Impressum" }
|
||||||
|
|
||||||
|
h2 { "Information according to 5 TMG" }
|
||||||
|
p {
|
||||||
|
"CERTifAI GmbH"
|
||||||
|
br {}
|
||||||
|
"Musterstrasse 1"
|
||||||
|
br {}
|
||||||
|
"10115 Berlin"
|
||||||
|
br {}
|
||||||
|
"Germany"
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "Represented by" }
|
||||||
|
p { "Managing Director: [Name]" }
|
||||||
|
|
||||||
|
h2 { "Contact" }
|
||||||
|
p {
|
||||||
|
"Email: info@certifai.example"
|
||||||
|
br {}
|
||||||
|
"Phone: +49 (0) 30 1234567"
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "Commercial Register" }
|
||||||
|
p {
|
||||||
|
"Registered at: Amtsgericht Berlin-Charlottenburg"
|
||||||
|
br {}
|
||||||
|
"Registration number: HRB XXXXXX"
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "VAT ID" }
|
||||||
|
p { "VAT identification number according to 27a UStG: DE XXXXXXXXX" }
|
||||||
|
|
||||||
|
h2 { "Responsible for content according to 55 Abs. 2 RStV" }
|
||||||
|
p {
|
||||||
|
"[Name]"
|
||||||
|
br {}
|
||||||
|
"CERTifAI GmbH"
|
||||||
|
br {}
|
||||||
|
"Musterstrasse 1"
|
||||||
|
br {}
|
||||||
|
"10115 Berlin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footer { class: "legal-footer",
|
||||||
|
Link { to: Route::LandingPage {}, "Back to Home" }
|
||||||
|
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
419
src/pages/landing.rs
Normal file
419
src/pages/landing.rs
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::{
|
||||||
|
BsArrowRight, BsGlobe2, BsKey, BsRobot, BsServer, BsShieldCheck,
|
||||||
|
};
|
||||||
|
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
|
use crate::Route;
|
||||||
|
|
||||||
|
/// Public landing page for the CERTifAI platform.
|
||||||
|
///
|
||||||
|
/// Displays a marketing-oriented page with hero section, feature grid,
|
||||||
|
/// how-it-works steps, and call-to-action banners. This page is accessible
|
||||||
|
/// without authentication.
|
||||||
|
#[component]
|
||||||
|
pub fn LandingPage() -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "landing",
|
||||||
|
LandingNav {}
|
||||||
|
HeroSection {}
|
||||||
|
SocialProof {}
|
||||||
|
FeaturesGrid {}
|
||||||
|
HowItWorks {}
|
||||||
|
CtaBanner {}
|
||||||
|
LandingFooter {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sticky top navigation bar with logo, nav links, and CTA buttons.
|
||||||
|
#[component]
|
||||||
|
fn LandingNav() -> Element {
|
||||||
|
rsx! {
|
||||||
|
nav { class: "landing-nav",
|
||||||
|
div { class: "landing-nav-inner",
|
||||||
|
Link { to: Route::LandingPage {}, class: "landing-logo",
|
||||||
|
span { class: "landing-logo-icon",
|
||||||
|
Icon { icon: BsShieldCheck, width: 24, height: 24 }
|
||||||
|
}
|
||||||
|
span { "CERTifAI" }
|
||||||
|
}
|
||||||
|
div { class: "landing-nav-links",
|
||||||
|
a { href: "#features", "Features" }
|
||||||
|
a { href: "#how-it-works", "How It Works" }
|
||||||
|
a { href: "#pricing", "Pricing" }
|
||||||
|
}
|
||||||
|
div { class: "landing-nav-actions",
|
||||||
|
Link {
|
||||||
|
to: Route::Login { redirect_url: "/dashboard".into() },
|
||||||
|
class: "btn btn-ghost btn-sm",
|
||||||
|
"Log In"
|
||||||
|
}
|
||||||
|
Link {
|
||||||
|
to: Route::Login { redirect_url: "/dashboard".into() },
|
||||||
|
class: "btn btn-primary btn-sm",
|
||||||
|
"Get Started"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hero section with headline, subtitle, and CTA buttons.
|
||||||
|
#[component]
|
||||||
|
fn HeroSection() -> Element {
|
||||||
|
rsx! {
|
||||||
|
section { class: "hero-section",
|
||||||
|
div { class: "hero-content",
|
||||||
|
div { class: "hero-badge badge badge-outline",
|
||||||
|
"Privacy-First GenAI Infrastructure"
|
||||||
|
}
|
||||||
|
h1 { class: "hero-title",
|
||||||
|
"Your AI. Your Data."
|
||||||
|
br {}
|
||||||
|
span { class: "hero-title-accent", "Your Infrastructure." }
|
||||||
|
}
|
||||||
|
p { class: "hero-subtitle",
|
||||||
|
"Self-hosted, GDPR-compliant generative AI platform for "
|
||||||
|
"enterprises that refuse to compromise on data sovereignty. "
|
||||||
|
"Deploy LLMs, agents, and MCP servers on your own terms."
|
||||||
|
}
|
||||||
|
div { class: "hero-actions",
|
||||||
|
Link {
|
||||||
|
to: Route::Login { redirect_url: "/dashboard".into() },
|
||||||
|
class: "btn btn-primary btn-lg",
|
||||||
|
"Get Started"
|
||||||
|
Icon { icon: BsArrowRight, width: 18, height: 18 }
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
href: "#features",
|
||||||
|
class: "btn btn-outline btn-lg",
|
||||||
|
"Learn More"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "hero-graphic",
|
||||||
|
// Abstract shield/network SVG motif
|
||||||
|
svg {
|
||||||
|
view_box: "0 0 400 400",
|
||||||
|
fill: "none",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
// Gradient definitions
|
||||||
|
defs {
|
||||||
|
linearGradient {
|
||||||
|
id: "grad1",
|
||||||
|
x1: "0%", y1: "0%",
|
||||||
|
x2: "100%", y2: "100%",
|
||||||
|
stop { offset: "0%", stop_color: "#91a4d2" }
|
||||||
|
stop { offset: "100%", stop_color: "#6d85c6" }
|
||||||
|
}
|
||||||
|
linearGradient {
|
||||||
|
id: "grad2",
|
||||||
|
x1: "0%", y1: "100%",
|
||||||
|
x2: "100%", y2: "0%",
|
||||||
|
stop { offset: "0%", stop_color: "#f97066" }
|
||||||
|
stop { offset: "100%", stop_color: "#f9a066" }
|
||||||
|
}
|
||||||
|
radialGradient {
|
||||||
|
id: "glow",
|
||||||
|
cx: "50%", cy: "50%", r: "50%",
|
||||||
|
stop { offset: "0%", stop_color: "rgba(145,164,210,0.3)" }
|
||||||
|
stop { offset: "100%", stop_color: "rgba(145,164,210,0)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Background glow
|
||||||
|
circle { cx: "200", cy: "200", r: "180", fill: "url(#glow)" }
|
||||||
|
// Shield outline
|
||||||
|
path {
|
||||||
|
d: "M200 40 L340 110 L340 230 C340 300 270 360 200 380 \
|
||||||
|
C130 360 60 300 60 230 L60 110 Z",
|
||||||
|
stroke: "url(#grad1)",
|
||||||
|
stroke_width: "2",
|
||||||
|
fill: "none",
|
||||||
|
opacity: "0.6",
|
||||||
|
}
|
||||||
|
// Inner shield
|
||||||
|
path {
|
||||||
|
d: "M200 80 L310 135 L310 225 C310 280 255 330 200 345 \
|
||||||
|
C145 330 90 280 90 225 L90 135 Z",
|
||||||
|
stroke: "url(#grad1)",
|
||||||
|
stroke_width: "1.5",
|
||||||
|
fill: "rgba(145,164,210,0.05)",
|
||||||
|
opacity: "0.8",
|
||||||
|
}
|
||||||
|
// Network nodes
|
||||||
|
circle { cx: "200", cy: "180", r: "8", fill: "url(#grad1)" }
|
||||||
|
circle { cx: "150", cy: "230", r: "6", fill: "url(#grad2)" }
|
||||||
|
circle { cx: "250", cy: "230", r: "6", fill: "url(#grad2)" }
|
||||||
|
circle { cx: "200", cy: "280", r: "6", fill: "url(#grad1)" }
|
||||||
|
circle { cx: "130", cy: "170", r: "4", fill: "#91a4d2", opacity: "0.6" }
|
||||||
|
circle { cx: "270", cy: "170", r: "4", fill: "#91a4d2", opacity: "0.6" }
|
||||||
|
// Network connections
|
||||||
|
line {
|
||||||
|
x1: "200", y1: "180", x2: "150", y2: "230",
|
||||||
|
stroke: "#91a4d2", stroke_width: "1", opacity: "0.4",
|
||||||
|
}
|
||||||
|
line {
|
||||||
|
x1: "200", y1: "180", x2: "250", y2: "230",
|
||||||
|
stroke: "#91a4d2", stroke_width: "1", opacity: "0.4",
|
||||||
|
}
|
||||||
|
line {
|
||||||
|
x1: "150", y1: "230", x2: "200", y2: "280",
|
||||||
|
stroke: "#91a4d2", stroke_width: "1", opacity: "0.4",
|
||||||
|
}
|
||||||
|
line {
|
||||||
|
x1: "250", y1: "230", x2: "200", y2: "280",
|
||||||
|
stroke: "#91a4d2", stroke_width: "1", opacity: "0.4",
|
||||||
|
}
|
||||||
|
line {
|
||||||
|
x1: "200", y1: "180", x2: "130", y2: "170",
|
||||||
|
stroke: "#91a4d2", stroke_width: "1", opacity: "0.3",
|
||||||
|
}
|
||||||
|
line {
|
||||||
|
x1: "200", y1: "180", x2: "270", y2: "170",
|
||||||
|
stroke: "#91a4d2", stroke_width: "1", opacity: "0.3",
|
||||||
|
}
|
||||||
|
// Checkmark inside shield center
|
||||||
|
path {
|
||||||
|
d: "M180 200 L195 215 L225 185",
|
||||||
|
stroke: "url(#grad1)",
|
||||||
|
stroke_width: "3",
|
||||||
|
stroke_linecap: "round",
|
||||||
|
stroke_linejoin: "round",
|
||||||
|
fill: "none",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Social proof / trust indicator strip.
|
||||||
|
#[component]
|
||||||
|
fn SocialProof() -> Element {
|
||||||
|
rsx! {
|
||||||
|
section { class: "social-proof",
|
||||||
|
p { class: "social-proof-text",
|
||||||
|
"Built for enterprises that value "
|
||||||
|
span { class: "social-proof-highlight", "data sovereignty" }
|
||||||
|
}
|
||||||
|
div { class: "social-proof-stats",
|
||||||
|
div { class: "proof-stat",
|
||||||
|
span { class: "proof-stat-value", "100%" }
|
||||||
|
span { class: "proof-stat-label", "On-Premise" }
|
||||||
|
}
|
||||||
|
div { class: "proof-divider" }
|
||||||
|
div { class: "proof-stat",
|
||||||
|
span { class: "proof-stat-value", "GDPR" }
|
||||||
|
span { class: "proof-stat-label", "Compliant" }
|
||||||
|
}
|
||||||
|
div { class: "proof-divider" }
|
||||||
|
div { class: "proof-stat",
|
||||||
|
span { class: "proof-stat-value", "EU" }
|
||||||
|
span { class: "proof-stat-label", "Data Residency" }
|
||||||
|
}
|
||||||
|
div { class: "proof-divider" }
|
||||||
|
div { class: "proof-stat",
|
||||||
|
span { class: "proof-stat-value", "Zero" }
|
||||||
|
span { class: "proof-stat-label", "Third-Party Sharing" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feature cards grid section.
|
||||||
|
#[component]
|
||||||
|
fn FeaturesGrid() -> Element {
|
||||||
|
rsx! {
|
||||||
|
section { id: "features", class: "features-section",
|
||||||
|
h2 { class: "section-title", "Everything You Need" }
|
||||||
|
p { class: "section-subtitle",
|
||||||
|
"A complete, self-hosted GenAI stack under your full control."
|
||||||
|
}
|
||||||
|
div { class: "features-grid",
|
||||||
|
FeatureCard {
|
||||||
|
icon: rsx! { Icon { icon: BsServer, width: 28, height: 28 } },
|
||||||
|
title: "Self-Hosted Infrastructure",
|
||||||
|
description: "Deploy on your own hardware or private cloud. \
|
||||||
|
Full control over your AI stack with no external dependencies.",
|
||||||
|
}
|
||||||
|
FeatureCard {
|
||||||
|
icon: rsx! { Icon { icon: BsShieldCheck, width: 28, height: 28 } },
|
||||||
|
title: "GDPR Compliant",
|
||||||
|
description: "EU data residency guaranteed. Your data never \
|
||||||
|
leaves your infrastructure or gets shared with third parties.",
|
||||||
|
}
|
||||||
|
FeatureCard {
|
||||||
|
icon: rsx! { Icon { icon: FaCubes, width: 28, height: 28 } },
|
||||||
|
title: "LLM Management",
|
||||||
|
description: "Deploy, monitor, and manage multiple language \
|
||||||
|
models. Switch between models with zero downtime.",
|
||||||
|
}
|
||||||
|
FeatureCard {
|
||||||
|
icon: rsx! { Icon { icon: BsRobot, width: 28, height: 28 } },
|
||||||
|
title: "Agent Builder",
|
||||||
|
description: "Create custom AI agents with integrated Langchain \
|
||||||
|
and Langfuse for full observability and control.",
|
||||||
|
}
|
||||||
|
FeatureCard {
|
||||||
|
icon: rsx! { Icon { icon: BsGlobe2, width: 28, height: 28 } },
|
||||||
|
title: "MCP Server Management",
|
||||||
|
description: "Manage Model Context Protocol servers to extend \
|
||||||
|
your AI capabilities with external tool integrations.",
|
||||||
|
}
|
||||||
|
FeatureCard {
|
||||||
|
icon: rsx! { Icon { icon: BsKey, width: 28, height: 28 } },
|
||||||
|
title: "API Key Management",
|
||||||
|
description: "Generate API keys, track usage per seat, and \
|
||||||
|
set fine-grained permissions for every integration.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual feature card.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `icon` - The icon element to display
|
||||||
|
/// * `title` - Feature title
|
||||||
|
/// * `description` - Feature description text
|
||||||
|
#[component]
|
||||||
|
fn FeatureCard(icon: Element, title: &'static str, description: &'static str) -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "card feature-card",
|
||||||
|
div { class: "feature-card-icon", {icon} }
|
||||||
|
h3 { class: "feature-card-title", "{title}" }
|
||||||
|
p { class: "feature-card-desc", "{description}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three-step "How It Works" section.
|
||||||
|
#[component]
|
||||||
|
fn HowItWorks() -> Element {
|
||||||
|
rsx! {
|
||||||
|
section { id: "how-it-works", class: "how-it-works-section",
|
||||||
|
h2 { class: "section-title", "Up and Running in Minutes" }
|
||||||
|
p { class: "section-subtitle",
|
||||||
|
"Three steps to sovereign AI infrastructure."
|
||||||
|
}
|
||||||
|
div { class: "steps-grid",
|
||||||
|
StepCard {
|
||||||
|
number: "01",
|
||||||
|
title: "Deploy",
|
||||||
|
description: "Install CERTifAI on your infrastructure \
|
||||||
|
with a single command. Supports Docker, Kubernetes, \
|
||||||
|
and bare metal.",
|
||||||
|
}
|
||||||
|
StepCard {
|
||||||
|
number: "02",
|
||||||
|
title: "Configure",
|
||||||
|
description: "Connect your identity provider, select \
|
||||||
|
your models, and set up team permissions through \
|
||||||
|
the admin dashboard.",
|
||||||
|
}
|
||||||
|
StepCard {
|
||||||
|
number: "03",
|
||||||
|
title: "Scale",
|
||||||
|
description: "Add users, deploy more models, and \
|
||||||
|
integrate with your existing tools via API keys \
|
||||||
|
and MCP servers.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual step card.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `number` - Step number string (e.g. "01")
|
||||||
|
/// * `title` - Step title
|
||||||
|
/// * `description` - Step description text
|
||||||
|
#[component]
|
||||||
|
fn StepCard(number: &'static str, title: &'static str, description: &'static str) -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "step-card",
|
||||||
|
span { class: "step-number", "{number}" }
|
||||||
|
h3 { class: "step-title", "{title}" }
|
||||||
|
p { class: "step-desc", "{description}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call-to-action banner before the footer.
|
||||||
|
#[component]
|
||||||
|
fn CtaBanner() -> Element {
|
||||||
|
rsx! {
|
||||||
|
section { class: "cta-banner",
|
||||||
|
h2 { class: "cta-title",
|
||||||
|
"Ready to take control of your AI infrastructure?"
|
||||||
|
}
|
||||||
|
p { class: "cta-subtitle",
|
||||||
|
"Start deploying sovereign GenAI today. No credit card required."
|
||||||
|
}
|
||||||
|
div { class: "cta-actions",
|
||||||
|
Link {
|
||||||
|
to: Route::Login { redirect_url: "/dashboard".into() },
|
||||||
|
class: "btn btn-primary btn-lg",
|
||||||
|
"Get Started Free"
|
||||||
|
Icon { icon: BsArrowRight, width: 18, height: 18 }
|
||||||
|
}
|
||||||
|
Link {
|
||||||
|
to: Route::Login { redirect_url: "/dashboard".into() },
|
||||||
|
class: "btn btn-outline btn-lg",
|
||||||
|
"Log In"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Landing page footer with links and copyright.
|
||||||
|
#[component]
|
||||||
|
fn LandingFooter() -> Element {
|
||||||
|
rsx! {
|
||||||
|
footer { class: "landing-footer",
|
||||||
|
div { class: "landing-footer-inner",
|
||||||
|
div { class: "footer-brand",
|
||||||
|
div { class: "landing-logo",
|
||||||
|
span { class: "landing-logo-icon",
|
||||||
|
Icon { icon: BsShieldCheck, width: 20, height: 20 }
|
||||||
|
}
|
||||||
|
span { "CERTifAI" }
|
||||||
|
}
|
||||||
|
p { class: "footer-tagline",
|
||||||
|
"Sovereign GenAI infrastructure for enterprises."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "footer-links-group",
|
||||||
|
h4 { class: "footer-links-heading", "Product" }
|
||||||
|
a { href: "#features", "Features" }
|
||||||
|
a { href: "#how-it-works", "How It Works" }
|
||||||
|
a { href: "#pricing", "Pricing" }
|
||||||
|
}
|
||||||
|
div { class: "footer-links-group",
|
||||||
|
h4 { class: "footer-links-heading", "Legal" }
|
||||||
|
Link { to: Route::ImpressumPage {}, "Impressum" }
|
||||||
|
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
|
||||||
|
}
|
||||||
|
div { class: "footer-links-group",
|
||||||
|
h4 { class: "footer-links-heading", "Resources" }
|
||||||
|
a { href: "#", "Documentation" }
|
||||||
|
a { href: "#", "API Reference" }
|
||||||
|
a { href: "#", "Support" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "footer-bottom",
|
||||||
|
p { "2026 CERTifAI. All rights reserved." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,9 @@
|
|||||||
|
mod impressum;
|
||||||
|
mod landing;
|
||||||
mod overview;
|
mod overview;
|
||||||
|
mod privacy;
|
||||||
|
|
||||||
|
pub use impressum::*;
|
||||||
|
pub use landing::*;
|
||||||
pub use overview::*;
|
pub use overview::*;
|
||||||
|
pub use privacy::*;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pub fn OverviewPage() -> Element {
|
|||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
if let Some(Ok(false)) = auth_check() {
|
if let Some(Ok(false)) = auth_check() {
|
||||||
navigator.push(NavigationTarget::<Route>::External(
|
navigator.push(NavigationTarget::<Route>::External(
|
||||||
"/auth?redirect_url=/".into(),
|
"/auth?redirect_url=/dashboard".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -40,11 +40,7 @@ pub fn OverviewPage() -> Element {
|
|||||||
description: "Guides & API Reference".to_string(),
|
description: "Guides & API Reference".to_string(),
|
||||||
href: "#".to_string(),
|
href: "#".to_string(),
|
||||||
icon: rsx! {
|
icon: rsx! {
|
||||||
Icon {
|
Icon { icon: BsBook, width: 28, height: 28 }
|
||||||
icon: BsBook,
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
DashboardCard {
|
DashboardCard {
|
||||||
@@ -52,11 +48,7 @@ pub fn OverviewPage() -> Element {
|
|||||||
description: "Observability & Analytics".to_string(),
|
description: "Observability & Analytics".to_string(),
|
||||||
href: "#".to_string(),
|
href: "#".to_string(),
|
||||||
icon: rsx! {
|
icon: rsx! {
|
||||||
Icon {
|
Icon { icon: FaChartLine, width: 28, height: 28 }
|
||||||
icon: FaChartLine,
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
DashboardCard {
|
DashboardCard {
|
||||||
@@ -64,11 +56,7 @@ pub fn OverviewPage() -> Element {
|
|||||||
description: "Agent Framework".to_string(),
|
description: "Agent Framework".to_string(),
|
||||||
href: "#".to_string(),
|
href: "#".to_string(),
|
||||||
icon: rsx! {
|
icon: rsx! {
|
||||||
Icon {
|
Icon { icon: FaGears, width: 28, height: 28 }
|
||||||
icon: FaGears,
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
DashboardCard {
|
DashboardCard {
|
||||||
@@ -76,11 +64,7 @@ pub fn OverviewPage() -> Element {
|
|||||||
description: "Browse Models".to_string(),
|
description: "Browse Models".to_string(),
|
||||||
href: "#".to_string(),
|
href: "#".to_string(),
|
||||||
icon: rsx! {
|
icon: rsx! {
|
||||||
Icon {
|
Icon { icon: FaCubes, width: 28, height: 28 }
|
||||||
icon: FaCubes,
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
110
src/pages/privacy.rs
Normal file
110
src/pages/privacy.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
|
use crate::Route;
|
||||||
|
|
||||||
|
/// Privacy Policy page.
|
||||||
|
///
|
||||||
|
/// Displays the platform's privacy policy. Publicly accessible
|
||||||
|
/// without authentication.
|
||||||
|
#[component]
|
||||||
|
pub fn PrivacyPage() -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "legal-page",
|
||||||
|
nav { class: "legal-nav",
|
||||||
|
Link { to: Route::LandingPage {}, class: "landing-logo",
|
||||||
|
span { class: "landing-logo-icon",
|
||||||
|
Icon { icon: BsShieldCheck, width: 20, height: 20 }
|
||||||
|
}
|
||||||
|
span { "CERTifAI" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main { class: "legal-content",
|
||||||
|
h1 { "Privacy Policy" }
|
||||||
|
p { class: "legal-updated",
|
||||||
|
"Last updated: February 2026"
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "1. Introduction" }
|
||||||
|
p {
|
||||||
|
"CERTifAI GmbH (\"we\", \"our\", \"us\") is committed to "
|
||||||
|
"protecting your personal data. This privacy policy explains "
|
||||||
|
"how we collect, use, and safeguard your information when you "
|
||||||
|
"use our platform."
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "2. Data Controller" }
|
||||||
|
p {
|
||||||
|
"CERTifAI GmbH"
|
||||||
|
br {}
|
||||||
|
"Musterstrasse 1, 10115 Berlin, Germany"
|
||||||
|
br {}
|
||||||
|
"Email: privacy@certifai.example"
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "3. Data We Collect" }
|
||||||
|
p {
|
||||||
|
"We collect only the minimum data necessary to provide "
|
||||||
|
"our services:"
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
li {
|
||||||
|
strong { "Account data: " }
|
||||||
|
"Name, email address, and organization details "
|
||||||
|
"provided during registration."
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
strong { "Usage data: " }
|
||||||
|
"API call logs, token counts, and feature usage "
|
||||||
|
"metrics for billing and analytics."
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
strong { "Technical data: " }
|
||||||
|
"IP addresses, browser type, and session identifiers "
|
||||||
|
"for security and platform stability."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "4. How We Use Your Data" }
|
||||||
|
ul {
|
||||||
|
li { "To provide and maintain the CERTifAI platform" }
|
||||||
|
li { "To manage your account and subscription" }
|
||||||
|
li { "To communicate service updates and security notices" }
|
||||||
|
li { "To comply with legal obligations" }
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "5. Data Storage and Sovereignty" }
|
||||||
|
p {
|
||||||
|
"CERTifAI is a self-hosted platform. All AI workloads, "
|
||||||
|
"model data, and inference results remain entirely within "
|
||||||
|
"your own infrastructure. We do not access, store, or "
|
||||||
|
"process your AI data on our servers."
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "6. Your Rights (GDPR)" }
|
||||||
|
p { "Under the GDPR, you have the right to:" }
|
||||||
|
ul {
|
||||||
|
li { "Access your personal data" }
|
||||||
|
li { "Rectify inaccurate data" }
|
||||||
|
li { "Request erasure of your data" }
|
||||||
|
li { "Restrict or object to processing" }
|
||||||
|
li { "Data portability" }
|
||||||
|
li {
|
||||||
|
"Lodge a complaint with a supervisory authority"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { "7. Contact" }
|
||||||
|
p {
|
||||||
|
"For privacy-related inquiries, contact us at "
|
||||||
|
"privacy@certifai.example."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footer { class: "legal-footer",
|
||||||
|
Link { to: Route::LandingPage {}, "Back to Home" }
|
||||||
|
Link { to: Route::ImpressumPage {}, "Impressum" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
styles/input.css
Normal file
112
styles/input.css
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui";
|
||||||
|
|
||||||
|
/* ===== CERTifAI Dark Theme (default) ===== */
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: "certifai-dark";
|
||||||
|
default: true;
|
||||||
|
prefersdark: true;
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
/* Base: deep navy-charcoal */
|
||||||
|
--color-base-100: oklch(18% 0.02 260);
|
||||||
|
--color-base-200: oklch(14% 0.02 260);
|
||||||
|
--color-base-300: oklch(11% 0.02 260);
|
||||||
|
--color-base-content: oklch(90% 0.01 260);
|
||||||
|
|
||||||
|
/* Primary: electric indigo */
|
||||||
|
--color-primary: oklch(62% 0.26 275);
|
||||||
|
--color-primary-content: oklch(98% 0.01 275);
|
||||||
|
|
||||||
|
/* Secondary: coral */
|
||||||
|
--color-secondary: oklch(68% 0.18 25);
|
||||||
|
--color-secondary-content: oklch(98% 0.01 25);
|
||||||
|
|
||||||
|
/* Accent: teal */
|
||||||
|
--color-accent: oklch(72% 0.15 185);
|
||||||
|
--color-accent-content: oklch(12% 0.03 185);
|
||||||
|
|
||||||
|
/* Neutral */
|
||||||
|
--color-neutral: oklch(25% 0.02 260);
|
||||||
|
--color-neutral-content: oklch(85% 0.01 260);
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--color-info: oklch(70% 0.18 230);
|
||||||
|
--color-info-content: oklch(98% 0.01 230);
|
||||||
|
|
||||||
|
--color-success: oklch(68% 0.19 145);
|
||||||
|
--color-success-content: oklch(98% 0.01 145);
|
||||||
|
|
||||||
|
--color-warning: oklch(82% 0.22 85);
|
||||||
|
--color-warning-content: oklch(18% 0.04 85);
|
||||||
|
|
||||||
|
--color-error: oklch(65% 0.26 25);
|
||||||
|
--color-error-content: oklch(98% 0.01 25);
|
||||||
|
|
||||||
|
/* Sharp, modern radii */
|
||||||
|
--radius-selector: 0.25rem;
|
||||||
|
--radius-field: 0.25rem;
|
||||||
|
--radius-box: 0.5rem;
|
||||||
|
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
|
||||||
|
--border: 1px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CERTifAI Light Theme ===== */
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: "certifai-light";
|
||||||
|
default: false;
|
||||||
|
prefersdark: false;
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
/* Base: clean off-white */
|
||||||
|
--color-base-100: oklch(98% 0.005 260);
|
||||||
|
--color-base-200: oklch(95% 0.008 260);
|
||||||
|
--color-base-300: oklch(91% 0.012 260);
|
||||||
|
--color-base-content: oklch(20% 0.03 260);
|
||||||
|
|
||||||
|
/* Primary: indigo (adjusted for light bg) */
|
||||||
|
--color-primary: oklch(50% 0.26 275);
|
||||||
|
--color-primary-content: oklch(98% 0.01 275);
|
||||||
|
|
||||||
|
/* Secondary: coral (adjusted for light bg) */
|
||||||
|
--color-secondary: oklch(58% 0.18 25);
|
||||||
|
--color-secondary-content: oklch(98% 0.01 25);
|
||||||
|
|
||||||
|
/* Accent: teal (adjusted for light bg) */
|
||||||
|
--color-accent: oklch(55% 0.15 185);
|
||||||
|
--color-accent-content: oklch(98% 0.01 185);
|
||||||
|
|
||||||
|
/* Neutral */
|
||||||
|
--color-neutral: oklch(35% 0.02 260);
|
||||||
|
--color-neutral-content: oklch(98% 0.01 260);
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--color-info: oklch(55% 0.18 230);
|
||||||
|
--color-info-content: oklch(98% 0.01 230);
|
||||||
|
|
||||||
|
--color-success: oklch(52% 0.19 145);
|
||||||
|
--color-success-content: oklch(98% 0.01 145);
|
||||||
|
|
||||||
|
--color-warning: oklch(72% 0.22 85);
|
||||||
|
--color-warning-content: oklch(18% 0.04 85);
|
||||||
|
|
||||||
|
--color-error: oklch(55% 0.26 25);
|
||||||
|
--color-error-content: oklch(98% 0.01 25);
|
||||||
|
|
||||||
|
/* Same sharp radii */
|
||||||
|
--radius-selector: 0.25rem;
|
||||||
|
--radius-field: 0.25rem;
|
||||||
|
--radius-box: 0.5rem;
|
||||||
|
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
|
||||||
|
--border: 1px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
Reference in New Issue
Block a user