4 Commits

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

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

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

View File

@@ -164,14 +164,8 @@ jobs:
run: cargo install git-cliff --locked
- name: Generate changelog
run: git cliff --output CHANGELOG.md
- name: Commit and push changelog
run: |
git config user.name "CI Bot"
git config user.email "ci@certifai.local"
git add CHANGELOG.md
if git diff --cached --quiet; then
echo "No changelog changes to commit"
else
git commit -m "docs: update CHANGELOG.md [skip ci]"
git push origin HEAD:"${GITHUB_REF_NAME}"
fi
- name: Upload changelog artifact
uses: actions/upload-artifact@v4
with:
name: changelog
path: CHANGELOG.md

8
.gitignore vendored
View File

@@ -12,5 +12,9 @@
# Logs
*.log
# Keycloak data
keycloak/
# Keycloak runtime data (but keep realm-export.json)
keycloak/*
!keycloak/realm-export.json
# Node modules
node_modules/

2
Cargo.lock generated
View File

@@ -739,6 +739,7 @@ version = "0.1.0"
dependencies = [
"async-stripe",
"axum",
"base64 0.22.1",
"chrono",
"dioxus",
"dioxus-cli-config",
@@ -755,6 +756,7 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"time",
"tokio",

View File

@@ -73,6 +73,8 @@ dioxus-free-icons = { version = "0.10", features = [
"bootstrap",
"font-awesome-solid",
] }
sha2 = { version = "0.10.9", default-features = false, optional = true }
base64 = { version = "0.22.1", default-features = false, optional = true }
[features]
# default = ["web"]
@@ -87,6 +89,8 @@ server = [
"dep:time",
"dep:rand",
"dep:url",
"dep:sha2",
"dep:base64",
]
[[bin]]

View File

@@ -19,12 +19,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:$PATH"
# Install dx CLI via cargo-binstall
RUN curl -L --proto '=https' --tlsv1.2 -sSf \
https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh \
| bash
RUN cargo binstall dioxus-cli@0.7.3 --root /.cargo -y --force
ENV PATH="/.cargo/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
@@ -33,11 +29,11 @@ RUN cargo chef cook --release --recipe-path recipe.json
# Copy source and build
COPY . .
# Ensure styles directory exists for build.rs tailwind step
RUN mkdir -p styles && touch styles/input.css
# Install frontend dependencies (DaisyUI, Tailwind) for the build.rs CSS step
RUN bun install --frozen-lockfile
# Bundle the fullstack application
RUN dx bundle --platform fullstack
RUN dx bundle --release --fullstack
# Stage 3: Minimal runtime image
FROM debian:bookworm-slim AS runtime
@@ -58,4 +54,4 @@ EXPOSE 8000
ENV IP=0.0.0.0
ENV PORT=8000
ENTRYPOINT ["./server"]
ENTRYPOINT ["./dashboard"]

25
assets/logo.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

File diff suppressed because it is too large Load Diff

33
bun.lock Normal file
View File

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

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

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

16
package.json Normal file
View File

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

View File

@@ -3,14 +3,20 @@ use dioxus::prelude::*;
/// Application routes.
///
/// `OverviewPage` is wrapped in the `AppShell` layout so the sidebar
/// renders around every authenticated page. The `/login` route remains
/// outside the shell (unauthenticated).
/// Public pages (`LandingPage`, `ImpressumPage`, `PrivacyPage`) live
/// outside the `AppShell` layout. Authenticated pages like `OverviewPage`
/// are wrapped in `AppShell` which renders the sidebar.
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
pub enum Route {
#[route("/")]
LandingPage {},
#[route("/impressum")]
ImpressumPage {},
#[route("/privacy")]
PrivacyPage {},
#[layout(AppShell)]
#[route("/")]
#[route("/dashboard")]
OverviewPage {},
#[end_layout]
#[route("/login?:redirect_url")]
@@ -39,8 +45,8 @@ pub fn App() -> Element {
crossorigin: "anonymous",
}
document::Link { rel: "stylesheet", href: GOOGLE_FONTS }
document::Link { rel: "stylesheet", href: MAIN_CSS }
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
Router::<Route> {}
document::Link { rel: "stylesheet", href: MAIN_CSS }
div { "data-theme": "certifai-dark", Router::<Route> {} }
}
}

View File

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

View File

@@ -16,28 +16,37 @@ use crate::infrastructure::{state::User, Error, UserStateInner};
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
/// In-memory store for pending OAuth states and their associated redirect
/// URLs. 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).
/// Data stored alongside each pending OAuth state. Holds the optional
/// post-login redirect URL and the PKCE code verifier needed for the
/// token exchange.
#[derive(Debug, Clone)]
struct PendingOAuthEntry {
redirect_url: Option<String>,
code_verifier: String,
}
/// In-memory store for pending OAuth states. Keyed by the random state
/// string. This avoids dependence on the session cookie surviving the
/// Keycloak redirect round-trip (the `dx serve` proxy can drop
/// `Set-Cookie` headers on 307 responses).
#[derive(Debug, Clone, Default)]
pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, Option<String>>>>);
pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
impl PendingOAuthStore {
/// Insert a pending state with an optional post-login redirect URL.
fn insert(&self, state: String, redirect_url: Option<String>) {
/// Insert a pending state with an optional redirect URL and PKCE verifier.
fn insert(&self, state: String, entry: PendingOAuthEntry) {
// RwLock::write only panics if the lock is poisoned, which
// indicates a prior panic -- propagating is acceptable here.
#[allow(clippy::expect_used)]
self.0
.write()
.expect("pending oauth store lock poisoned")
.insert(state, 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).
fn take(&self, state: &str) -> Option<Option<String>> {
fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
#[allow(clippy::expect_used)]
self.0
.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.
///
/// Generates a random CSRF state, stores it (along with the optional
@@ -142,9 +173,17 @@ pub async fn auth_login(
) -> Result<impl IntoResponse, Error> {
let config = OAuthConfig::from_env()?;
let state = generate_state();
let code_verifier = generate_code_verifier();
let code_challenge = derive_code_challenge(&code_verifier);
let redirect_url = params.get("redirect_url").cloned();
pending.insert(state.clone(), redirect_url);
pending.insert(
state.clone(),
PendingOAuthEntry {
redirect_url,
code_verifier,
},
);
let mut url = Url::parse(&config.auth_endpoint())
.map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?;
@@ -154,7 +193,9 @@ pub async fn auth_login(
.append_pair("redirect_uri", &config.redirect_uri)
.append_pair("response_type", "code")
.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()))
}
@@ -203,11 +244,11 @@ pub async fn auth_callback(
.get("state")
.ok_or_else(|| Error::StateError("missing state parameter".into()))?;
let redirect_url = pending
let entry = pending
.take(returned_state)
.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
.get("code")
.ok_or_else(|| Error::StateError("missing code parameter".into()))?;
@@ -220,6 +261,7 @@ pub async fn auth_callback(
("client_id", &config.client_id),
("redirect_uri", &config.redirect_uri),
("code", code),
("code_verifier", &entry.code_verifier),
])
.send()
.await
@@ -259,7 +301,8 @@ pub async fn auth_callback(
set_login_session(session, user_state).await?;
let target = redirect_url
let target = entry
.redirect_url
.filter(|u| !u.is_empty())
.unwrap_or_else(|| "/".into());

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

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

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

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

View File

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

View File

@@ -20,7 +20,7 @@ pub fn OverviewPage() -> Element {
use_effect(move || {
if let Some(Ok(false)) = auth_check() {
navigator.push(NavigationTarget::<Route>::External(
"/auth?redirect_url=/".into(),
"/auth?redirect_url=/dashboard".into(),
));
}
});

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

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

112
styles/input.css Normal file
View File

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

View File

@@ -1 +0,0 @@
@import "tailwindcss";