diff --git a/.env.example b/.env.example index a62d8a6..bc49c38 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,80 @@ -# Keycloak Configuration (frontend public client) +# ============================================================================ +# CERTifAI Dashboard - Environment Variables +# ============================================================================ +# Copy this file to .env and fill in the values. +# Variables marked [REQUIRED] must be set; others have sensible defaults. + +# --------------------------------------------------------------------------- +# Keycloak Configuration (frontend public client) [REQUIRED] +# --------------------------------------------------------------------------- KEYCLOAK_URL=http://localhost:8080 KEYCLOAK_REALM=certifai KEYCLOAK_CLIENT_ID=certifai-dashboard -# Application Configuration +# Keycloak admin / service-account client (server-to-server calls) [OPTIONAL] +KEYCLOAK_ADMIN_CLIENT_ID= +KEYCLOAK_ADMIN_CLIENT_SECRET= + +# --------------------------------------------------------------------------- +# Application Configuration [REQUIRED] +# --------------------------------------------------------------------------- APP_URL=http://localhost:8000 REDIRECT_URI=http://localhost:8000/auth/callback ALLOWED_ORIGINS=http://localhost:8000 -# SearXNG meta-search engine +# --------------------------------------------------------------------------- +# MongoDB [OPTIONAL - defaults shown] +# --------------------------------------------------------------------------- +MONGODB_URI=mongodb://localhost:27017 +MONGODB_DATABASE=certifai + +# --------------------------------------------------------------------------- +# SearXNG meta-search engine [OPTIONAL - default: http://localhost:8888] +# --------------------------------------------------------------------------- SEARXNG_URL=http://localhost:8888 -# Ollama LLM instance (used for article summarization and chat) -OLLAMA_URL=http://mac-mini-von-benjamin-2:11434 -OLLAMA_MODEL=qwen3:30b-a3b +# --------------------------------------------------------------------------- +# Ollama LLM instance [OPTIONAL - defaults shown] +# --------------------------------------------------------------------------- +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=llama3.1:8b + +# --------------------------------------------------------------------------- +# LLM Providers (comma-separated list) [OPTIONAL] +# --------------------------------------------------------------------------- +LLM_PROVIDERS=ollama + +# --------------------------------------------------------------------------- +# SMTP (transactional email) [OPTIONAL] +# --------------------------------------------------------------------------- +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_ADDRESS= + +# --------------------------------------------------------------------------- +# Stripe billing [OPTIONAL] +# --------------------------------------------------------------------------- +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PUBLISHABLE_KEY= + +# --------------------------------------------------------------------------- +# LangChain / LangGraph / Langfuse [OPTIONAL] +# --------------------------------------------------------------------------- +LANGCHAIN_URL= +LANGGRAPH_URL= +LANGFUSE_URL= + +# --------------------------------------------------------------------------- +# Vector database [OPTIONAL] +# --------------------------------------------------------------------------- +VECTORDB_URL= + +# --------------------------------------------------------------------------- +# S3-compatible object storage [OPTIONAL] +# --------------------------------------------------------------------------- +S3_URL= +S3_ACCESS_KEY= +S3_SECRET_KEY= diff --git a/Cargo.toml b/Cargo.toml index b5dd7e5..2064284 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,12 @@ maud = { version = "0.27", default-features = false } url = { version = "2.5.4", default-features = false, optional = true } web-sys = { version = "0.3", optional = true, features = [ "Clipboard", + "Document", + "Element", + "HtmlElement", "Navigator", + "Storage", + "Window", ] } tracing = "0.1.40" # Debug @@ -93,6 +98,8 @@ server = [ "dep:sha2", "dep:base64", "dep:scraper", + "dep:secrecy", + "dep:petname", ] [[bin]] diff --git a/assets/main.css b/assets/main.css index 80e702a..c283179 100644 --- a/assets/main.css +++ b/assets/main.css @@ -1,8 +1,43 @@ +/* ===== Theme Variables ===== */ +:root { + --bg-body: #0f1116; + --bg-sidebar: #0a0c10; + --bg-card: #1a1d26; + --bg-surface: #1e222d; + --text-primary: #e2e8f0; + --text-heading: #f1f5f9; + --text-muted: #8892a8; + --text-faint: #5a6478; + --text-dimmest: #3d4556; + --border-primary: #1e222d; + --border-secondary: #2a2f3d; + --accent: #91a4d2; + --accent-secondary: #6d85c6; + --avatar-text: #0a0c10; +} + +[data-theme="certifai-light"] { + --bg-body: #f4f6f9; + --bg-sidebar: #ffffff; + --bg-card: #ffffff; + --bg-surface: #e9ecf2; + --text-primary: #1e293b; + --text-heading: #0f172a; + --text-muted: #64748b; + --text-faint: #94a3b8; + --text-dimmest: #cbd5e1; + --border-primary: #e2e8f0; + --border-secondary: #cbd5e1; + --accent: #5570b8; + --accent-secondary: #3d5aaf; + --avatar-text: #ffffff; +} + /* ===== Fonts ===== */ body { font-family: 'Inter', sans-serif; - background-color: #0f1116; - color: #e2e8f0; + background-color: var(--bg-body); + color: var(--text-primary); margin: 0; padding: 0; } @@ -26,8 +61,8 @@ h6 { .sidebar { width: 260px; min-width: 260px; - background-color: #0a0c10; - border-right: 1px solid #1e222d; + background-color: var(--bg-sidebar); + border-right: 1px solid var(--border-primary); display: flex; flex-direction: column; height: 100vh; @@ -41,7 +76,7 @@ h6 { align-items: center; gap: 12px; padding: 24px 20px 20px; - border-bottom: 1px solid #1e222d; + border-bottom: 1px solid var(--border-primary); } .avatar-circle { @@ -49,7 +84,7 @@ h6 { height: 38px; min-width: 38px; border-radius: 50%; - background: linear-gradient(135deg, #91a4d2, #6d85c6); + background: linear-gradient(135deg, var(--accent), var(--accent-secondary)); display: flex; align-items: center; justify-content: center; @@ -59,12 +94,28 @@ h6 { font-family: 'Space Grotesk', sans-serif; font-size: 14px; font-weight: 600; - color: #0a0c10; + color: var(--avatar-text); +} + +.sidebar-user-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.sidebar-name { + font-size: 14px; + font-weight: 600; + color: var(--text-heading); + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .sidebar-email { - font-size: 13px; - color: #8892a8; + font-size: 12px; + color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -85,7 +136,7 @@ h6 { gap: 12px; padding: 10px 14px; border-radius: 8px; - color: #8892a8; + color: var(--text-muted); text-decoration: none; font-size: 14px; font-weight: 500; @@ -93,23 +144,26 @@ h6 { } .sidebar-link:hover { - background-color: #1e222d; - color: #e2e8f0; + background-color: var(--bg-surface); + color: var(--text-primary); } .sidebar-link.active { background-color: rgba(145, 164, 210, 0.12); - color: #91a4d2; + color: var(--accent); } -/* -- Sidebar Logout -- */ -.sidebar-logout { +/* -- Sidebar Bottom Actions (Logout + Theme Toggle) -- */ +.sidebar-bottom-actions { + display: flex; + align-items: center; + justify-content: space-between; padding: 4px 10px; - border-top: 1px solid #1e222d; + border-top: 1px solid var(--border-primary); } .logout-btn { - color: #8892a8; + color: var(--text-muted); } .logout-btn:hover { @@ -117,10 +171,29 @@ h6 { background-color: rgba(248, 113, 113, 0.08); } +.theme-toggle-btn { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: color 0.2s, background-color 0.2s; +} + +.theme-toggle-btn:hover { + color: var(--accent); + background-color: var(--bg-surface); +} + /* -- Sidebar Footer -- */ .sidebar-footer { padding: 16px 20px; - border-top: 1px solid #1e222d; + border-top: 1px solid var(--border-primary); display: flex; flex-direction: column; align-items: center; @@ -133,18 +206,18 @@ h6 { } .social-link { - color: #5a6478; + color: var(--text-faint); transition: color 0.15s ease; text-decoration: none; } .social-link:hover { - color: #91a4d2; + color: var(--accent); } .sidebar-version { font-size: 11px; - color: #3d4556; + color: var(--text-dimmest); font-family: 'Inter', monospace; } @@ -164,7 +237,7 @@ h6 { .overview-heading { font-size: 28px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); margin-bottom: 32px; } @@ -187,33 +260,33 @@ h6 { flex-direction: column; gap: 12px; padding: 24px; - background-color: #1e222d; - border: 1px solid #2a2f3d; + background-color: var(--bg-surface); + border: 1px solid var(--border-secondary); border-radius: 12px; text-decoration: none; - color: #e2e8f0; + color: var(--text-primary); transition: border-color 0.2s ease, box-shadow 0.2s ease; } .dashboard-card:hover { - border-color: #91a4d2; + border-color: var(--accent); box-shadow: 0 0 20px rgba(145, 164, 210, 0.10); } .card-icon { - color: #91a4d2; + color: var(--accent); } .card-title { font-size: 18px; font-weight: 600; - color: #f1f5f9; + color: var(--text-heading); margin: 0; } .card-description { font-size: 14px; - color: #8892a8; + color: var(--text-muted); margin: 0; } @@ -229,9 +302,9 @@ h6 { position: sticky; top: 0; z-index: 100; - background-color: rgba(15, 17, 22, 0.85); + background-color: color-mix(in srgb, var(--bg-body) 85%, transparent); backdrop-filter: blur(12px); - border-bottom: 1px solid #1e222d; + border-bottom: 1px solid var(--border-primary); } .landing-nav-inner { @@ -250,12 +323,12 @@ h6 { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); text-decoration: none; } .landing-logo-icon { - color: #91a4d2; + color: var(--accent); display: flex; align-items: center; } @@ -267,7 +340,7 @@ h6 { } .landing-nav-links a { - color: #8892a8; + color: var(--text-muted); text-decoration: none; font-size: 14px; font-weight: 500; @@ -275,7 +348,7 @@ h6 { } .landing-nav-links a:hover { - color: #e2e8f0; + color: var(--text-primary); } .landing-nav-actions { @@ -305,7 +378,7 @@ h6 { .hero-badge { font-size: 13px; font-weight: 500; - color: #91a4d2; + color: var(--accent); border-color: rgba(145, 164, 210, 0.3); margin-bottom: 24px; } @@ -314,13 +387,13 @@ h6 { font-size: 52px; font-weight: 700; line-height: 1.1; - color: #f1f5f9; + color: var(--text-heading); margin: 0 0 24px; width: 100%; } .hero-title-accent { - background: linear-gradient(135deg, #91a4d2, #6d85c6); + background: linear-gradient(135deg, var(--accent), var(--accent-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -329,7 +402,7 @@ h6 { .hero-subtitle { font-size: 18px; line-height: 1.7; - color: #8892a8; + color: var(--text-muted); margin: 0 0 36px; max-width: 520px; width: 100%; @@ -351,20 +424,20 @@ h6 { /* -- Social Proof -- */ .social-proof { - border-top: 1px solid #1e222d; - border-bottom: 1px solid #1e222d; + border-top: 1px solid var(--border-primary); + border-bottom: 1px solid var(--border-primary); padding: 40px 32px; text-align: center; } .social-proof-text { font-size: 16px; - color: #8892a8; + color: var(--text-muted); margin: 0 0 28px; } .social-proof-highlight { - color: #91a4d2; + color: var(--accent); font-weight: 600; } @@ -387,12 +460,12 @@ h6 { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); } .proof-stat-label { font-size: 13px; - color: #5a6478; + color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.05em; } @@ -400,21 +473,21 @@ h6 { .proof-divider { width: 1px; height: 40px; - background-color: #1e222d; + background-color: var(--bg-surface); } /* -- Section Titles -- */ .section-title { font-size: 36px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); text-align: center; margin: 0 0 12px; } .section-subtitle { font-size: 18px; - color: #8892a8; + color: var(--text-muted); text-align: center; margin: 0 0 48px; } @@ -433,34 +506,34 @@ h6 { } .feature-card { - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 12px; padding: 32px 28px; transition: border-color 0.2s ease, transform 0.2s ease; } .feature-card:hover { - border-color: #91a4d2; + border-color: var(--accent); transform: translateY(-2px); } .feature-card-icon { - color: #91a4d2; + color: var(--accent); margin-bottom: 16px; } .feature-card-title { font-size: 18px; font-weight: 600; - color: #f1f5f9; + color: var(--text-heading); margin: 0 0 8px; } .feature-card-desc { font-size: 14px; line-height: 1.6; - color: #8892a8; + color: var(--text-muted); margin: 0; } @@ -486,7 +559,7 @@ h6 { font-family: 'Space Grotesk', sans-serif; font-size: 48px; font-weight: 700; - background: linear-gradient(135deg, #91a4d2, #6d85c6); + background: linear-gradient(135deg, var(--accent), var(--accent-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -497,14 +570,14 @@ h6 { .step-title { font-size: 22px; font-weight: 600; - color: #f1f5f9; + color: var(--text-heading); margin: 0 0 12px; } .step-desc { font-size: 15px; line-height: 1.6; - color: #8892a8; + color: var(--text-muted); margin: 0; } @@ -524,13 +597,13 @@ h6 { .cta-title { font-size: 32px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); margin: 0 0 12px; } .cta-subtitle { font-size: 18px; - color: #8892a8; + color: var(--text-muted); margin: 0 0 32px; } @@ -542,7 +615,7 @@ h6 { /* -- Landing Footer -- */ .landing-footer { - border-top: 1px solid #1e222d; + border-top: 1px solid var(--border-primary); padding: 60px 32px 0; margin-top: auto; } @@ -563,7 +636,7 @@ h6 { .footer-tagline { font-size: 14px; - color: #5a6478; + color: var(--text-faint); margin: 0; max-width: 280px; } @@ -577,7 +650,7 @@ h6 { .footer-links-heading { font-size: 13px; font-weight: 600; - color: #8892a8; + color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 4px; @@ -585,26 +658,26 @@ h6 { .footer-links-group a { font-size: 14px; - color: #5a6478; + color: var(--text-faint); text-decoration: none; transition: color 0.15s ease; } .footer-links-group a:hover { - color: #91a4d2; + color: var(--accent); } .footer-bottom { max-width: 1200px; margin: 48px auto 0; padding: 20px 0; - border-top: 1px solid #1e222d; + border-top: 1px solid var(--border-primary); text-align: center; } .footer-bottom p { font-size: 13px; - color: #3d4556; + color: var(--text-dimmest); margin: 0; } @@ -617,7 +690,7 @@ h6 { .legal-nav { padding: 20px 32px; - border-bottom: 1px solid #1e222d; + border-bottom: 1px solid var(--border-primary); } .legal-content { @@ -630,21 +703,21 @@ h6 { .legal-content h1 { font-size: 36px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); margin: 0 0 32px; } .legal-content h2 { font-size: 22px; font-weight: 600; - color: #f1f5f9; + color: var(--text-heading); margin: 40px 0 12px; } .legal-content p { font-size: 15px; line-height: 1.7; - color: #8892a8; + color: var(--text-muted); margin: 0 0 16px; } @@ -656,19 +729,19 @@ h6 { .legal-content li { font-size: 15px; line-height: 1.7; - color: #8892a8; + color: var(--text-muted); margin-bottom: 8px; } .legal-updated { font-size: 14px; - color: #5a6478; + color: var(--text-faint); font-style: italic; } .legal-footer { padding: 20px 32px; - border-top: 1px solid #1e222d; + border-top: 1px solid var(--border-primary); display: flex; gap: 24px; justify-content: center; @@ -676,13 +749,13 @@ h6 { .legal-footer a { font-size: 14px; - color: #5a6478; + color: var(--text-faint); text-decoration: none; transition: color 0.15s ease; } .legal-footer a:hover { - color: #91a4d2; + color: var(--accent); } /* ===== Responsive: Landing Page ===== */ @@ -771,8 +844,8 @@ h6 { align-items: center; justify-content: center; padding: 10px 20px; - background: linear-gradient(135deg, #91a4d2, #6d85c6); - color: #0a0c10; + background: linear-gradient(135deg, var(--accent), var(--accent-secondary)); + color: var(--avatar-text); border: none; border-radius: 8px; font-size: 14px; @@ -796,9 +869,9 @@ h6 { align-items: center; justify-content: center; padding: 10px 20px; - background-color: #1e222d; - color: #e2e8f0; - border: 1px solid #2a2f3d; + background-color: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border-secondary); border-radius: 8px; font-size: 14px; font-weight: 500; @@ -808,7 +881,7 @@ h6 { } .btn-secondary:hover { - border-color: #91a4d2; + border-color: var(--accent); } .btn-icon { @@ -818,8 +891,8 @@ h6 { width: 36px; height: 36px; background-color: transparent; - color: #8892a8; - border: 1px solid #2a2f3d; + color: var(--text-muted); + border: 1px solid var(--border-secondary); border-radius: 8px; cursor: pointer; font-size: 16px; @@ -827,8 +900,8 @@ h6 { } .btn-icon:hover { - color: #e2e8f0; - border-color: #91a4d2; + color: var(--text-primary); + border-color: var(--accent); } .btn-danger { @@ -857,16 +930,16 @@ h6 { .form-group label { font-size: 13px; font-weight: 500; - color: #8892a8; + color: var(--text-muted); } .form-select, .form-input { padding: 10px 14px; - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 8px; - color: #e2e8f0; + color: var(--text-primary); font-size: 14px; font-family: 'Inter', sans-serif; outline: none; @@ -875,7 +948,7 @@ h6 { .form-select:focus, .form-input:focus { - border-color: #91a4d2; + border-color: var(--accent); } .form-success { @@ -900,13 +973,13 @@ h6 { .page-title { font-size: 28px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); margin: 0 0 4px; } .page-subtitle { font-size: 15px; - color: #8892a8; + color: var(--text-muted); margin: 0; } @@ -915,7 +988,7 @@ h6 { display: flex; gap: 4px; padding: 0 0 20px; - border-bottom: 1px solid #1e222d; + border-bottom: 1px solid var(--border-primary); margin-bottom: 24px; } @@ -924,19 +997,19 @@ h6 { border-radius: 8px; font-size: 14px; font-weight: 500; - color: #8892a8; + color: var(--text-muted); text-decoration: none; transition: background-color 0.15s ease, color 0.15s ease; } .sub-nav-item:hover { - background-color: #1e222d; - color: #e2e8f0; + background-color: var(--bg-surface); + color: var(--text-primary); } .sub-nav-item--active { background-color: rgba(145, 164, 210, 0.12); - color: #91a4d2; + color: var(--accent); } /* ===== Shell Content (Developer/Org) ===== */ @@ -964,9 +1037,9 @@ h6 { .filter-tab { padding: 6px 16px; border-radius: 20px; - border: 1px solid #2a2f3d; + border: 1px solid var(--border-secondary); background-color: transparent; - color: #8892a8; + color: var(--text-muted); font-size: 13px; font-weight: 500; cursor: pointer; @@ -975,14 +1048,14 @@ h6 { } .filter-tab:hover { - border-color: #91a4d2; - color: #e2e8f0; + border-color: var(--accent); + color: var(--text-primary); } .filter-tab--active { background-color: rgba(145, 164, 210, 0.12); - border-color: #91a4d2; - color: #91a4d2; + border-color: var(--accent); + color: var(--accent); } /* ===== News Card ===== */ @@ -993,8 +1066,8 @@ h6 { } .news-card { - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 12px; overflow: hidden; transition: border-color 0.2s ease, transform 0.2s ease; @@ -1003,7 +1076,7 @@ h6 { } .news-card:hover { - border-color: #91a4d2; + border-color: var(--accent); transform: translateY(-2px); } @@ -1046,7 +1119,7 @@ h6 { letter-spacing: 0.05em; /* Default badge color for any topic */ background-color: rgba(99, 132, 210, 0.15); - color: #91a4d2; + color: var(--accent); } .news-badge--llm { @@ -1076,18 +1149,18 @@ h6 { .news-card-source { font-size: 12px; - color: #5a6478; + color: var(--text-faint); } .news-card-date { font-size: 12px; - color: #3d4556; + color: var(--text-dimmest); } .news-card-title { font-size: 16px; font-weight: 600; - color: #f1f5f9; + color: var(--text-heading); margin: 0 0 8px; line-height: 1.3; } @@ -1098,18 +1171,18 @@ h6 { } .news-card-title a:hover { - color: #91a4d2; + color: var(--accent); } .news-card-summary { font-size: 13px; line-height: 1.5; - color: #8892a8; + color: var(--text-muted); margin: 0; } .news-card--selected { - border-color: #91a4d2; + border-color: var(--accent); background-color: rgba(145, 164, 210, 0.08); } @@ -1178,7 +1251,7 @@ h6 { .dashboard-loading { text-align: center; padding: 24px; - color: #8892a8; + color: var(--text-muted); font-size: 14px; } @@ -1192,7 +1265,7 @@ h6 { .topic-remove { background: none; border: none; - color: #5a6478; + color: var(--text-faint); font-size: 12px; cursor: pointer; padding: 2px 4px; @@ -1207,9 +1280,9 @@ h6 { .topic-add-btn { padding: 6px 14px; border-radius: 20px; - border: 1px dashed #2a2f3d; + border: 1px dashed var(--border-secondary); background-color: transparent; - color: #5a6478; + color: var(--text-faint); font-size: 16px; cursor: pointer; transition: all 0.15s ease; @@ -1218,8 +1291,8 @@ h6 { } .topic-add-btn:hover { - border-color: #91a4d2; - color: #91a4d2; + border-color: var(--accent); + color: var(--accent); } .topic-input-wrapper { @@ -1231,9 +1304,9 @@ h6 { .topic-input { padding: 5px 12px; border-radius: 20px; - border: 1px solid #2a2f3d; - background-color: #1a1d26; - color: #e2e8f0; + border: 1px solid var(--border-secondary); + background-color: var(--bg-card); + color: var(--text-primary); font-size: 13px; font-family: 'Inter', sans-serif; outline: none; @@ -1241,19 +1314,19 @@ h6 { } .topic-input:focus { - border-color: #91a4d2; + border-color: var(--accent); } .topic-cancel-btn { background: none; border: none; - color: #5a6478; + color: var(--text-faint); font-size: 12px; cursor: pointer; } .topic-cancel-btn:hover { - color: #e2e8f0; + color: var(--text-primary); } /* ===== Settings Panel ===== */ @@ -1262,8 +1335,8 @@ h6 { } .settings-panel { - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 12px; padding: 20px; margin-bottom: 24px; @@ -1272,7 +1345,7 @@ h6 { .settings-panel-title { font-size: 15px; font-weight: 600; - color: #e2e8f0; + color: var(--text-primary); margin: 0 0 16px; } @@ -1283,7 +1356,7 @@ h6 { .settings-field label { display: block; font-size: 12px; - color: #8892a8; + color: var(--text-muted); margin-bottom: 4px; font-weight: 500; } @@ -1293,16 +1366,16 @@ h6 { max-width: 400px; padding: 8px 12px; border-radius: 8px; - border: 1px solid #2a2f3d; - background-color: #0f1116; - color: #e2e8f0; + border: 1px solid var(--border-secondary); + background-color: var(--bg-body); + color: var(--text-primary); font-size: 13px; font-family: 'Inter', sans-serif; outline: none; } .settings-input:focus { - border-color: #91a4d2; + border-color: var(--accent); } .settings-hint { @@ -1317,8 +1390,8 @@ h6 { /* ===== Article Detail Panel ===== */ .article-detail-panel { - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 12px; padding: 24px; position: relative; @@ -1329,8 +1402,8 @@ h6 { top: 16px; right: 16px; background: none; - border: 1px solid #2a2f3d; - color: #8892a8; + border: 1px solid var(--border-secondary); + color: var(--text-muted); width: 32px; height: 32px; border-radius: 8px; @@ -1355,7 +1428,7 @@ h6 { .article-detail-title { font-size: 22px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); margin: 0 0 12px; line-height: 1.3; } @@ -1370,7 +1443,7 @@ h6 { .article-detail-source { font-size: 13px; - color: #8892a8; + color: var(--text-muted); display: inline-flex; align-items: center; gap: 6px; @@ -1385,7 +1458,7 @@ h6 { .article-detail-date { font-size: 13px; - color: #5a6478; + color: var(--text-faint); } .article-detail-body { @@ -1395,21 +1468,21 @@ h6 { .article-detail-body p { font-size: 14px; line-height: 1.7; - color: #c8d0e0; + color: var(--text-primary); margin: 0; } .article-detail-link { display: inline-block; font-size: 13px; - color: #91a4d2; + color: var(--accent); text-decoration: none; margin-bottom: 20px; transition: color 0.15s ease; } .article-detail-link:hover { - color: #b4c4e8; + color: var(--accent-secondary); } /* ---- AI Summary Bubble ---- */ @@ -1425,7 +1498,7 @@ h6 { .ai-summary-bubble-text { font-size: 14px; line-height: 1.65; - color: #c8d0e0; + color: var(--text-primary); margin: 0; white-space: pre-wrap; } @@ -1434,7 +1507,7 @@ h6 { display: block; font-size: 11px; font-weight: 600; - color: #91a4d2; + color: var(--accent); margin-top: 12px; text-transform: uppercase; letter-spacing: 0.05em; @@ -1446,7 +1519,7 @@ h6 { align-items: center; gap: 10px; font-size: 14px; - color: #91a4d2; + color: var(--accent); font-style: italic; } @@ -1469,7 +1542,7 @@ h6 { width: 6px; height: 6px; border-radius: 50%; - background: #91a4d2; + background: var(--accent); animation: dotPulse 1.2s infinite ease-in-out; } @@ -1495,7 +1568,7 @@ h6 { /* ---- Follow-up Chat ---- */ .article-chat { margin-top: 16px; - border-top: 1px solid #2a2f3d; + border-top: 1px solid var(--border-secondary); padding-top: 16px; } @@ -1526,7 +1599,7 @@ h6 { align-self: flex-end; background: rgba(99, 132, 210, 0.2); border: 1px solid rgba(99, 132, 210, 0.3); - color: #d0d8ee; + color: var(--text-primary); border-bottom-right-radius: 4px; } @@ -1534,7 +1607,7 @@ h6 { align-self: flex-start; background: rgba(145, 164, 210, 0.08); border: 1px solid rgba(145, 164, 210, 0.15); - color: #c8d0e0; + color: var(--text-primary); border-bottom-left-radius: 4px; } @@ -1549,18 +1622,18 @@ h6 { .article-chat-textbox { flex: 1; - background: #1a1e2e; - border: 1px solid #2a2f3d; + background: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 8px; padding: 10px 14px; font-size: 14px; - color: #c8d0e0; + color: var(--text-primary); outline: none; transition: border-color 0.2s; } .article-chat-textbox:focus { - border-color: #6384d2; + border-color: var(--accent-secondary); } .article-chat-textbox:disabled { @@ -1568,8 +1641,8 @@ h6 { } .article-chat-send { - background: #6384d2; - color: #fff; + background: var(--accent-secondary); + color: var(--avatar-text); border: none; border-radius: 8px; padding: 10px 18px; @@ -1581,7 +1654,7 @@ h6 { } .article-chat-send:hover:not(:disabled) { - background: #5270b8; + background: var(--accent); } .article-chat-send:disabled { @@ -1601,8 +1674,8 @@ h6 { } .providers-form { - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 12px; padding: 28px; } @@ -1616,13 +1689,13 @@ h6 { .providers-status h3 { font-size: 18px; font-weight: 600; - color: #f1f5f9; + color: var(--text-heading); margin: 0 0 16px; } .status-card { - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 12px; padding: 20px; } @@ -1631,7 +1704,7 @@ h6 { display: flex; justify-content: space-between; padding: 10px 0; - border-bottom: 1px solid #1e222d; + border-bottom: 1px solid var(--border-primary); } .status-row:last-child { @@ -1640,13 +1713,13 @@ h6 { .status-label { font-size: 13px; - color: #8892a8; + color: var(--text-muted); } .status-value { font-size: 13px; font-weight: 500; - color: #e2e8f0; + color: var(--text-primary); } /* ===== Chat Page ===== */ @@ -1660,10 +1733,10 @@ h6 { .chat-sidebar-panel { width: 260px; min-width: 260px; - border-right: 1px solid #1e222d; + border-right: 1px solid var(--border-primary); display: flex; flex-direction: column; - background-color: #0d0f14; + background-color: var(--bg-sidebar); } .chat-sidebar-header { @@ -1671,13 +1744,13 @@ h6 { align-items: center; justify-content: space-between; padding: 20px; - border-bottom: 1px solid #1e222d; + border-bottom: 1px solid var(--border-primary); } .chat-sidebar-header h3 { font-size: 16px; font-weight: 600; - color: #f1f5f9; + color: var(--text-heading); margin: 0; } @@ -1700,7 +1773,7 @@ h6 { } .chat-session-item:hover { - background-color: #1e222d; + background-color: var(--bg-surface); } .chat-session-item--active { @@ -1710,13 +1783,13 @@ h6 { .chat-session-title { font-size: 14px; font-weight: 500; - color: #e2e8f0; + color: var(--text-primary); margin-bottom: 4px; } .chat-session-date { font-size: 12px; - color: #5a6478; + color: var(--text-faint); } .chat-main-panel { @@ -1739,7 +1812,7 @@ h6 { display: flex; align-items: center; justify-content: center; - color: #5a6478; + color: var(--text-faint); } /* ===== Chat Bubble ===== */ @@ -1754,22 +1827,22 @@ h6 { .chat-bubble--user { align-self: flex-end; background-color: rgba(145, 164, 210, 0.15); - color: #e2e8f0; + color: var(--text-primary); border-bottom-right-radius: 4px; } .chat-bubble--assistant { align-self: flex-start; - background-color: #1a1d26; - border: 1px solid #2a2f3d; - color: #e2e8f0; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); + color: var(--text-primary); border-bottom-left-radius: 4px; } .chat-bubble--system { align-self: center; background-color: transparent; - color: #5a6478; + color: var(--text-faint); font-size: 13px; font-style: italic; } @@ -1784,12 +1857,12 @@ h6 { .chat-bubble-role { font-size: 12px; font-weight: 600; - color: #91a4d2; + color: var(--accent); } .chat-bubble-time { font-size: 11px; - color: #5a6478; + color: var(--text-faint); } .chat-bubble-content { @@ -1808,7 +1881,7 @@ h6 { padding: 4px 10px; background-color: rgba(145, 164, 210, 0.1); border-radius: 4px; - color: #91a4d2; + color: var(--accent); } /* -- Chat Input Bar -- */ @@ -1817,24 +1890,24 @@ h6 { align-items: center; gap: 8px; padding: 16px 24px; - border-top: 1px solid #1e222d; - background-color: #0d0f14; + border-top: 1px solid var(--border-primary); + background-color: var(--bg-sidebar); } .chat-input { flex: 1; padding: 10px 14px; - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 8px; - color: #e2e8f0; + color: var(--text-primary); font-size: 14px; font-family: 'Inter', sans-serif; outline: none; } .chat-input:focus { - border-color: #91a4d2; + border-color: var(--accent); } .chat-attach-btn { @@ -1858,15 +1931,15 @@ h6 { /* ===== Tool Card ===== */ .tool-card { - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 12px; padding: 24px; transition: border-color 0.2s ease; } .tool-card:hover { - border-color: #91a4d2; + border-color: var(--accent); } .tool-card-header { @@ -1878,7 +1951,7 @@ h6 { .tool-card-icon { font-size: 24px; - color: #91a4d2; + color: var(--accent); } .tool-status { @@ -1892,7 +1965,7 @@ h6 { } .tool-status--inactive { - background-color: #5a6478; + background-color: var(--text-faint); } .tool-status--error { @@ -1902,14 +1975,14 @@ h6 { .tool-card-name { font-size: 16px; font-weight: 600; - color: #f1f5f9; + color: var(--text-heading); margin: 0 0 6px; } .tool-card-desc { font-size: 13px; line-height: 1.5; - color: #8892a8; + color: var(--text-muted); margin: 0 0 16px; } @@ -1922,7 +1995,7 @@ h6 { .tool-card-category { font-size: 11px; font-weight: 500; - color: #5a6478; + color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.05em; } @@ -1944,8 +2017,8 @@ h6 { } .tool-toggle--off { - background-color: #1e222d; - color: #5a6478; + background-color: var(--bg-surface); + color: var(--text-faint); } /* ===== Knowledge Base Page ===== */ @@ -1973,19 +2046,19 @@ h6 { .knowledge-table thead th { font-size: 12px; font-weight: 600; - color: #5a6478; + color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.05em; padding: 12px 16px; text-align: left; - border-bottom: 1px solid #1e222d; + border-bottom: 1px solid var(--border-primary); } .knowledge-table tbody td { font-size: 14px; - color: #e2e8f0; + color: var(--text-primary); padding: 14px 16px; - border-bottom: 1px solid #1e222d; + border-bottom: 1px solid var(--border-primary); } .file-row:hover { @@ -2000,7 +2073,7 @@ h6 { } .file-row-icon { - color: #91a4d2; + color: var(--accent); font-size: 12px; } @@ -2017,8 +2090,8 @@ h6 { text-align: center; max-width: 480px; padding: 48px 40px; - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 16px; } @@ -2034,20 +2107,20 @@ h6 { font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; - color: #91a4d2; + color: var(--accent); } .placeholder-card h2 { font-size: 24px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); margin: 0 0 12px; } .placeholder-desc { font-size: 14px; line-height: 1.6; - color: #8892a8; + color: var(--text-muted); margin: 0 0 24px; } @@ -2056,7 +2129,7 @@ h6 { margin-top: 12px; font-size: 12px; font-weight: 600; - color: #91a4d2; + color: var(--accent); padding: 4px 12px; border: 1px solid rgba(145, 164, 210, 0.3); border-radius: 20px; @@ -2077,8 +2150,8 @@ h6 { align-items: center; gap: 2px; padding: 16px; - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 12px; } @@ -2086,12 +2159,12 @@ h6 { font-family: 'Space Grotesk', sans-serif; font-size: 22px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); } .analytics-stat-label { font-size: 12px; - color: #5a6478; + color: var(--text-faint); } .analytics-stat-change { @@ -2121,8 +2194,8 @@ h6 { /* ===== Pricing Card ===== */ .pricing-card { - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 16px; padding: 32px 28px; text-align: center; @@ -2134,7 +2207,7 @@ h6 { } .pricing-card--highlighted { - border-color: #91a4d2; + border-color: var(--accent); box-shadow: 0 0 30px rgba(145, 164, 210, 0.1); position: relative; } @@ -2142,7 +2215,7 @@ h6 { .pricing-card-name { font-size: 20px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); margin: 0 0 16px; } @@ -2154,17 +2227,17 @@ h6 { font-family: 'Space Grotesk', sans-serif; font-size: 40px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); } .pricing-card-period { font-size: 14px; - color: #5a6478; + color: var(--text-faint); } .pricing-card-seats { font-size: 13px; - color: #8892a8; + color: var(--text-muted); margin: 0 0 24px; } @@ -2177,9 +2250,9 @@ h6 { .pricing-card-features li { font-size: 14px; - color: #8892a8; + color: var(--text-muted); padding: 6px 0; - border-bottom: 1px solid #1e222d; + border-bottom: 1px solid var(--border-primary); } .pricing-card-features li:last-child { @@ -2189,8 +2262,8 @@ h6 { .pricing-card-cta { width: 100%; padding: 12px; - background: linear-gradient(135deg, #91a4d2, #6d85c6); - color: #0a0c10; + background: linear-gradient(135deg, var(--accent), var(--accent-secondary)); + color: var(--avatar-text); border: none; border-radius: 8px; font-size: 14px; @@ -2222,8 +2295,8 @@ h6 { align-items: center; gap: 4px; padding: 20px; - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 12px; } @@ -2231,12 +2304,12 @@ h6 { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); } .org-stat-label { font-size: 12px; - color: #5a6478; + color: var(--text-faint); } /* ===== Organization Table ===== */ @@ -2252,19 +2325,19 @@ h6 { .org-table thead th { font-size: 12px; font-weight: 600; - color: #5a6478; + color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.05em; padding: 12px 16px; text-align: left; - border-bottom: 1px solid #1e222d; + border-bottom: 1px solid var(--border-primary); } .org-table tbody td { font-size: 14px; - color: #e2e8f0; + color: var(--text-primary); padding: 14px 16px; - border-bottom: 1px solid #1e222d; + border-bottom: 1px solid var(--border-primary); } .member-row:hover { @@ -2273,17 +2346,17 @@ h6 { .member-role-select { padding: 6px 10px; - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 6px; - color: #e2e8f0; + color: var(--text-primary); font-size: 13px; font-family: 'Inter', sans-serif; outline: none; } .member-role-select:focus { - border-color: #91a4d2; + border-color: var(--accent); } /* ===== Modal ===== */ @@ -2301,8 +2374,8 @@ h6 { } .modal-content { - background-color: #1a1d26; - border: 1px solid #2a2f3d; + background-color: var(--bg-card); + border: 1px solid var(--border-secondary); border-radius: 16px; padding: 32px; min-width: 400px; @@ -2312,7 +2385,7 @@ h6 { .modal-content h3 { font-size: 20px; font-weight: 700; - color: #f1f5f9; + color: var(--text-heading); margin: 0 0 20px; } @@ -2333,7 +2406,7 @@ h6 { align-self: flex-start; max-height: 80vh; overflow-y: auto; - border-left: 1px solid #1e222d; + border-left: 1px solid var(--border-primary); padding-left: 24px; display: flex; flex-direction: column; @@ -2349,7 +2422,7 @@ h6 { .sidebar-section-title { font-size: 12px; font-weight: 600; - color: #5a6478; + color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.05em; margin: 0; @@ -2380,7 +2453,7 @@ h6 { .sidebar-status-label { font-size: 13px; - color: #e2e8f0; + color: var(--text-primary); font-weight: 500; } @@ -2397,7 +2470,7 @@ h6 { padding: 3px 10px; border-radius: 12px; background-color: rgba(145, 164, 210, 0.1); - color: #91a4d2; + color: var(--accent); border: 1px solid rgba(145, 164, 210, 0.2); } @@ -2411,14 +2484,14 @@ h6 { border-radius: 6px; font-size: 13px; font-family: 'Inter', sans-serif; - color: #8892a8; + color: var(--text-muted); cursor: pointer; transition: background-color 0.15s ease, color 0.15s ease; } .sidebar-topic-link:hover { background-color: rgba(145, 164, 210, 0.08); - color: #e2e8f0; + color: var(--text-primary); } /* ===== Responsive: Dashboard Pages ===== */ @@ -2493,7 +2566,7 @@ h6 { min-width: unset; max-height: 200px; border-right: none; - border-bottom: 1px solid #1e222d; + border-bottom: 1px solid var(--border-primary); } .page-header { diff --git a/assets/tailwind.css b/assets/tailwind.css index 7177b28..173cc7e 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -1290,9 +1290,6 @@ } } } - .fixed { - position: fixed; - } .relative { position: relative; } diff --git a/src/app.rs b/src/app.rs index 8964acc..79be24e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -93,6 +93,17 @@ pub fn App() -> Element { "# } - div { "data-theme": "certifai-dark", Router:: {} } + // Apply persisted theme to before first paint to avoid flash. + // Default to certifai-dark when no preference is stored. + document::Script { + r#" + (function() {{ + var t = localStorage.getItem('theme') || 'certifai-dark'; + document.documentElement.setAttribute('data-theme', t); + }})(); + "# + } + + Router:: {} } } diff --git a/src/components/app_shell.rs b/src/components/app_shell.rs index 12ec446..9711336 100644 --- a/src/components/app_shell.rs +++ b/src/components/app_shell.rs @@ -1,21 +1,65 @@ use dioxus::prelude::*; use crate::components::sidebar::Sidebar; +use crate::infrastructure::auth_check::check_auth; +use crate::models::AuthInfo; use crate::Route; /// Application shell layout that wraps all authenticated pages. /// -/// Renders a fixed sidebar on the left and the active child route -/// in the scrollable main content area via `Outlet`. +/// Calls [`check_auth`] on mount to fetch the current user's session. +/// If unauthenticated, redirects to `/auth`. Otherwise renders the +/// sidebar with real user data and the active child route. #[component] pub fn AppShell() -> Element { - rsx! { - div { class: "app-shell", - Sidebar { - email: "user@example.com".to_string(), - avatar_url: String::new(), + // use_resource memoises the async call and avoids infinite re-render + // loops that use_effect + spawn + signal writes can cause. + #[allow(clippy::redundant_closure)] + let auth = use_resource(move || check_auth()); + + // Clone the inner value out of the Signal to avoid holding the + // borrow across the rsx! return (Dioxus lifetime constraint). + let auth_snapshot: Option> = auth.read().clone(); + + match auth_snapshot { + Some(Ok(info)) if info.authenticated => { + rsx! { + div { class: "app-shell", + Sidebar { + email: info.email, + name: info.name, + avatar_url: info.avatar_url, + } + main { class: "main-content", Outlet:: {} } + } + } + } + Some(Ok(_)) => { + // Not authenticated -- redirect to login. + let nav = navigator(); + nav.push(NavigationTarget::::External("/auth".into())); + rsx! { + div { class: "app-shell loading", + p { "Redirecting to login..." } + } + } + } + Some(Err(e)) => { + let msg = e.to_string(); + rsx! { + div { class: "auth-error", + p { "Authentication error: {msg}" } + a { href: "/auth", "Login" } + } + } + } + None => { + // Still loading. + rsx! { + div { class: "app-shell loading", + p { "Loading..." } + } } - main { class: "main-content", Outlet:: {} } } } } diff --git a/src/components/sidebar.rs b/src/components/sidebar.rs index 4997acb..b543ebd 100644 --- a/src/components/sidebar.rs +++ b/src/components/sidebar.rs @@ -1,7 +1,7 @@ use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::{ BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub, - BsGrid, BsHouseDoor, BsPuzzle, + BsGrid, BsHouseDoor, BsMoonFill, BsPuzzle, BsSunFill, }; use dioxus_free_icons::Icon; @@ -19,10 +19,11 @@ struct NavItem { /// /// # Arguments /// +/// * `name` - User display name (shown in header if non-empty). /// * `email` - Email address displayed beneath the avatar placeholder. /// * `avatar_url` - URL for the avatar image (unused placeholder for now). #[component] -pub fn Sidebar(email: String, avatar_url: String) -> Element { +pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element { let nav_items: Vec = vec![ NavItem { label: "Dashboard", @@ -66,7 +67,7 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element { rsx! { aside { class: "sidebar", - SidebarHeader { email: email.clone(), avatar_url } + SidebarHeader { name, email: email.clone(), avatar_url } nav { class: "sidebar-nav", for item in nav_items { @@ -93,13 +94,14 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element { } } - div { class: "sidebar-logout", + div { class: "sidebar-bottom-actions", Link { to: NavigationTarget::::External("/auth/logout".into()), class: "sidebar-link logout-btn", Icon { icon: BsBoxArrowRight, width: 18, height: 18 } span { "Logout" } } + ThemeToggle {} } SidebarFooter {} @@ -107,30 +109,123 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element { } } -/// Avatar circle and email display at the top of the sidebar. +/// Avatar circle, name, and email display at the top of the sidebar. /// /// # Arguments /// +/// * `name` - User display name. If non-empty, shown above the email. /// * `email` - User email to display. /// * `avatar_url` - Placeholder for future avatar image URL. #[component] -fn SidebarHeader(email: String, avatar_url: String) -> Element { - // Extract initials from email (first two chars before @). - let initials: String = email - .split('@') - .next() - .unwrap_or("U") - .chars() - .take(2) - .collect::() - .to_uppercase(); +fn SidebarHeader(name: String, email: String, avatar_url: String) -> Element { + // Derive initials: prefer name words, fall back to email prefix. + let initials: String = if name.is_empty() { + email + .split('@') + .next() + .unwrap_or("U") + .chars() + .take(2) + .collect::() + .to_uppercase() + } else { + name.split_whitespace() + .filter_map(|w| w.chars().next()) + .take(2) + .collect::() + .to_uppercase() + }; rsx! { div { class: "sidebar-header", div { class: "avatar-circle", span { class: "avatar-initials", "{initials}" } } - p { class: "sidebar-email", "{email}" } + div { class: "sidebar-user-info", + if !name.is_empty() { + p { class: "sidebar-name", "{name}" } + } + p { class: "sidebar-email", "{email}" } + } + } + } +} + +/// Toggle button that switches between dark and light themes. +/// +/// Sets `data-theme` on the `` element and persists the choice +/// in `localStorage` so it survives page reloads. +#[component] +fn ThemeToggle() -> Element { + let mut is_dark = use_signal(|| { + // Read persisted preference from localStorage on first render. + #[cfg(feature = "web")] + { + web_sys::window() + .and_then(|w| w.local_storage().ok().flatten()) + .and_then(|s| s.get_item("theme").ok().flatten()) + .is_none_or(|v| v != "certifai-light") + } + #[cfg(not(feature = "web"))] + { + true + } + }); + + // Apply the persisted theme to the DOM on first render so the + // page doesn't flash dark if the user previously chose light. + #[cfg(feature = "web")] + { + let dark = *is_dark.read(); + use_effect(move || { + let theme = if dark { + "certifai-dark" + } else { + "certifai-light" + }; + if let Some(doc) = web_sys::window().and_then(|w| w.document()) { + if let Some(el) = doc.document_element() { + let _ = el.set_attribute("data-theme", theme); + } + } + }); + } + + let toggle = move |_| { + let new_dark = !*is_dark.read(); + is_dark.set(new_dark); + + #[cfg(feature = "web")] + { + let theme = if new_dark { + "certifai-dark" + } else { + "certifai-light" + }; + if let Some(doc) = web_sys::window().and_then(|w| w.document()) { + if let Some(el) = doc.document_element() { + let _ = el.set_attribute("data-theme", theme); + } + } + if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) + { + let _ = storage.set_item("theme", theme); + } + } + }; + + let dark = *is_dark.read(); + + rsx! { + button { + class: "theme-toggle-btn", + title: if dark { "Switch to light mode" } else { "Switch to dark mode" }, + onclick: toggle, + if dark { + Icon { icon: BsSunFill, width: 16, height: 16 } + } else { + Icon { icon: BsMoonFill, width: 16, height: 16 } + } } } } diff --git a/src/infrastructure/auth.rs b/src/infrastructure/auth.rs index a58f845..9894878 100644 --- a/src/infrastructure/auth.rs +++ b/src/infrastructure/auth.rs @@ -12,7 +12,11 @@ use rand::RngExt; use tower_sessions::Session; use url::Url; -use crate::infrastructure::{state::User, Error, UserStateInner}; +use crate::infrastructure::{ + server_state::ServerState, + state::{User, UserStateInner}, + Error, +}; pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user"; @@ -55,70 +59,6 @@ impl PendingOAuthStore { } } -/// Configuration loaded from environment variables for Keycloak OAuth. -struct OAuthConfig { - keycloak_url: String, - realm: String, - client_id: String, - redirect_uri: String, - app_url: String, -} - -impl OAuthConfig { - /// Load OAuth configuration from environment variables. - /// - /// # Errors - /// - /// Returns `Error::StateError` if any required env var is missing. - fn from_env() -> Result { - dotenvy::dotenv().ok(); - Ok(Self { - keycloak_url: std::env::var("KEYCLOAK_URL") - .map_err(|_| Error::StateError("KEYCLOAK_URL not set".into()))?, - realm: std::env::var("KEYCLOAK_REALM") - .map_err(|_| Error::StateError("KEYCLOAK_REALM not set".into()))?, - client_id: std::env::var("KEYCLOAK_CLIENT_ID") - .map_err(|_| Error::StateError("KEYCLOAK_CLIENT_ID not set".into()))?, - redirect_uri: std::env::var("REDIRECT_URI") - .map_err(|_| Error::StateError("REDIRECT_URI not set".into()))?, - app_url: std::env::var("APP_URL") - .map_err(|_| Error::StateError("APP_URL not set".into()))?, - }) - } - - /// Build the Keycloak OpenID Connect authorization endpoint URL. - fn auth_endpoint(&self) -> String { - format!( - "{}/realms/{}/protocol/openid-connect/auth", - self.keycloak_url, self.realm - ) - } - - /// Build the Keycloak OpenID Connect token endpoint URL. - fn token_endpoint(&self) -> String { - format!( - "{}/realms/{}/protocol/openid-connect/token", - self.keycloak_url, self.realm - ) - } - - /// Build the Keycloak OpenID Connect userinfo endpoint URL. - fn userinfo_endpoint(&self) -> String { - format!( - "{}/realms/{}/protocol/openid-connect/userinfo", - self.keycloak_url, self.realm - ) - } - - /// Build the Keycloak OpenID Connect end-session (logout) endpoint URL. - fn logout_endpoint(&self) -> String { - format!( - "{}/realms/{}/protocol/openid-connect/logout", - self.keycloak_url, self.realm - ) - } -} - /// Generate a cryptographically random state string for CSRF protection. fn generate_state() -> String { let bytes: [u8; 32] = rand::rng().random(); @@ -165,35 +105,36 @@ fn derive_code_challenge(verifier: &str) -> String { /// /// # Errors /// -/// Returns `Error` if env vars are missing. +/// Returns `Error` if the Keycloak config is missing or the URL is malformed. #[axum::debug_handler] pub async fn auth_login( + Extension(state): Extension, Extension(pending): Extension, Query(params): Query>, ) -> Result { - let config = OAuthConfig::from_env()?; - let state = generate_state(); + let kc = state.keycloak; + let csrf_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(), + csrf_state.clone(), PendingOAuthEntry { redirect_url, code_verifier, }, ); - let mut url = Url::parse(&config.auth_endpoint()) + let mut url = Url::parse(&kc.auth_endpoint()) .map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?; url.query_pairs_mut() - .append_pair("client_id", &config.client_id) - .append_pair("redirect_uri", &config.redirect_uri) + .append_pair("client_id", &kc.client_id) + .append_pair("redirect_uri", &kc.redirect_uri) .append_pair("response_type", "code") .append_pair("scope", "openid profile email") - .append_pair("state", &state) + .append_pair("state", &csrf_state) .append_pair("code_challenge", &code_challenge) .append_pair("code_challenge_method", "S256"); @@ -213,6 +154,10 @@ struct UserinfoResponse { /// The subject identifier (unique user ID in Keycloak). sub: String, email: Option, + /// Keycloak `preferred_username` claim. + preferred_username: Option, + /// Full name from the Keycloak profile. + name: Option, /// Keycloak may include a picture/avatar URL via protocol mappers. picture: Option, } @@ -234,10 +179,11 @@ struct UserinfoResponse { #[axum::debug_handler] pub async fn auth_callback( session: Session, + Extension(state): Extension, Extension(pending): Extension, Query(params): Query>, ) -> Result { - let config = OAuthConfig::from_env()?; + let kc = state.keycloak; // --- CSRF validation via the in-memory pending store --- let returned_state = params @@ -255,11 +201,11 @@ pub async fn auth_callback( let client = reqwest::Client::new(); let token_resp = client - .post(config.token_endpoint()) + .post(kc.token_endpoint()) .form(&[ ("grant_type", "authorization_code"), - ("client_id", &config.client_id), - ("redirect_uri", &config.redirect_uri), + ("client_id", kc.client_id.as_str()), + ("redirect_uri", kc.redirect_uri.as_str()), ("code", code), ("code_verifier", &entry.code_verifier), ]) @@ -279,7 +225,7 @@ pub async fn auth_callback( // --- Fetch userinfo --- let userinfo: UserinfoResponse = client - .get(config.userinfo_endpoint()) + .get(kc.userinfo_endpoint()) .bearer_auth(&tokens.access_token) .send() .await @@ -288,6 +234,12 @@ pub async fn auth_callback( .await .map_err(|e| Error::StateError(format!("userinfo parse failed: {e}")))?; + // Prefer `name`, fall back to `preferred_username`, then empty. + let display_name = userinfo + .name + .or(userinfo.preferred_username) + .unwrap_or_default(); + // --- Build user state and persist in session --- let user_state = UserStateInner { sub: userinfo.sub, @@ -295,6 +247,7 @@ pub async fn auth_callback( refresh_token: tokens.refresh_token.unwrap_or_default(), user: User { email: userinfo.email.unwrap_or_default(), + name: display_name, avatar_url: userinfo.picture.unwrap_or_default(), }, }; @@ -316,10 +269,13 @@ pub async fn auth_callback( /// /// # Errors /// -/// Returns `Error` if env vars are missing or the session cannot be flushed. +/// Returns `Error` if the session cannot be flushed or the URL is malformed. #[axum::debug_handler] -pub async fn logout(session: Session) -> Result { - let config = OAuthConfig::from_env()?; +pub async fn logout( + session: Session, + Extension(state): Extension, +) -> Result { + let kc = state.keycloak; // Flush all session data. session @@ -327,12 +283,12 @@ pub async fn logout(session: Session) -> Result { .await .map_err(|e| Error::StateError(format!("session flush failed: {e}")))?; - let mut url = Url::parse(&config.logout_endpoint()) + let mut url = Url::parse(&kc.logout_endpoint()) .map_err(|e| Error::StateError(format!("invalid logout endpoint URL: {e}")))?; url.query_pairs_mut() - .append_pair("client_id", &config.client_id) - .append_pair("post_logout_redirect_uri", &config.app_url); + .append_pair("client_id", &kc.client_id) + .append_pair("post_logout_redirect_uri", &kc.app_url); Ok(Redirect::temporary(url.as_str())) } diff --git a/src/infrastructure/auth_check.rs b/src/infrastructure/auth_check.rs new file mode 100644 index 0000000..00b6c6c --- /dev/null +++ b/src/infrastructure/auth_check.rs @@ -0,0 +1,36 @@ +use crate::models::AuthInfo; +use dioxus::prelude::*; + +/// Check the current user's authentication state. +/// +/// Reads the tower-sessions session on the server and returns an +/// [`AuthInfo`] describing the logged-in user. When no valid session +/// exists, `authenticated` is `false` and all other fields are empty. +/// +/// # Errors +/// +/// Returns `ServerFnError` if the session store cannot be read. +#[server(endpoint = "check-auth")] +pub async fn check_auth() -> Result { + use crate::infrastructure::auth::LOGGED_IN_USER_SESS_KEY; + use crate::infrastructure::state::UserStateInner; + use dioxus_fullstack::FullstackContext; + + let session: tower_sessions::Session = FullstackContext::extract().await?; + + let user_state: Option = session + .get(LOGGED_IN_USER_SESS_KEY) + .await + .map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?; + + match user_state { + Some(u) => Ok(AuthInfo { + authenticated: true, + sub: u.sub, + email: u.user.email, + name: u.user.name, + avatar_url: u.user.avatar_url, + }), + None => Ok(AuthInfo::default()), + } +} diff --git a/src/infrastructure/auth_middleware.rs b/src/infrastructure/auth_middleware.rs new file mode 100644 index 0000000..d741492 --- /dev/null +++ b/src/infrastructure/auth_middleware.rs @@ -0,0 +1,41 @@ +use axum::{ + extract::Request, + middleware::Next, + response::{IntoResponse, Response}, +}; +use reqwest::StatusCode; +use tower_sessions::Session; + +use crate::infrastructure::auth::LOGGED_IN_USER_SESS_KEY; +use crate::infrastructure::state::UserStateInner; + +/// Server function endpoints that are allowed without authentication. +/// +/// `check-auth` must be public so the frontend can determine login state. +const PUBLIC_API_ENDPOINTS: &[&str] = &["/api/check-auth"]; + +/// Axum middleware that enforces authentication on `/api/` server +/// function endpoints. +/// +/// Requests whose path starts with `/api/` (except those listed in +/// [`PUBLIC_API_ENDPOINTS`]) are rejected with `401 Unauthorized` when +/// no valid session exists. All other paths pass through untouched. +pub async fn require_auth(session: Session, request: Request, next: Next) -> Response { + let path = request.uri().path(); + + // Only gate /api/ server function routes. + if path.starts_with("/api/") && !PUBLIC_API_ENDPOINTS.contains(&path) { + let is_authed = session + .get::(LOGGED_IN_USER_SESS_KEY) + .await + .ok() + .flatten() + .is_some(); + + if !is_authed { + return (StatusCode::UNAUTHORIZED, "Authentication required").into_response(); + } + } + + next.run(request).await +} diff --git a/src/infrastructure/config.rs b/src/infrastructure/config.rs new file mode 100644 index 0000000..c068aa7 --- /dev/null +++ b/src/infrastructure/config.rs @@ -0,0 +1,253 @@ +//! Configuration structs loaded once at startup from environment variables. +//! +//! Each struct provides a `from_env()` constructor that reads `std::env::var` +//! values. Required variables cause an `Error::ConfigError` on failure; +//! optional ones default to an empty string. + +use secrecy::SecretString; + +use super::Error; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Read a required environment variable or return `Error::ConfigError`. +fn required_env(name: &str) -> Result { + std::env::var(name).map_err(|_| Error::ConfigError(format!("{name} is required but not set"))) +} + +/// Read an optional environment variable, defaulting to an empty string. +fn optional_env(name: &str) -> String { + std::env::var(name).unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// KeycloakConfig +// --------------------------------------------------------------------------- + +/// Keycloak OpenID Connect settings for the public (frontend) client. +/// +/// Also carries the admin service-account credentials used for +/// server-to-server calls (e.g. user management APIs). +#[derive(Debug)] +pub struct KeycloakConfig { + /// Base URL of the Keycloak instance (e.g. `http://localhost:8080`). + pub url: String, + /// Keycloak realm name. + pub realm: String, + /// Public client ID used by the dashboard frontend. + pub client_id: String, + /// OAuth redirect URI registered in Keycloak. + pub redirect_uri: String, + /// Root URL of this application (used for post-logout redirect). + pub app_url: String, + /// Confidential client ID for admin/server-to-server calls. + pub admin_client_id: String, + /// Confidential client secret (wrapped for debug safety). + pub admin_client_secret: SecretString, +} + +impl KeycloakConfig { + /// Load Keycloak configuration from environment variables. + /// + /// # Errors + /// + /// Returns `Error::ConfigError` if a required variable is missing. + pub fn from_env() -> Result { + Ok(Self { + url: required_env("KEYCLOAK_URL")?, + realm: required_env("KEYCLOAK_REALM")?, + client_id: required_env("KEYCLOAK_CLIENT_ID")?, + redirect_uri: required_env("REDIRECT_URI")?, + app_url: required_env("APP_URL")?, + admin_client_id: optional_env("KEYCLOAK_ADMIN_CLIENT_ID"), + admin_client_secret: SecretString::from(optional_env("KEYCLOAK_ADMIN_CLIENT_SECRET")), + }) + } + + /// OpenID Connect authorization endpoint URL. + pub fn auth_endpoint(&self) -> String { + format!( + "{}/realms/{}/protocol/openid-connect/auth", + self.url, self.realm + ) + } + + /// OpenID Connect token endpoint URL. + pub fn token_endpoint(&self) -> String { + format!( + "{}/realms/{}/protocol/openid-connect/token", + self.url, self.realm + ) + } + + /// OpenID Connect userinfo endpoint URL. + pub fn userinfo_endpoint(&self) -> String { + format!( + "{}/realms/{}/protocol/openid-connect/userinfo", + self.url, self.realm + ) + } + + /// OpenID Connect end-session (logout) endpoint URL. + pub fn logout_endpoint(&self) -> String { + format!( + "{}/realms/{}/protocol/openid-connect/logout", + self.url, self.realm + ) + } +} + +// --------------------------------------------------------------------------- +// SmtpConfig +// --------------------------------------------------------------------------- + +/// SMTP mail settings for transactional emails (invites, alerts, etc.). +#[derive(Debug)] +pub struct SmtpConfig { + /// SMTP server hostname. + pub host: String, + /// SMTP server port (as string for flexibility, e.g. "587"). + pub port: String, + /// SMTP username. + pub username: String, + /// SMTP password (wrapped for debug safety). + pub password: SecretString, + /// Sender address shown in the `From:` header. + pub from_address: String, +} + +impl SmtpConfig { + /// Load SMTP configuration from environment variables. + /// + /// All fields are optional; defaults to empty strings when absent. + /// + /// # Errors + /// + /// Currently infallible but returns `Result` for consistency. + pub fn from_env() -> Result { + Ok(Self { + host: optional_env("SMTP_HOST"), + port: optional_env("SMTP_PORT"), + username: optional_env("SMTP_USERNAME"), + password: SecretString::from(optional_env("SMTP_PASSWORD")), + from_address: optional_env("SMTP_FROM_ADDRESS"), + }) + } +} + +// --------------------------------------------------------------------------- +// ServiceUrls +// --------------------------------------------------------------------------- + +/// URLs and credentials for external services (Ollama, SearXNG, S3, etc.). +#[derive(Debug)] +pub struct ServiceUrls { + /// Ollama LLM instance base URL. + pub ollama_url: String, + /// Default Ollama model to use. + pub ollama_model: String, + /// SearXNG meta-search engine base URL. + pub searxng_url: String, + /// LangChain service URL. + pub langchain_url: String, + /// LangGraph service URL. + pub langgraph_url: String, + /// Langfuse observability URL. + pub langfuse_url: String, + /// Vector database URL. + pub vectordb_url: String, + /// S3-compatible object storage URL. + pub s3_url: String, + /// S3 access key. + pub s3_access_key: String, + /// S3 secret key (wrapped for debug safety). + pub s3_secret_key: SecretString, +} + +impl ServiceUrls { + /// Load service URLs from environment variables. + /// + /// All fields are optional with sensible defaults where applicable. + /// + /// # Errors + /// + /// Currently infallible but returns `Result` for consistency. + pub fn from_env() -> Result { + Ok(Self { + ollama_url: std::env::var("OLLAMA_URL") + .unwrap_or_else(|_| "http://localhost:11434".into()), + ollama_model: std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into()), + searxng_url: std::env::var("SEARXNG_URL") + .unwrap_or_else(|_| "http://localhost:8888".into()), + langchain_url: optional_env("LANGCHAIN_URL"), + langgraph_url: optional_env("LANGGRAPH_URL"), + langfuse_url: optional_env("LANGFUSE_URL"), + vectordb_url: optional_env("VECTORDB_URL"), + s3_url: optional_env("S3_URL"), + s3_access_key: optional_env("S3_ACCESS_KEY"), + s3_secret_key: SecretString::from(optional_env("S3_SECRET_KEY")), + }) + } +} + +// --------------------------------------------------------------------------- +// StripeConfig +// --------------------------------------------------------------------------- + +/// Stripe billing configuration. +#[derive(Debug)] +pub struct StripeConfig { + /// Stripe secret API key (wrapped for debug safety). + pub secret_key: SecretString, + /// Stripe webhook signing secret (wrapped for debug safety). + pub webhook_secret: SecretString, + /// Stripe publishable key (safe to expose to the frontend). + pub publishable_key: String, +} + +impl StripeConfig { + /// Load Stripe configuration from environment variables. + /// + /// # Errors + /// + /// Currently infallible but returns `Result` for consistency. + pub fn from_env() -> Result { + Ok(Self { + secret_key: SecretString::from(optional_env("STRIPE_SECRET_KEY")), + webhook_secret: SecretString::from(optional_env("STRIPE_WEBHOOK_SECRET")), + publishable_key: optional_env("STRIPE_PUBLISHABLE_KEY"), + }) + } +} + +// --------------------------------------------------------------------------- +// LlmProvidersConfig +// --------------------------------------------------------------------------- + +/// Comma-separated list of enabled LLM provider identifiers. +/// +/// For example: `LLM_PROVIDERS=ollama,openai,anthropic` +#[derive(Debug)] +pub struct LlmProvidersConfig { + /// Parsed provider names. + pub providers: Vec, +} + +impl LlmProvidersConfig { + /// Load the provider list from `LLM_PROVIDERS`. + /// + /// # Errors + /// + /// Currently infallible but returns `Result` for consistency. + pub fn from_env() -> Result { + let raw = optional_env("LLM_PROVIDERS"); + let providers: Vec = raw + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + Ok(Self { providers }) + } +} diff --git a/src/infrastructure/database.rs b/src/infrastructure/database.rs new file mode 100644 index 0000000..16b2a4a --- /dev/null +++ b/src/infrastructure/database.rs @@ -0,0 +1,52 @@ +//! MongoDB connection wrapper with typed collection accessors. + +use mongodb::{bson::doc, Client, Collection}; + +use super::Error; +use crate::models::{OrgBillingRecord, OrgSettings, UserPreferences}; + +/// Thin wrapper around [`mongodb::Database`] that provides typed +/// collection accessors for the application's domain models. +#[derive(Clone, Debug)] +pub struct Database { + inner: mongodb::Database, +} + +impl Database { + /// Connect to MongoDB, select the given database, and verify + /// connectivity with a `ping` command. + /// + /// # Arguments + /// + /// * `uri` - MongoDB connection string (e.g. `mongodb://localhost:27017`) + /// * `db_name` - Database name to use + /// + /// # Errors + /// + /// Returns `Error::DatabaseError` if the client cannot be created + /// or the ping fails. + pub async fn connect(uri: &str, db_name: &str) -> Result { + let client = Client::with_uri_str(uri).await?; + let db = client.database(db_name); + + // Verify the connection is alive. + db.run_command(doc! { "ping": 1 }).await?; + + Ok(Self { inner: db }) + } + + /// Collection for per-user preferences (theme, custom topics, etc.). + pub fn user_preferences(&self) -> Collection { + self.inner.collection("user_preferences") + } + + /// Collection for organisation-level settings. + pub fn org_settings(&self) -> Collection { + self.inner.collection("org_settings") + } + + /// Collection for per-cycle billing records. + pub fn org_billing(&self) -> Collection { + self.inner.collection("org_billing") + } +} diff --git a/src/infrastructure/error.rs b/src/infrastructure/error.rs index 0e969a7..65b2d51 100644 --- a/src/infrastructure/error.rs +++ b/src/infrastructure/error.rs @@ -1,22 +1,43 @@ use axum::response::IntoResponse; use reqwest::StatusCode; +/// Central error type for infrastructure-layer failures. +/// +/// Each variant maps to an appropriate HTTP status code when converted +/// into an Axum response. #[derive(thiserror::Error, Debug)] pub enum Error { #[error("{0}")] StateError(String), + #[error("database error: {0}")] + DatabaseError(String), + + #[error("configuration error: {0}")] + ConfigError(String), + #[error("IoError: {0}")] IoError(#[from] std::io::Error), } +impl From for Error { + fn from(err: mongodb::error::Error) -> Self { + Self::DatabaseError(err.to_string()) + } +} + impl IntoResponse for Error { fn into_response(self) -> axum::response::Response { let msg = self.to_string(); tracing::error!("Converting Error to Response: {msg}"); match self { - Self::StateError(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - _ => (StatusCode::INTERNAL_SERVER_ERROR, "Unknown error").into_response(), + Self::StateError(e) | Self::ConfigError(e) => { + (StatusCode::INTERNAL_SERVER_ERROR, e).into_response() + } + Self::DatabaseError(e) => (StatusCode::SERVICE_UNAVAILABLE, e).into_response(), + Self::IoError(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, "Unknown error").into_response() + } } } } diff --git a/src/infrastructure/llm.rs b/src/infrastructure/llm.rs index 5addf44..07379c0 100644 --- a/src/infrastructure/llm.rs +++ b/src/infrastructure/llm.rs @@ -166,19 +166,20 @@ pub async fn summarize_article( ollama_url: String, model: String, ) -> Result { - dotenvy::dotenv().ok(); use inner::{fetch_article_text, ChatMessage, OllamaChatRequest, OllamaChatResponse}; - // Fall back to env var or default if the URL is empty + let state: crate::infrastructure::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + // Use caller-provided values or fall back to ServerState config let base_url = if ollama_url.is_empty() { - std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into()) + state.services.ollama_url.clone() } else { ollama_url }; - // Fall back to env var or default if the model is empty let model = if model.is_empty() { - std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into()) + state.services.ollama_model.clone() } else { model }; @@ -264,17 +265,19 @@ pub async fn chat_followup( ollama_url: String, model: String, ) -> Result { - dotenvy::dotenv().ok(); use inner::{ChatMessage, OllamaChatRequest, OllamaChatResponse}; + let state: crate::infrastructure::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let base_url = if ollama_url.is_empty() { - std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into()) + state.services.ollama_url.clone() } else { ollama_url }; let model = if model.is_empty() { - std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into()) + state.services.ollama_model.clone() } else { model }; diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index a974ede..5300185 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -1,24 +1,37 @@ // Server function modules (compiled for both web and server features; // the #[server] macro generates client stubs for the web target) +pub mod auth_check; pub mod llm; pub mod ollama; pub mod searxng; -// Server-only modules (Axum handlers, state, etc.) +// Server-only modules (Axum handlers, state, configs, DB, etc.) #[cfg(feature = "server")] mod auth; #[cfg(feature = "server")] +mod auth_middleware; +#[cfg(feature = "server")] +pub mod config; +#[cfg(feature = "server")] +pub mod database; +#[cfg(feature = "server")] mod error; #[cfg(feature = "server")] mod server; #[cfg(feature = "server")] +pub mod server_state; +#[cfg(feature = "server")] mod state; #[cfg(feature = "server")] pub use auth::*; #[cfg(feature = "server")] +pub use auth_middleware::*; +#[cfg(feature = "server")] pub use error::*; #[cfg(feature = "server")] pub use server::*; #[cfg(feature = "server")] +pub use server_state::*; +#[cfg(feature = "server")] pub use state::*; diff --git a/src/infrastructure/ollama.rs b/src/infrastructure/ollama.rs index 5ef0449..d09b03e 100644 --- a/src/infrastructure/ollama.rs +++ b/src/infrastructure/ollama.rs @@ -47,10 +47,11 @@ struct OllamaModel { /// are caught and returned as `online: false` #[post("/api/ollama-status")] pub async fn get_ollama_status(ollama_url: String) -> Result { - dotenvy::dotenv().ok(); + let state: crate::infrastructure::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; let base_url = if ollama_url.is_empty() { - std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into()) + state.services.ollama_url.clone() } else { ollama_url }; diff --git a/src/infrastructure/searxng.rs b/src/infrastructure/searxng.rs index 67e6274..713e67e 100644 --- a/src/infrastructure/searxng.rs +++ b/src/infrastructure/searxng.rs @@ -112,11 +112,11 @@ mod inner { /// Returns `ServerFnError` if the SearXNG request fails or response parsing fails #[post("/api/search")] pub async fn search_topic(query: String) -> Result, ServerFnError> { - dotenvy::dotenv().ok(); use inner::{extract_source, rank_and_deduplicate, SearxngResponse}; - let searxng_url = - std::env::var("SEARXNG_URL").unwrap_or_else(|_| "http://localhost:8888".into()); + let state: crate::infrastructure::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let searxng_url = state.services.searxng_url.clone(); // Enrich the query with "latest news" context for better results, // similar to how Perplexity reformulates queries before searching. @@ -198,12 +198,12 @@ pub async fn search_topic(query: String) -> Result, ServerFnError> /// Returns `ServerFnError` if the SearXNG search request fails #[get("/api/trending")] pub async fn get_trending_topics() -> Result, ServerFnError> { - dotenvy::dotenv().ok(); use inner::SearxngResponse; use std::collections::HashMap; - let searxng_url = - std::env::var("SEARXNG_URL").unwrap_or_else(|_| "http://localhost:8888".into()); + let state: crate::infrastructure::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let searxng_url = state.services.searxng_url.clone(); // Use POST to match SearXNG's default `method: "POST"` setting let search_url = format!("{searxng_url}/search"); diff --git a/src/infrastructure/server.rs b/src/infrastructure/server.rs index 56f2977..8ce30e2 100644 --- a/src/infrastructure/server.rs +++ b/src/infrastructure/server.rs @@ -1,54 +1,94 @@ -use crate::infrastructure::{ - auth_callback, auth_login, logout, PendingOAuthStore, UserState, UserStateInner, -}; - use dioxus::prelude::*; use axum::routing::get; -use axum::Extension; +use axum::{middleware, Extension}; use time::Duration; use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer}; +use crate::infrastructure::{ + auth_callback, auth_login, + config::{KeycloakConfig, LlmProvidersConfig, ServiceUrls, SmtpConfig, StripeConfig}, + database::Database, + logout, require_auth, + server_state::{ServerState, ServerStateInner}, + PendingOAuthStore, +}; + /// Start the Axum server with Dioxus fullstack, session management, -/// and Keycloak OAuth routes. +/// MongoDB, and Keycloak OAuth routes. +/// +/// Loads all configuration from environment variables once, connects +/// to MongoDB, and builds a [`ServerState`] shared across every request. /// /// # Errors /// -/// Returns `Error` if the tokio runtime or TCP listener fails to start. +/// Returns `Error` if the tokio runtime, config loading, DB connection, +/// or TCP listener fails. pub fn server_start(app: fn() -> Element) -> Result<(), super::Error> { tokio::runtime::Runtime::new()?.block_on(async move { - let state: UserState = UserStateInner { - access_token: "abcd".into(), - sub: "abcd".into(), - refresh_token: "abcd".into(), - ..Default::default() + // Load .env once at startup. + dotenvy::dotenv().ok(); + + // ---- Load and leak config structs for 'static lifetime ---- + let keycloak: &'static KeycloakConfig = Box::leak(Box::new(KeycloakConfig::from_env()?)); + let smtp: &'static SmtpConfig = Box::leak(Box::new(SmtpConfig::from_env()?)); + let services: &'static ServiceUrls = Box::leak(Box::new(ServiceUrls::from_env()?)); + let stripe: &'static StripeConfig = Box::leak(Box::new(StripeConfig::from_env()?)); + let llm_providers: &'static LlmProvidersConfig = + Box::leak(Box::new(LlmProvidersConfig::from_env()?)); + + tracing::info!("Configuration loaded"); + + // ---- Connect to MongoDB ---- + let mongo_uri = + std::env::var("MONGODB_URI").unwrap_or_else(|_| "mongodb://localhost:27017".into()); + let mongo_db = std::env::var("MONGODB_DATABASE").unwrap_or_else(|_| "certifai".into()); + + let db = Database::connect(&mongo_uri, &mongo_db).await?; + tracing::info!("Connected to MongoDB (database: {mongo_db})"); + + // ---- Build ServerState ---- + let server_state: ServerState = ServerStateInner { + db, + keycloak, + smtp, + services, + stripe, + llm_providers, } .into(); + + // ---- Session layer ---- let key = Key::generate(); let store = MemoryStore::default(); let session = SessionManagerLayer::new(store) .with_secure(false) // Lax is required so the browser sends the session cookie // on the redirect back from Keycloak (cross-origin GET). - // Strict would silently drop the cookie on that navigation. .with_same_site(tower_sessions::cookie::SameSite::Lax) .with_expiry(tower_sessions::Expiry::OnInactivity(Duration::hours(24))) .with_signed(key); + + // ---- Build router ---- let addr = dioxus_cli_config::fullstack_address_or_localhost(); let listener = tokio::net::TcpListener::bind(addr).await?; - // Layers are applied AFTER serve_dioxus_application so they - // wrap both the custom Axum routes AND the Dioxus server - // function routes (e.g. check_auth needs Session access). + + // Layers wrap in reverse order: session (outermost) -> auth + // middleware -> extensions -> route handlers. The session layer + // must be outermost so the `Session` extractor is available to + // the auth middleware, which gates all `/api/` server function + // routes (except `check-auth`). let router = axum::Router::new() .route("/auth", get(auth_login)) .route("/auth/callback", get(auth_callback)) .route("/logout", get(logout)) .serve_dioxus_application(ServeConfig::new(), app) .layer(Extension(PendingOAuthStore::default())) - .layer(Extension(state)) + .layer(Extension(server_state)) + .layer(middleware::from_fn(require_auth)) .layer(session); - info!("Serving at {addr}"); + tracing::info!("Serving at {addr}"); axum::serve(listener, router.into_make_service()).await?; Ok(()) diff --git a/src/infrastructure/server_state.rs b/src/infrastructure/server_state.rs new file mode 100644 index 0000000..2817791 --- /dev/null +++ b/src/infrastructure/server_state.rs @@ -0,0 +1,74 @@ +//! Application-wide server state available in both Axum handlers and +//! Dioxus server functions via `extract()`. +//! +//! ```rust,ignore +//! // Inside a #[server] function: +//! let state: ServerState = extract().await?; +//! ``` + +use std::{ops::Deref, sync::Arc}; + +use super::{ + config::{KeycloakConfig, LlmProvidersConfig, ServiceUrls, SmtpConfig, StripeConfig}, + database::Database, + Error, +}; + +/// Cheap-to-clone handle to the shared server state. +/// +/// Stored as an Axum `Extension` so it is accessible from both +/// route handlers and Dioxus `#[server]` functions. +#[derive(Clone)] +pub struct ServerState(Arc); + +impl Deref for ServerState { + type Target = ServerStateInner; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for ServerState { + fn from(value: ServerStateInner) -> Self { + Self(Arc::new(value)) + } +} + +/// Inner struct holding all long-lived application resources. +/// +/// Config references are `&'static` because they are `Box::leak`ed +/// at startup -- they never change at runtime. +pub struct ServerStateInner { + /// MongoDB connection pool. + pub db: Database, + /// Keycloak / OAuth2 settings. + pub keycloak: &'static KeycloakConfig, + /// Outbound email settings. + pub smtp: &'static SmtpConfig, + /// URLs for Ollama, SearXNG, LangChain, S3, etc. + pub services: &'static ServiceUrls, + /// Stripe billing keys. + pub stripe: &'static StripeConfig, + /// Enabled LLM provider list. + pub llm_providers: &'static LlmProvidersConfig, +} + +// `FromRequestParts` lets us `extract::()` inside +// Dioxus server functions and regular Axum handlers alike. +impl axum::extract::FromRequestParts for ServerState +where + S: Send + Sync, +{ + type Rejection = Error; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &S, + ) -> Result { + parts + .extensions + .get::() + .cloned() + .ok_or(Error::StateError("ServerState extension not found".into())) + } +} diff --git a/src/infrastructure/state.rs b/src/infrastructure/state.rs index 89d77bc..d6c2bc1 100644 --- a/src/infrastructure/state.rs +++ b/src/infrastructure/state.rs @@ -1,8 +1,8 @@ use std::{ops::Deref, sync::Arc}; -use axum::extract::FromRequestParts; use serde::{Deserialize, Serialize}; +/// Cheap-to-clone handle to per-session user data. #[derive(Debug, Clone)] pub struct UserState(Arc); @@ -19,39 +19,28 @@ impl From for UserState { } } +/// Per-session user data stored in the tower-sessions session store. +/// +/// Persisted across requests for the lifetime of the session. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct UserStateInner { - /// Subject in Oauth + /// Subject identifier from Keycloak (unique user ID). pub sub: String, - /// Access Token + /// OAuth2 access token. pub access_token: String, - /// Refresh Token + /// OAuth2 refresh token. pub refresh_token: String, - /// User + /// Basic user profile. pub user: User, } +/// Basic user profile stored alongside the session. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct User { - /// Email + /// Email address. pub email: String, - /// Avatar Url + /// Display name (preferred_username or full name from Keycloak). + pub name: String, + /// Avatar / profile picture URL. pub avatar_url: String, } - -impl FromRequestParts for UserState -where - S: std::marker::Sync + std::marker::Send, -{ - type Rejection = super::Error; - async fn from_request_parts( - parts: &mut axum::http::request::Parts, - _: &S, - ) -> Result { - parts - .extensions - .get::() - .cloned() - .ok_or(super::Error::StateError("Unable to get extension".into())) - } -} diff --git a/src/models/organization.rs b/src/models/organization.rs index 8013182..790e687 100644 --- a/src/models/organization.rs +++ b/src/models/organization.rs @@ -82,3 +82,37 @@ pub struct BillingUsage { pub tokens_limit: u64, pub billing_cycle_end: String, } + +/// Organisation-level settings stored in MongoDB. +/// +/// These complement Keycloak's Organizations feature with +/// business-specific data (billing, feature flags). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct OrgSettings { + /// Keycloak organisation identifier. + pub org_id: String, + /// Active pricing plan identifier. + pub plan_id: String, + /// Feature flags toggled on for this organisation. + pub enabled_features: Vec, + /// Stripe customer ID linked to this organisation. + pub stripe_customer_id: String, +} + +/// A single billing cycle record stored in MongoDB. +/// +/// Captures seat and token usage between two dates for +/// invoicing and usage dashboards. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct OrgBillingRecord { + /// Keycloak organisation identifier. + pub org_id: String, + /// ISO 8601 start of the billing cycle. + pub cycle_start: String, + /// ISO 8601 end of the billing cycle. + pub cycle_end: String, + /// Number of seats consumed during this cycle. + pub seats_used: u32, + /// Number of tokens consumed during this cycle. + pub tokens_used: u64, +} diff --git a/src/models/user.rs b/src/models/user.rs index b019073..efee692 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,21 +1,44 @@ -use serde::Deserialize; -use serde::Serialize; +use serde::{Deserialize, Serialize}; + +/// Basic user display data used by frontend components. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct UserData { pub name: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LoggedInState { - pub access_token: String, +/// Authentication information returned by the `check_auth` server function. +/// +/// The frontend uses this to determine whether the user is logged in +/// and to display their profile (name, email, avatar). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct AuthInfo { + /// Whether the user has a valid session + pub authenticated: bool, + /// Keycloak subject identifier (unique user ID) + pub sub: String, + /// User email address pub email: String, + /// User display name + pub name: String, + /// Avatar URL (from Keycloak picture claim) + pub avatar_url: String, } -impl LoggedInState { - pub fn new(access_token: String, email: String) -> Self { - Self { - access_token, - email, - } - } +/// Per-user preferences stored in MongoDB. +/// +/// Keyed by `sub` (Keycloak subject) and optionally scoped to an org. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct UserPreferences { + /// Keycloak subject identifier + pub sub: String, + /// Organization ID (from Keycloak Organizations) + pub org_id: String, + /// User-selected news/search topics + pub custom_topics: Vec, + /// Per-user Ollama URL override (empty = use server default) + pub ollama_url_override: String, + /// Per-user Ollama model override (empty = use server default) + pub ollama_model_override: String, + /// Recently searched queries for quick access + pub recent_searches: Vec, }