From 60e2af72f0ba2e7bed4c767bf187f7932378550e Mon Sep 17 00:00:00 2001 From: Nurfog Date: Fri, 23 Jan 2026 11:43:17 -0300 Subject: [PATCH] feat: ad interface to upload logo and favicon --- README.md | 1 + services/cms-service/src/handlers.rs | 109 ++++++++++++++ services/cms-service/src/main.rs | 3 +- .../src/app/admin/organizations/page.tsx | 58 +++++++- web/studio/src/app/admin/users/page.tsx | 134 +++++++++++++++++- web/studio/src/app/profile/page.tsx | 7 +- web/studio/src/components/AuthHeader.tsx | 5 +- .../src/components/BrandingSettings.tsx | 50 +++---- web/studio/src/lib/api.ts | 17 +++ 9 files changed, 346 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 0cc0249..fe77a3e 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,7 @@ Obtiene una lista de todas las organizaciones registradas. - **Global Localization**: Native support for English, Spanish, and Portuguese. - **PDF Integrated Viewer**: Read academic documents without leaving the platform. - **Interactive Video Markers**: Pause-and-answer questions embedded in video lessons. +- **White-Label Branding**: Fully custom platform name, logo, favicon, and color themes per organization. ## 📄 Licencia Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio. \ No newline at end of file diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index e058eb9..58ca9de 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -1773,6 +1773,23 @@ pub struct AuthPayload { pub organization_name: Option, } +#[derive(serde::Deserialize)] +pub struct ProvisionPayload { + pub org_name: String, + pub org_domain: Option, + pub admin_email: String, + pub admin_password: String, + pub admin_full_name: String, +} + +#[derive(serde::Deserialize, serde::Serialize)] +pub struct AdminCreateUserPayload { + pub email: String, + pub password: String, + pub full_name: String, + pub role: String, +} + pub async fn register( State(pool): State, Json(payload): Json, @@ -1842,6 +1859,52 @@ pub async fn register( })) } +pub async fn admin_create_user( + Org(org_ctx): Org, + claims: common::auth::Claims, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Admin access required".into())); + } + + if !["admin", "instructor", "student"].contains(&payload.role.as_str()) { + return Err((StatusCode::BAD_REQUEST, "Invalid role".into())); + } + + let password_hash = hash(payload.password, DEFAULT_COST) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?; + + let user = sqlx::query_as::<_, User>( + "INSERT INTO users (email, password_hash, full_name, role, organization_id) VALUES ($1, $2, $3, $4, $5) RETURNING *" + ) + .bind(&payload.email) + .bind(password_hash) + .bind(&payload.full_name) + .bind(&payload.role) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| { + tracing::error!("Failed to create user: {}", e); + (StatusCode::CONFLICT, "User already exists or DB error".into()) + })?; + + Ok(Json(UserResponse { + id: user.id, + email: user.email, + full_name: user.full_name, + role: user.role, + organization_id: user.organization_id, + xp: user.xp, + level: user.level, + avatar_url: user.avatar_url, + bio: user.bio, + language: user.language, + })) +} + pub async fn login( State(pool): State, Json(payload): Json, @@ -2758,6 +2821,52 @@ pub async fn create_organization( Ok(Json(org)) } +pub async fn provision_organization( + claims: common::auth::Claims, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if claims.role != "admin" || claims.org != Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap() { + return Err((StatusCode::FORBIDDEN, "Super Admin access required".into())); + } + + let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let org = sqlx::query_as::<_, Organization>( + "INSERT INTO organizations (name, domain) VALUES ($1, $2) RETURNING *" + ) + .bind(&payload.org_name) + .bind(&payload.org_domain) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to create organization: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create organization".into()) + })?; + + let password_hash = hash(payload.admin_password, DEFAULT_COST) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?; + + sqlx::query( + "INSERT INTO users (email, password_hash, full_name, role, organization_id) VALUES ($1, $2, $3, $4, $5)" + ) + .bind(&payload.admin_email) + .bind(password_hash) + .bind(&payload.admin_full_name) + .bind("admin") + .bind(org.id) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to create admin user: {}", e); + (StatusCode::CONFLICT, "User already exists or DB error".into()) + })?; + + tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(org)) +} + #[derive(serde::Deserialize)] pub struct CreateWebhookPayload { pub url: String, diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 89b0ed6..17664a4 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -139,7 +139,7 @@ async fn main() { get(handlers::get_grading_categories), ) .route("/auth/me", get(handlers::get_me)) - .route("/users", get(handlers::get_all_users)) + .route("/users", get(handlers::get_all_users).post(handlers::admin_create_user)) .route("/users/{id}", axum::routing::put(handlers::update_user)) .route("/audit-logs", get(handlers::get_audit_logs)) .route("/api/ai/review-text", post(handlers::review_text)) @@ -151,6 +151,7 @@ async fn main() { "/organizations", get(handlers::get_organizations).post(handlers::create_organization), ) + .route("/admin/provision", post(handlers::provision_organization)) .route( "/webhooks", get(handlers::get_webhooks).post(handlers::create_webhook), diff --git a/web/studio/src/app/admin/organizations/page.tsx b/web/studio/src/app/admin/organizations/page.tsx index 565a5ac..f288155 100644 --- a/web/studio/src/app/admin/organizations/page.tsx +++ b/web/studio/src/app/admin/organizations/page.tsx @@ -13,6 +13,11 @@ export default function OrganizationsPage() { const [newName, setNewName] = useState(''); const [newDomain, setNewDomain] = useState(''); + // Admin User States + const [adminFullName, setAdminFullName] = useState(''); + const [adminEmail, setAdminEmail] = useState(''); + const [adminPassword, setAdminPassword] = useState(''); + // Branding States const [isBrandingModalOpen, setIsBrandingModalOpen] = useState(false); const [selectedOrg, setSelectedOrg] = useState(null); @@ -49,13 +54,23 @@ export default function OrganizationsPage() { const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); try { - await cmsApi.createOrganization(newName, newDomain || undefined); + await cmsApi.provisionOrganization({ + org_name: newName, + org_domain: newDomain || undefined, + admin_full_name: adminFullName, + admin_email: adminEmail, + admin_password: adminPassword + }); setNewName(''); setNewDomain(''); + setAdminFullName(''); + setAdminEmail(''); + setAdminPassword(''); setIsModalOpen(false); loadOrganizations(); } catch (error) { console.error('Failed to create organization', error); + alert('Failed to provision organization. Please ensure the email is unique.'); } }; @@ -271,10 +286,49 @@ export default function OrganizationsPage() { type="text" value={newDomain} onChange={(e) => setNewDomain(e.target.value)} - className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all" + className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm" placeholder="e.g. acme.com" /> + +
+

Initial Administrator

+
+
+ + setAdminFullName(e.target.value)} + className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all" + placeholder="e.g. John Doe" + /> +
+
+ + setAdminEmail(e.target.value)} + className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all" + placeholder="admin@acme.com" + /> +
+
+ + setAdminPassword(e.target.value)} + className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all" + placeholder="••••••••" + /> +
+
+
@@ -167,6 +198,101 @@ export default function UsersPage() {
)} + + {/* Create User Modal */} + {isModalOpen && ( +
+
+
+
+
+ +
+

Add New User

+
+ +
+ +
+
+ +
+ + setNewUser({ ...newUser, full_name: e.target.value })} + className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all" + placeholder="e.g. John Doe" + /> +
+
+ +
+ +
+ + setNewUser({ ...newUser, email: e.target.value })} + className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm" + placeholder="user@example.com" + /> +
+
+ +
+ +
+ + setNewUser({ ...newUser, password: e.target.value })} + className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all" + placeholder="••••••••" + /> +
+
+ +
+ + +
+ +
+ + +
+
+
+
+ )} ); } diff --git a/web/studio/src/app/profile/page.tsx b/web/studio/src/app/profile/page.tsx index 5f554d6..3c2dc87 100644 --- a/web/studio/src/app/profile/page.tsx +++ b/web/studio/src/app/profile/page.tsx @@ -16,6 +16,7 @@ import { LogOut, Trash2 } from "lucide-react"; +import Image from "next/image"; export default function ProfilePage() { const { t, setLanguage: setContextLanguage } = useTranslation(); @@ -102,10 +103,12 @@ export default function ProfilePage() {
{avatarUrl ? ( - {fullName} ) : ( diff --git a/web/studio/src/components/AuthHeader.tsx b/web/studio/src/components/AuthHeader.tsx index 15aaa02..c5932b3 100644 --- a/web/studio/src/components/AuthHeader.tsx +++ b/web/studio/src/components/AuthHeader.tsx @@ -1,7 +1,7 @@ "use client"; import { useAuth } from "@/context/AuthContext"; -import { LogOut, ShieldAlert, Building2, Activity } from "lucide-react"; +import { LogOut, ShieldAlert, Building2, Activity, Settings } from "lucide-react"; import Link from "next/link"; export default function AuthHeader() { @@ -19,6 +19,9 @@ export default function AuthHeader() { Tasks + + Settings + )} {user && ( diff --git a/web/studio/src/components/BrandingSettings.tsx b/web/studio/src/components/BrandingSettings.tsx index 284d388..3d4ef3a 100644 --- a/web/studio/src/components/BrandingSettings.tsx +++ b/web/studio/src/components/BrandingSettings.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import Image from "next/image"; import { cmsApi, Organization, BrandingPayload, getImageUrl } from "@/lib/api"; import FileUpload from "./FileUpload"; import { useRouter } from "next/navigation"; @@ -41,10 +42,9 @@ export default function BrandingSettings() { 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 + router.refresh(); } catch (error) { console.error("Failed to update branding:", error); alert("Failed to update branding settings."); @@ -53,25 +53,6 @@ export default function BrandingSettings() { } }; - 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.
; @@ -99,9 +80,15 @@ export default function BrandingSettings() { {/* Logo Section */}
-
+
{org.logo_url ? ( - Logo + Logo ) : ( No logo uploaded )} @@ -109,8 +96,7 @@ export default function BrandingSettings() { { - // Adapt the response format from logo upload API to what FileUpload expects + customUploadFn={async (file) => { const res = await cmsApi.uploadOrganizationLogo(org.id, file); return { url: res.logo_url || "" }; }} @@ -125,9 +111,17 @@ export default function BrandingSettings() { {/* Favicon Section */}
-
+
{org.favicon_url ? ( - Favicon +
+ Favicon +
) : ( No favicon )} @@ -135,7 +129,7 @@ export default function BrandingSettings() { { + customUploadFn={async (file) => { const res = await cmsApi.uploadOrganizationFavicon(org.id, file); return { url: res.favicon_url || "" }; }} diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index 8310da0..a8abda2 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -142,6 +142,21 @@ export interface OrganizationSSOConfig { updated_at: string; } +export interface ProvisionPayload { + org_name: string; + org_domain?: string; + admin_email: string; + admin_password: string; + admin_full_name: string; +} + +export interface UserCreatePayload { + email: string; + password: string; + full_name: string; + role: string; +} + export interface AuthResponse { user: User; token: string; @@ -272,6 +287,7 @@ export const cmsApi = { getOrganization: (): Promise => apiFetch('/organization'), getOrganizations: (): Promise => apiFetch('/organizations'), createOrganization: (name: string, domain?: string): Promise => apiFetch('/organizations', { method: 'POST', body: JSON.stringify({ name, domain }) }), + provisionOrganization: (data: ProvisionPayload): Promise => apiFetch('/admin/provision', { method: 'POST', body: JSON.stringify(data) }), // Auth register: (payload: AuthPayload): Promise => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }), @@ -327,6 +343,7 @@ export const cmsApi = { // Users getAllUsers: (): Promise => apiFetch('/users'), + createUser: (data: UserCreatePayload): Promise => apiFetch('/users', { method: 'POST', body: JSON.stringify(data) }), updateUser: (id: string, payload: { role?: string, organization_id?: string, full_name?: string, avatar_url?: string, bio?: string, language?: string }): Promise => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), // Webhooks