Files
certifai/src/components/sidebar.rs
Sharang Parnerkar 74a225224c
All checks were successful
CI / Format (push) Successful in 26s
CI / Clippy (push) Successful in 3m0s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Deploy (push) Has been skipped
feat(chat): replace built-in chat with LibreChat SSO integration
Replaces the custom chat page with an external LibreChat instance that
shares Keycloak SSO for seamless auto-login. Removes Tools and Knowledge
Base pages as these are now handled by LibreChat's built-in capabilities.

- Add LibreChat service to docker-compose with Ollama backend config
- Add Keycloak OIDC client (certifai-librechat) with prompt=none for
  silent SSO
- Create librechat.yaml with CERTifAI branding, Ollama endpoint, and
  custom page title/logo
- Change sidebar Chat link to external URL (opens LibreChat in new tab)
- Remove chat page, tools page, knowledge base page and all related
  components (chat_sidebar, chat_bubble, chat_input_bar, etc.)
- Remove tool_card, file_row components and tool/knowledge models
- Remove chat_stream SSE handler (no longer needed)
- Clean up i18n files: remove chat, tools, knowledge sections
- Dashboard article summarization via Ollama remains intact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:37:17 +01:00

394 lines
14 KiB
Rust

use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsGithub, BsGlobe2,
BsGrid, BsHouseDoor, BsMoonFill, BsSunFill,
};
use dioxus_free_icons::Icon;
use crate::i18n::{t, Locale};
use crate::Route;
/// Destination for a sidebar link: either an internal route or an external URL.
enum NavTarget {
/// Internal Dioxus route (rendered as `Link { to: route }`).
Internal(Route),
/// External URL opened in a new tab (rendered as `<a href>`).
External(&'static str),
}
/// Navigation entry for the sidebar.
///
/// `key` is a stable identifier used for active-route detection and never
/// changes across locales. `label` is the translated display string.
struct NavItem {
key: &'static str,
label: String,
target: NavTarget,
/// Bootstrap icon element rendered beside the label.
icon: Element,
}
/// Fixed left sidebar containing header, navigation, logout, and footer.
///
/// # Arguments
///
/// * `name` - User display name (shown in header if non-empty).
/// * `email` - Email address displayed beneath the avatar placeholder.
/// * `avatar_url` - URL for the avatar image (unused placeholder for now).
/// * `class` - CSS class override (e.g. to add `sidebar--open` on mobile).
/// * `on_nav` - Callback fired when a nav link is clicked (used to close
/// the mobile menu).
#[component]
pub fn Sidebar(
name: String,
email: String,
avatar_url: String,
#[props(default = "sidebar".to_string())] class: String,
#[props(default)] on_nav: EventHandler<()>,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let locale_val = *locale.read();
let nav_items: Vec<NavItem> = vec![
NavItem {
key: "dashboard",
label: t(locale_val, "nav.dashboard"),
target: NavTarget::Internal(Route::DashboardPage {}),
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
},
NavItem {
key: "providers",
label: t(locale_val, "nav.providers"),
target: NavTarget::Internal(Route::ProvidersPage {}),
icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } },
},
NavItem {
key: "chat",
label: t(locale_val, "nav.chat"),
// Opens LibreChat in a new tab; SSO via shared Keycloak realm.
target: NavTarget::External("http://localhost:3080"),
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
},
NavItem {
key: "developer",
label: t(locale_val, "nav.developer"),
target: NavTarget::Internal(Route::AgentsPage {}),
icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } },
},
NavItem {
key: "organization",
label: t(locale_val, "nav.organization"),
target: NavTarget::Internal(Route::OrgPricingPage {}),
icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } },
},
];
// Determine current path to highlight the active nav link.
let current_route = use_route::<Route>();
let logout_label = t(locale_val, "common.logout");
rsx! {
aside { class: "{class}",
div { class: "sidebar-top-row",
SidebarHeader { name, email: email.clone(), avatar_url }
LocalePicker {}
}
nav { class: "sidebar-nav",
for item in nav_items {
{
match &item.target {
NavTarget::Internal(route) => {
// Active detection for nested routes: highlight the parent
// nav item when any child route within the nested shell
// is active.
let is_active = match &current_route {
Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => {
item.key == "developer"
}
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
item.key == "organization"
}
_ => *route == current_route,
};
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
let route = route.clone();
rsx! {
Link {
to: route,
class: cls,
onclick: move |_| on_nav.call(()),
{item.icon}
span { "{item.label}" }
}
}
}
NavTarget::External(url) => {
let url = *url;
rsx! {
a {
href: url,
target: "_blank",
rel: "noopener noreferrer",
class: "sidebar-link",
onclick: move |_| on_nav.call(()),
{item.icon}
span { "{item.label}" }
}
}
}
}
}
}
}
div { class: "sidebar-bottom-actions",
Link {
to: NavigationTarget::<Route>::External("/logout".into()),
class: "sidebar-link logout-btn",
Icon { icon: BsBoxArrowRight, width: 18, height: 18 }
span { "{logout_label}" }
}
ThemeToggle {}
}
SidebarFooter {}
}
}
}
/// 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(name: String, email: String, avatar_url: String) -> Element {
// Derive initials: prefer name words, fall back to email prefix.
let initials: String = if name.is_empty() {
email
.split('@')
.next()
.unwrap_or("U")
.chars()
.take(2)
.collect::<String>()
.to_uppercase()
} else {
name.split_whitespace()
.filter_map(|w| w.chars().next())
.take(2)
.collect::<String>()
.to_uppercase()
};
rsx! {
div { class: "sidebar-header",
div { class: "avatar-circle",
span { class: "avatar-initials", "{initials}" }
}
div { class: "sidebar-user-info",
if !name.is_empty() {
p { class: "sidebar-name", "{name}" }
}
p { class: "sidebar-email", "{email}" }
}
}
}
}
/// Toggle button that switches between dark and light themes.
///
/// Sets `data-theme` on the `<html>` element and persists the choice
/// in `localStorage` so it survives page reloads.
#[component]
fn ThemeToggle() -> Element {
let locale = use_context::<Signal<Locale>>();
let mut is_dark = use_signal(|| {
// Read persisted preference from localStorage on first render.
#[cfg(feature = "web")]
{
web_sys::window()
.and_then(|w| w.local_storage().ok().flatten())
.and_then(|s| s.get_item("theme").ok().flatten())
.is_none_or(|v| v != "certifai-light")
}
#[cfg(not(feature = "web"))]
{
true
}
});
// Apply the persisted theme to the DOM on first render so the
// page doesn't flash dark if the user previously chose light.
#[cfg(feature = "web")]
{
let dark = *is_dark.read();
use_effect(move || {
let theme = if dark {
"certifai-dark"
} else {
"certifai-light"
};
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Some(el) = doc.document_element() {
let _ = el.set_attribute("data-theme", theme);
}
}
});
}
let toggle = move |_| {
let new_dark = !*is_dark.read();
is_dark.set(new_dark);
#[cfg(feature = "web")]
{
let theme = if new_dark {
"certifai-dark"
} else {
"certifai-light"
};
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Some(el) = doc.document_element() {
let _ = el.set_attribute("data-theme", theme);
}
}
if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten())
{
let _ = storage.set_item("theme", theme);
}
}
};
let dark = *is_dark.read();
let locale_val = *locale.read();
let title = if dark {
t(locale_val, "nav.switch_light")
} else {
t(locale_val, "nav.switch_dark")
};
rsx! {
button {
class: "theme-toggle-btn",
title: "{title}",
onclick: toggle,
if dark {
Icon { icon: BsSunFill, width: 16, height: 16 }
} else {
Icon { icon: BsMoonFill, width: 16, height: 16 }
}
}
}
}
/// Compact language picker with globe icon and ISO 3166-1 alpha-2 code.
///
/// Renders a button showing a globe icon and the current locale's two-letter
/// country code (e.g. "EN", "DE"). Clicking toggles a dropdown overlay with
/// all available locales. Persists the selection to `localStorage`.
#[component]
fn LocalePicker() -> Element {
let mut locale = use_context::<Signal<Locale>>();
let current = *locale.read();
let mut open = use_signal(|| false);
let mut select_locale = move |new_locale: Locale| {
locale.set(new_locale);
open.set(false);
#[cfg(feature = "web")]
{
if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten())
{
let _ = storage.set_item("certifai_locale", new_locale.code());
}
}
};
let code_upper = current.code().to_uppercase();
rsx! {
div { class: "locale-picker",
button {
class: "locale-picker-btn",
title: current.label(),
onclick: move |_| {
let cur = *open.read();
open.set(!cur);
},
Icon { icon: BsGlobe2, width: 14, height: 14 }
span { class: "locale-picker-code", "{code_upper}" }
}
if *open.read() {
// Invisible backdrop to close dropdown on outside click
div {
class: "locale-picker-backdrop",
onclick: move |_| open.set(false),
}
div { class: "locale-picker-dropdown",
for loc in Locale::all() {
{
let is_active = *loc == current;
let cls = if is_active {
"locale-picker-item locale-picker-item--active"
} else {
"locale-picker-item"
};
let loc_copy = *loc;
rsx! {
button {
class: "{cls}",
onclick: move |_| select_locale(loc_copy),
span { class: "locale-picker-item-code",
"{loc_copy.code().to_uppercase()}"
}
span { class: "locale-picker-item-label",
"{loc_copy.label()}"
}
}
}
}
}
}
}
}
}
}
/// Footer section with version string and placeholder social links.
#[component]
fn SidebarFooter() -> Element {
let locale = use_context::<Signal<Locale>>();
let locale_val = *locale.read();
let version = env!("CARGO_PKG_VERSION");
let github_title = t(locale_val, "nav.github");
let impressum_title = t(locale_val, "common.impressum");
let privacy_label = t(locale_val, "common.privacy_policy");
let impressum_label = t(locale_val, "common.impressum");
rsx! {
footer { class: "sidebar-footer",
div { class: "sidebar-social",
a { href: "#", class: "social-link", title: "{github_title}",
Icon { icon: BsGithub, width: 16, height: 16 }
}
a { href: "#", class: "social-link", title: "{impressum_title}",
Icon { icon: BsGrid, width: 16, height: 16 }
}
}
div { class: "sidebar-legal",
Link { to: Route::PrivacyPage {}, class: "legal-link", "{privacy_label}" }
span { class: "legal-sep", "|" }
Link { to: Route::ImpressumPage {}, class: "legal-link", "{impressum_label}" }
}
p { class: "sidebar-version", "v{version}" }
}
}
}