From 3ae67b23c9fa79ae40657237f7eafd1a97903e74 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Fri, 23 Jan 2026 09:32:36 -0300 Subject: [PATCH] feat: add favicon, logo and Update the platform name (only available to the superuser) and company names --- roadmap.md | 3 +- .../20260123000000_add_branding_fields.sql | 10 + services/cms-service/src/handlers_branding.rs | 121 +++++++++- services/cms-service/src/main.rs | 4 + shared/common/src/models.rs | 2 + web/experience/src/components/AppHeader.tsx | 15 +- .../src/context/BrandingContext.tsx | 31 +++ web/experience/src/lib/api.ts | 2 + web/studio/src/app/layout.tsx | 2 + web/studio/src/app/settings/page.tsx | 31 +++ web/studio/src/components/BrandingManager.tsx | 53 +++++ .../src/components/BrandingSettings.tsx | 214 ++++++++++++++++++ web/studio/src/components/FileUpload.tsx | 14 +- web/studio/src/components/Navbar.tsx | 2 - web/studio/src/lib/api.ts | 23 ++ 15 files changed, 515 insertions(+), 12 deletions(-) create mode 100644 services/cms-service/migrations/20260123000000_add_branding_fields.sql create mode 100644 web/studio/src/app/settings/page.tsx create mode 100644 web/studio/src/components/BrandingManager.tsx create mode 100644 web/studio/src/components/BrandingSettings.tsx diff --git a/roadmap.md b/roadmap.md index 75f1133..570157a 100644 --- a/roadmap.md +++ b/roadmap.md @@ -78,7 +78,8 @@ - [x] **Super Admin y Org por Defecto**: Gestión global de todos los inquilinos - [x] **Visibilidad Global de Cursos**: Cursos de sistema disponibles para todas las organizaciones - [x] **Personalización de Marca (Branding)**: Identidad propia por organización (Completado) - - [x] Carga y optimización de logotipos + - [x] Carga y optimización de logotipos y favicons customizados + - [x] Nombre de plataforma personalizado (White-label) - [x] Esquemas de colores personalizados (Primario/Secundario) - [x] Adaptación dinámica del portal de Experience - [x] Previsualización en vivo del branding en Studio diff --git a/services/cms-service/migrations/20260123000000_add_branding_fields.sql b/services/cms-service/migrations/20260123000000_add_branding_fields.sql new file mode 100644 index 0000000..4b0c303 --- /dev/null +++ b/services/cms-service/migrations/20260123000000_add_branding_fields.sql @@ -0,0 +1,10 @@ +-- Migration: Add Platform Name and Favicon to Organizations +-- Adds fields for white-labeling the platform name and favicon + +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS platform_name TEXT, + ADD COLUMN IF NOT EXISTS favicon_url TEXT; + +-- Add comments for documentation +COMMENT ON COLUMN organizations.platform_name IS 'Custom name for the platform (e.g., "My Company Academy")'; +COMMENT ON COLUMN organizations.favicon_url IS 'URL path to organization favicon (stored in /uploads/org-favicons/)'; diff --git a/services/cms-service/src/handlers_branding.rs b/services/cms-service/src/handlers_branding.rs index c13bfab..b6c2038 100644 --- a/services/cms-service/src/handlers_branding.rs +++ b/services/cms-service/src/handlers_branding.rs @@ -17,11 +17,14 @@ use super::handlers::log_action; pub struct BrandingPayload { pub primary_color: Option, pub secondary_color: Option, + pub platform_name: Option, } #[derive(Serialize)] pub struct BrandingResponse { pub logo_url: Option, + pub favicon_url: Option, + pub platform_name: Option, pub primary_color: String, pub secondary_color: String, } @@ -138,6 +141,118 @@ pub async fn upload_organization_logo( Err((StatusCode::BAD_REQUEST, "No file provided".into())) } +// Upload organization favicon +pub async fn upload_organization_favicon( + claims: common::auth::Claims, + State(pool): State, + Path(org_id): Path, + mut multipart: axum::extract::Multipart, +) -> Result, (StatusCode, String)> { + // Only admins can upload favicons + if claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Admin access required".into())); + } + + // Verify organization exists and user has access + let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1") + .bind(org_id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?; + + // Process multipart form + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e)))? + { + let name = field.name().unwrap_or("").to_string(); + + if name == "file" { + let filename = field + .file_name() + .ok_or((StatusCode::BAD_REQUEST, "Missing filename".into()))? + .to_string(); + + // Validate file extension + let ext = filename.split('.').last().unwrap_or(""); + if !["png", "jpg", "jpeg", "svg", "ico"].contains(&ext.to_lowercase().as_str()) { + return Err(( + StatusCode::BAD_REQUEST, + "Invalid file type. Only PNG, JPG, SVG, and ICO allowed".into(), + )); + } + + let data = field.bytes().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to read file: {}", e), + ) + })?; + + // Validate file size (max 512KB for favicons seems reasonable, but sticking to 1MB to be safe) + if data.len() > 1024 * 1024 { + return Err(( + StatusCode::BAD_REQUEST, + "File too large. Maximum 1MB allowed".into(), + )); + } + + // Create uploads directory if it doesn't exist + std::fs::create_dir_all("uploads/org-favicons").map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create directory: {}", e), + ) + })?; + + // Generate unique filename + let unique_filename = format!("{}_{}.{}", org_id, uuid::Uuid::new_v4(), ext); + let filepath = format!("uploads/org-favicons/{}", unique_filename); + + // Save file + std::fs::write(&filepath, &data).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to save file: {}", e), + ) + })?; + + // Update organization in database + let favicon_url = format!("/{}", filepath); + sqlx::query("UPDATE organizations SET favicon_url = $1 WHERE id = $2") + .bind(&favicon_url) + .bind(org_id) + .execute(&pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + ) + })?; + + log_action( + &pool, + claims.org, + claims.sub, + "UPDATE_FAVICON", + "Organization", + org_id, + json!({"favicon_url": &favicon_url}), + ) + .await; + + return Ok(Json(json!({ + "favicon_url": favicon_url, + "message": "Favicon uploaded successfully" + }))); + } + } + + Err((StatusCode::BAD_REQUEST, "No file provided".into())) +} + // Update organization branding colors pub async fn update_organization_branding( claims: common::auth::Claims, @@ -180,12 +295,14 @@ pub async fn update_organization_branding( "UPDATE organizations SET primary_color = COALESCE($1, primary_color), secondary_color = COALESCE($2, secondary_color), + platform_name = COALESCE($3, platform_name), updated_at = NOW() - WHERE id = $3 + WHERE id = $4 RETURNING *", ) .bind(&payload.primary_color) .bind(&payload.secondary_color) + .bind(&payload.platform_name) .bind(org_id) .fetch_one(&pool) .await @@ -223,6 +340,8 @@ pub async fn get_organization_branding( Ok(Json(BrandingResponse { logo_url: org.logo_url, + favicon_url: org.favicon_url, + platform_name: org.platform_name, primary_color: org.primary_color.unwrap_or_else(|| "#3B82F6".to_string()), secondary_color: org.secondary_color.unwrap_or_else(|| "#8B5CF6".to_string()), })) diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 725b5f7..89b0ed6 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -168,6 +168,10 @@ async fn main() { "/organizations/{id}/logo", post(handlers_branding::upload_organization_logo), ) + .route( + "/organizations/{id}/favicon", + post(handlers_branding::upload_organization_favicon), + ) .route( "/organizations/{id}/branding", axum::routing::put(handlers_branding::update_organization_branding), diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index b7f9f4b..b21101b 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -178,6 +178,8 @@ pub struct Organization { pub primary_color: Option, pub secondary_color: Option, pub certificate_template: Option, + pub platform_name: Option, + pub favicon_url: Option, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/web/experience/src/components/AppHeader.tsx b/web/experience/src/components/AppHeader.tsx index 12434dd..b454576 100644 --- a/web/experience/src/components/AppHeader.tsx +++ b/web/experience/src/components/AppHeader.tsx @@ -8,26 +8,33 @@ import { useTranslation } from "@/context/I18nContext"; import { LogOut, Globe } from "lucide-react"; import NotificationCenter from "./NotificationCenter"; +import { lmsApi, getImageUrl } from "@/lib/api"; + export default function AppHeader() { const { t, language, setLanguage } = useTranslation(); const { branding } = useBranding(); const { user, logout } = useAuth(); + // Use platform_name if available, otherwise name, otherwise default + const platformName = branding?.platform_name || branding?.name || 'OpenCCB'; + return (
{branding?.logo_url ? ( - {branding.name} + {branding.name} ) : ( -
L
+
+ {platformName.charAt(0).toUpperCase()} +
)}
- {branding?.name?.toUpperCase() || 'APRENDER'} + {platformName.toUpperCase()} - {!branding && EXPERIENCIA} + EXPERIENCIA
diff --git a/web/experience/src/context/BrandingContext.tsx b/web/experience/src/context/BrandingContext.tsx index 6dab2db..172992e 100644 --- a/web/experience/src/context/BrandingContext.tsx +++ b/web/experience/src/context/BrandingContext.tsx @@ -34,6 +34,37 @@ export const BrandingProvider: React.FC<{ children: React.ReactNode }> = ({ chil if (data.secondary_color) { document.documentElement.style.setProperty('--secondary-color', data.secondary_color); } + + // Update Title + if (data.platform_name) { + document.title = `${data.platform_name} | Experiencia de Aprendizaje`; + } + + // Update Favicon + if (data.favicon_url) { + // Import getImageUrl logic locally or assume it needs import + // Since I can't easily add import at top with replace_file, I will assume getImageUrl handles the path or do logic here. + // Actually I need to import getImageUrl at the top. Instead of complicating, I'll update imports too. + const getImageUrl = (path?: string) => { + if (!path) return ''; + if (path.startsWith('http')) return path; + const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001"; + const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path; + const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`; + return `${CMS_API_URL}${finalPath}`; + }; + + const faviconUrl = getImageUrl(data.favicon_url); + const link: HTMLLinkElement | null = document.querySelector("link[rel*='icon']"); + if (link) { + link.href = faviconUrl; + } else { + const newLink = document.createElement("link"); + newLink.rel = "shortcut icon"; + newLink.href = faviconUrl; + document.head.appendChild(newLink); + } + } } catch (error) { console.error('Failed to load branding', error); } finally { diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 53ef715..53f5487 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -13,6 +13,8 @@ export interface Organization { id: string; name: string; logo_url?: string; + favicon_url?: string; + platform_name?: string; primary_color?: string; secondary_color?: string; } diff --git a/web/studio/src/app/layout.tsx b/web/studio/src/app/layout.tsx index e85afb5..62b2823 100644 --- a/web/studio/src/app/layout.tsx +++ b/web/studio/src/app/layout.tsx @@ -15,6 +15,7 @@ export const metadata: Metadata = { }; import AuthHeader from "@/components/AuthHeader"; +import BrandingManager from "@/components/BrandingManager"; export default function RootLayout({ children, @@ -27,6 +28,7 @@ export default function RootLayout({ +
diff --git a/web/studio/src/app/settings/page.tsx b/web/studio/src/app/settings/page.tsx new file mode 100644 index 0000000..b93df52 --- /dev/null +++ b/web/studio/src/app/settings/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import BrandingSettings from "@/components/BrandingSettings"; +import { useAuth } from "@/context/AuthContext"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function SettingsPage() { + const { user, loading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!loading && (!user || user.role !== "admin")) { + router.push("/"); + } + }, [user, loading, router]); + + if (loading) return null; + + if (!user || user.role !== "admin") return null; + + return ( +
+
+

Organization Settings

+

Manage your white-label branding and platform identity.

+
+ +
+ ); +} diff --git a/web/studio/src/components/BrandingManager.tsx b/web/studio/src/components/BrandingManager.tsx new file mode 100644 index 0000000..18513a3 --- /dev/null +++ b/web/studio/src/components/BrandingManager.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect } from "react"; +import { useAuth } from "@/context/AuthContext"; +import { cmsApi, getImageUrl } from "@/lib/api"; + +export default function BrandingManager() { + const { user } = useAuth(); + + useEffect(() => { + if (!user || user.role === "super_admin") return; + // Note: checking user.role === "admin" might be enough, but regular users in Studio (instructors) should also see branding? + // Usually YES. + + const fetchBranding = async () => { + try { + // If user has organization_id, fetch that org + // cmsApi.getOrganization() fetches based on X-Organization-Id or token claims. + // Assuming getOrganization() returns the current context org. + const org = await cmsApi.getOrganization(); + + if (org) { + // Update Title + if (org.platform_name) { + document.title = `${org.platform_name} | Studio`; + } + + // Update Favicon + if (org.favicon_url) { + const faviconUrl = getImageUrl(org.favicon_url); + const link: HTMLLinkElement | null = document.querySelector("link[rel*='icon']"); + if (link) { + link.href = faviconUrl; + } else { + const newLink = document.createElement("link"); + newLink.rel = "shortcut icon"; + newLink.href = faviconUrl; + document.head.appendChild(newLink); + } + } + } + } catch (err) { + console.error("Failed to load branding", err); + } + }; + + if (user) { + fetchBranding(); + } + }, [user]); + + return null; +} diff --git a/web/studio/src/components/BrandingSettings.tsx b/web/studio/src/components/BrandingSettings.tsx new file mode 100644 index 0000000..284d388 --- /dev/null +++ b/web/studio/src/components/BrandingSettings.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { cmsApi, Organization, BrandingPayload, getImageUrl } from "@/lib/api"; +import FileUpload from "./FileUpload"; +import { useRouter } from "next/navigation"; + +export default function BrandingSettings() { + const router = useRouter(); + const [org, setOrg] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [formData, setFormData] = useState({ + primary_color: "#3B82F6", + secondary_color: "#8B5CF6", + platform_name: "", + }); + + useEffect(() => { + fetchOrg(); + }, []); + + const fetchOrg = async () => { + try { + const data = await cmsApi.getOrganization(); + setOrg(data); + setFormData({ + primary_color: data.primary_color || "#3B82F6", + secondary_color: data.secondary_color || "#8B5CF6", + platform_name: data.platform_name || "", + }); + } catch (error) { + console.error("Failed to fetch organization:", error); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!org) return; + setSaving(true); + try { + await cmsApi.updateOrganizationBranding(org.id, formData); + // Refresh to update local state logic if needed + fetchOrg(); + alert("Branding updated successfully!"); + router.refresh(); // Refresh layouts to pick up new branding + } catch (error) { + console.error("Failed to update branding:", error); + alert("Failed to update branding settings."); + } finally { + setSaving(false); + } + }; + + const handleLogoUpload = async (file: File, onProgress: (pct: number) => void) => { + if (!org) throw new Error("No organization loaded"); + // Simulate progress for smoother UX if the API doesn't support it natively for direct fetch + // API wrapper uses fetch/xhr so branding API in api.ts needs to handle it. + // For now, we assume simple upload. + onProgress(50); + const res = await cmsApi.uploadOrganizationLogo(org.id, file); + onProgress(100); + return { url: res.logo_url }; // api returns { logo_url: ... } + }; + + const handleFaviconUpload = async (file: File, onProgress: (pct: number) => void) => { + if (!org) throw new Error("No organization loaded"); + onProgress(50); + const res = await cmsApi.uploadOrganizationFavicon(org.id, file); + onProgress(100); + return { url: res.favicon_url }; + }; + + if (loading) return
Loading settings...
; + if (!org) return
Failed to load organization settings.
; + + return ( +
+
+

+ 🎨 Brand Identity +

+ +
+ {/* Platform Name */} +
+ + setFormData({ ...formData, platform_name: e.target.value })} + placeholder={org.name} + className="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500/50 transition-colors" + /> +

Appears in the browser tab and page titles.

+
+ + {/* Logo Section */} +
+ +
+ {org.logo_url ? ( + Logo + ) : ( + No logo uploaded + )} +
+ { + // Adapt the response format from logo upload API to what FileUpload expects + const res = await cmsApi.uploadOrganizationLogo(org.id, file); + return { url: res.logo_url || "" }; + }} + onUploadComplete={(url) => { + setOrg({ ...org, logo_url: url }); + router.refresh(); + }} + /> +

Recommended: SVG or PNG, max 2MB.

+
+ + {/* Favicon Section */} +
+ +
+ {org.favicon_url ? ( + Favicon + ) : ( + No favicon + )} +
+ { + const res = await cmsApi.uploadOrganizationFavicon(org.id, file); + return { url: res.favicon_url || "" }; + }} + onUploadComplete={(url) => { + setOrg({ ...org, favicon_url: url }); + router.refresh(); + }} + /> +

Recommended: ICO or PNG, 32x32px.

+
+
+
+ +
+

+ 🌈 Brand Colors +

+
+ {/* Primary Color */} +
+ +
+ setFormData({ ...formData, primary_color: e.target.value })} + className="w-12 h-12 rounded-lg cursor-pointer bg-transparent border-none p-0" + /> +
+ setFormData({ ...formData, primary_color: e.target.value })} + className="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-2 text-white font-mono uppercase" + /> +
+
+

Used for main buttons, active states, and highlights.

+
+ + {/* Secondary Color */} +
+ +
+ setFormData({ ...formData, secondary_color: e.target.value })} + className="w-12 h-12 rounded-lg cursor-pointer bg-transparent border-none p-0" + /> +
+ setFormData({ ...formData, secondary_color: e.target.value })} + className="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-2 text-white font-mono uppercase" + /> +
+
+

Used for accents and gradients.

+
+
+
+ +
+ +
+
+ ); +} diff --git a/web/studio/src/components/FileUpload.tsx b/web/studio/src/components/FileUpload.tsx index ec47f8d..5bb2cfe 100644 --- a/web/studio/src/components/FileUpload.tsx +++ b/web/studio/src/components/FileUpload.tsx @@ -7,9 +7,10 @@ interface FileUploadProps { onUploadComplete: (url: string) => void; currentUrl?: string; accept?: string; + customUploadFn?: (file: File, onProgress: (pct: number) => void) => Promise<{ url: string }>; } -export default function FileUpload({ onUploadComplete, currentUrl, accept = "video/*,audio/*" }: FileUploadProps) { +export default function FileUpload({ onUploadComplete, currentUrl, accept = "video/*,audio/*", customUploadFn }: FileUploadProps) { const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadingFileName, setUploadingFileName] = useState(""); @@ -21,9 +22,14 @@ export default function FileUpload({ onUploadComplete, currentUrl, accept = "vid setUploadProgress(0); setUploadingFileName(file.name); try { - const result = await cmsApi.uploadAsset(file, (pct) => { - setUploadProgress(pct); - }); + let result; + if (customUploadFn) { + result = await customUploadFn(file, (pct) => setUploadProgress(pct)); + } else { + result = await cmsApi.uploadAsset(file, (pct) => { + setUploadProgress(pct); + }); + } onUploadComplete(result.url); } catch (err) { alert("Upload failed. Please try again."); diff --git a/web/studio/src/components/Navbar.tsx b/web/studio/src/components/Navbar.tsx index a7841d2..e48d084 100644 --- a/web/studio/src/components/Navbar.tsx +++ b/web/studio/src/components/Navbar.tsx @@ -56,7 +56,6 @@ export function Navbar() { )} - {/* Settings - */}
diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index 4af048c..8310da0 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -106,6 +106,8 @@ export interface Organization { name: string; domain?: string; logo_url?: string; + favicon_url?: string; + platform_name?: string; primary_color?: string; secondary_color?: string; certificate_template?: string; @@ -116,6 +118,7 @@ export interface Organization { export interface BrandingPayload { primary_color?: string; secondary_color?: string; + platform_name?: string; } export interface User { @@ -156,6 +159,8 @@ export interface UploadResponse { id: string; filename: string; url: string; + logo_url?: string; + favicon_url?: string; } export interface GradingCategory { @@ -390,6 +395,24 @@ export const cmsApi = { return res.json(); }); }, + uploadOrganizationFavicon: (id: string, file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + const token = getToken(); + const selectedOrgId = getSelectedOrgId(); + const headers: Record = { + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + ...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {}) + }; + return fetch(`${API_BASE_URL}/organizations/${id}/favicon`, { + method: 'POST', + headers, + body: formData, + }).then(res => { + if (!res.ok) return res.json().then(err => Promise.reject(new Error(err.message || 'Favicon upload failed'))); + return res.json(); + }); + }, // SSO getSSOConfig: (): Promise => apiFetch('/organization/sso'),