From 2dffbd8b7121ea3d9b81b5a4c674ffa0704f03f5 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Fri, 16 Jan 2026 12:15:15 -0300 Subject: [PATCH] feat: Implement multi-tenancy with default organization, global courses, user profiles, and new UI components like OrganizationSelector and Combobox. --- README.md | 34 ++-- install.sh | 3 +- roadmap.md | 71 +++----- ...20260116000010_update_fn_register_user.sql | 30 ++++ services/cms-service/src/handlers.rs | 85 +++++++--- services/lms-service/src/handlers.rs | 91 ++++++++-- services/lms-service/src/main.rs | 1 + shared/common/src/models.rs | 6 +- web/experience/src/app/page.tsx | 2 +- web/experience/src/app/profile/page.tsx | 156 ++++++++++++++++++ web/experience/src/components/AppHeader.tsx | 21 ++- web/experience/src/lib/api.ts | 14 +- web/studio/src/app/courses/[id]/page.tsx | 100 +++++++---- web/studio/src/app/page.tsx | 107 ++++++++++-- web/studio/src/app/profile/page.tsx | 152 +++++++++++++++++ web/studio/src/components/AuthHeader.tsx | 4 +- web/studio/src/components/Combobox.tsx | 91 ++++++++++ web/studio/src/components/Modal.tsx | 55 ++++++ .../src/components/OrganizationSelector.tsx | 67 ++++++++ web/studio/src/lib/api.ts | 5 +- 20 files changed, 942 insertions(+), 153 deletions(-) create mode 100644 services/cms-service/migrations/20260116000010_update_fn_register_user.sql create mode 100644 web/experience/src/app/profile/page.tsx create mode 100644 web/studio/src/app/profile/page.tsx create mode 100644 web/studio/src/components/Combobox.tsx create mode 100644 web/studio/src/components/Modal.tsx create mode 100644 web/studio/src/components/OrganizationSelector.tsx diff --git a/README.md b/README.md index 3103884..c783211 100644 --- a/README.md +++ b/README.md @@ -248,28 +248,38 @@ Métricas de retención y análisis de cohortes. --- -### 5. Multi-tenencia y Gestión (Solo Admin) -OpenCCB permite gestionar múltiples organizaciones desde un único punto de acceso. +### 5. Multi-tenancy and Global Management (Super Admin) +OpenCCB is built for multi-tenancy. Organizations are isolated, but a **Super Admin** can manage everything. + +#### Super Admin Definition +- **Default Organization ID**: `00000000-0000-0000-0000-000000000001` +- Any user with `role: admin` in this organization is a **Super Admin**. + +#### Global Courses +Courses created by Super Admins in the **Default Organization** are automatically marked as **Global**. +- They appear in the catalog of **all organizations**. +- Users from any organization can enroll in global courses. + +#### Cross-Tenant Publishing +Super Admins can publish courses to **any organization**. When publishing through the Studio, a premium **Organization Selector** (with search-as-you-type) allows choosing the target destination. #### X-Organization-Id Header -Los administradores pueden simular el contexto de cualquier organización enviando este encabezado: +Super Admins can simulate the context of any organization by sending this header in their requests: ```bash -curl -H "Authorization: Bearer $TOKEN" \ - -H "X-Organization-Id: $ORG_ID" \ +curl -H "Authorization: Bearer $SUPER_ADMIN_TOKEN" \ + -H "X-Organization-Id: $TARGET_ORG_ID" \ http://localhost:3001/courses ``` #### GET /organizations -Lista todas las organizaciones registradas. +Returns a searchable list of all organizations. (Admin only). --- -## 🏆 Gamificación y Analíticas -OpenCCB incluye un sistema integrado de: -- **XP y Niveles**: Los estudiantes progresan al completar lecciones. -- **Leaderboards**: Rankings dentro de la organización. -- **Analíticas Avanzadas**: Análisis de cohortes y mapas de calor de retención para instructores. -- **Multi-tenencia Nativa**: Aislamiento total de datos entre organizaciones. +## 🏆 Premium UI Components +- **Organization Selector**: A searchable combobox for managing large lists of tenants. +- **Glassmorphism Design**: Consistent aesthetic across Studio and Experience portals. +- **Micro-animations**: Enhanced feedback for publishing and content management. ## 📄 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/install.sh b/install.sh index ff9fcb6..2881a4d 100755 --- a/install.sh +++ b/install.sh @@ -225,8 +225,7 @@ if [ "$ADMIN_EXISTS" != "t" ]; then read -s -p "Admin Password [password123]: " ADMIN_PASS ADMIN_PASS=${ADMIN_PASS:-password123} echo "" - read -p "Organization Name [Default Organization]: " ORG_NAME - ORG_NAME=${ORG_NAME:-Default Organization} + ORG_NAME="Default Organization" fi echo "" diff --git a/roadmap.md b/roadmap.md index 76c646b..0427b00 100644 --- a/roadmap.md +++ b/roadmap.md @@ -70,22 +70,29 @@ - [x] Tier-based feedback visualization - [x] Real-time grade updates -## Phase 6: Advanced Features (In Progress) +## Phase 6: Advanced Features ✅ - [x] **Multi-tenancy**: Support for multiple organizations (Completed) - [x] Database schema migration (add `organization_id`) - [x] Update Rust models & JWT Claims - [x] Implement Axum middleware for organization context - [x] Update Frontend registration to support organizations + - [x] **Super Admin & Default Org**: Global management of all tenants. + - [x] **Global Course Visibility**: System-wide courses available to all organizations. - [x] **Organization Branding**: Custom identity per tenant (Completed) - [x] Logo upload & optimization - [x] Custom color schemes (Primary/Secondary) - [x] Dynamic Experience Portal adaptation - [x] Live Branding Preview in Studio +- [x] **Advanced UI**: + - [x] **Premium Organization Selector**: For search-as-you-type multi-tenant management. + - [x] **Searchable Combobox**: Elegant glassmorphism filtering component. + +## Phase 7: User Engagement & Social (In Progress) - [x] **Advanced Analytics**: - [x] Cohort analysis (Implemented) - [x] Retention metrics (Implemented) - [ ] Engagement heatmaps -- [ ] **AI Integration** (Next Up): +- [ ] **AI Integration**: - [x] AI-driven lesson summaries (Implemented) - [ ] Implement real-time video transcription via external API - [x] Automated quiz generation (Implemented) @@ -104,63 +111,27 @@ - [x] **Course Calendar**: - [x] Management of important dates (exams, assignments, milestones). - [ ] Automated reminders for upcoming deadlines. -- [ ] **Content Library**: - - [ ] Reusable content blocks - - [ ] Template courses - - [ ] Shared resource pool -## Phase 7: Enterprise Features (Future) +## Phase 8: Enterprise Features (Future) +- [x] **User Profiles & Lifecycle**: + - [x] **Integrated Logout**: Standardized session management in both portals. + - [ ] **Profile Management**: Self-service user info updates. - [ ] **Advanced Reporting**: - - [ ] Custom report builder - - [ ] Export to PDF/CSV - - [ ] Scheduled reports - [ ] **Integration Ecosystem**: - - [ ] LTI 1.3 support - - [ ] SCORM compliance - - [ ] Third-party integrations (Zoom, Google Meet, BigBlueButton) - [ ] **Mobile Apps**: - - [ ] Native iOS app - - [ ] Native Android app - - [ ] Offline mode - [ ] **Accessibility**: - - [ ] WCAG 2.1 AA compliance - - [ ] Screen reader optimization - - [ ] Keyboard navigation - -## Phase 8: Future Innovations (Next Gen) -- [ ] **AI & Adaptive Learning**: - - [ ] **AI Tutor**: Real-time context-aware assistant for students. - - [ ] **Auto-grading**: LLM-based evaluation for short answers and essays. - - [ ] **Adaptive Paths**: Dynamic content unlocking based on performance. -- [ ] **Monetization & Marketplace**: - - [ ] **Multi-tenant Payments**: Integrated Stripe/Mercado Pago per organization. - - [ ] **Subscriptions**: Monthly/Yearly membership support. - - [ ] **Promotions**: Coupons, scholarships, and referral systems. -- [ ] **Social & Collaborative**: - - [ ] **Peer Review**: Structured student-to-student evaluation flows. - - [ ] **Co-working Spaces**: Real-time shared whiteboards and documents. - - [ ] **AI-Threaded Forums**: Automatic discussion summaries and sentiment analysis. -- [ ] **Enterprise Ecosystem**: - - [ ] **SSO (Single Sign-On)**: Azure AD, Okta, and Google Workspace integration. - - [ ] **HRIS Integration**: Sync with Workday, SAP, and other HR tools. - - [ ] **Webhooks & API**: Extensibility for third-party automation. -- [ ] **Deep Analytics**: - - [ ] **Dropout Prediction**: ML models to detect students at risk. - - [ ] **Engagement Heatmaps**: Detailed video and interaction tracking. -- [ ] **Offline-First Experience**: - - [ ] **Mobile Offline Mode**: Encrypted downloads for learning on the go. ## Current Status -**Platform Maturity**: Core functionality is production-ready. Advanced features like AI integration are under active development. +**Platform Maturity**: Core multi-tenant architecture is stable and performance-optimized. **Recent Milestones**: -- ✅ **Gamification System**: XP, Levels, and Leaderboards integrated into the student experience. -- ✅ **Organization Branding**: Full customization of logos and brand colors across both portals. -- ✅ **Multi-Tenancy**: Full support for multiple organizations, from the database to the frontend. -- ✅ **Holistic Grading System**: Weighted categories, attempt tracking, and dynamic passing thresholds. +- ✅ **Super Admin Portal**: Unified management for multi-tenant deployments. +- ✅ **Premium Organization Selector**: High-performance searchable UI for tenant selection. +- ✅ **Global Courses**: Seamless content sharing across isolated organizations. +- ✅ **Gamification & Analytics**: Fully integrated student engagement loops. **Next Priorities**: -1. **AI Integration**: Implement real-time video transcription (external and local provider). -2. **Advanced Analytics**: Develop cohort analysis and retention metrics. -3. **Enterprise Ecosystem**: SSO (Single Sign-On) and Webhooks. +1. **User Profile UI**: A dedicated page for students and instructors to manage their identity. +2. **AI Transcription**: Finalizing the integration for automatic video subtitling. +3. **SSO Integration**: SAML/OIDC support for enterprise clients. diff --git a/services/cms-service/migrations/20260116000010_update_fn_register_user.sql b/services/cms-service/migrations/20260116000010_update_fn_register_user.sql new file mode 100644 index 0000000..c9759e4 --- /dev/null +++ b/services/cms-service/migrations/20260116000010_update_fn_register_user.sql @@ -0,0 +1,30 @@ +-- Migration: Update fn_register_user to handle default organization +-- Assigns users to the 'Default Organization' (0...1) if no name is provided. + +CREATE OR REPLACE FUNCTION fn_register_user( + p_email VARCHAR(255), + p_password_hash VARCHAR(255), + p_full_name VARCHAR(255), + p_role VARCHAR(50), + p_org_name VARCHAR(255) DEFAULT NULL +) RETURNS SETOF users AS $$ +DECLARE + v_org_id UUID; +BEGIN + -- Find or create organization + IF p_org_name IS NULL OR p_org_name = '' OR p_org_name = 'Default Organization' THEN + v_org_id := '00000000-0000-0000-0000-000000000001'; + ELSE + INSERT INTO organizations (name) + VALUES (p_org_name) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id INTO v_org_id; + END IF; + + -- Create user + RETURN QUERY + INSERT INTO users (email, password_hash, full_name, role, organization_id) + VALUES (p_email, p_password_hash, p_full_name, p_role, v_org_id) + RETURNING *; +END; +$$ LANGUAGE plpgsql; diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 46b467a..cca5e06 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -6,7 +6,7 @@ use axum::{ }; use bcrypt::{DEFAULT_COST, hash, verify}; use chrono::{DateTime, Utc}; -use common::auth::create_jwt; +use common::auth::{Claims, create_jwt}; use common::middleware::Org; use common::models::{ AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse, @@ -18,19 +18,41 @@ use sqlx::PgPool; use std::env; use uuid::Uuid; +#[derive(Deserialize)] +pub struct PublishPayload { + pub target_organization_id: Option, +} + pub async fn publish_course( Org(org_ctx): Org, + claims: Claims, State(pool): State, Path(id): Path, + Json(payload_params): Json, ) -> Result { - // 1. Fetch Course - let course = + let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + + // 1. Fetch Course (Super admin can publish any course, others only their org's) + let course = if is_super_admin { + sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") + .bind(id) + .fetch_one(&pool) + .await + } else { sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") .bind(id) .bind(org_ctx.id) .fetch_one(&pool) .await - .map_err(|_| StatusCode::NOT_FOUND)?; + } + .map_err(|_| StatusCode::NOT_FOUND)?; + + // Determine target organization + let target_org_id = if is_super_admin && payload_params.target_organization_id.is_some() { + payload_params.target_organization_id.unwrap() + } else { + course.organization_id + }; // 2. Fetch Modules let modules = @@ -51,11 +73,11 @@ pub async fn publish_course( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // 4. Fetch Organization + // 4. Fetch Target Organization let organization = sqlx::query_as::<_, common::models::Organization>( "SELECT * FROM organizations WHERE id = $1", ) - .bind(org_ctx.id) + .bind(target_org_id) .fetch_one(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -73,8 +95,12 @@ pub async fn publish_course( pub_modules.push(PublishedModule { module, lessons }); } + // Overwrite the course's organization_id in the payload if publishing to a different org + let mut course_for_pub = course.clone(); + course_for_pub.organization_id = target_org_id; + let payload = PublishedCourse { - course, + course: course_for_pub, organization, grading_categories, modules: pub_modules, @@ -98,7 +124,7 @@ pub async fn publish_course( return Err(StatusCode::INTERNAL_SERVER_ERROR); } - log_action(&pool, org_ctx.id, Uuid::new_v4(), "PUBLISH", "Course", id, json!({})).await; + log_action(&pool, org_ctx.id, Uuid::new_v4(), "PUBLISH", "Course", id, json!({ "target_org": target_org_id })).await; // 5. Trigger Webhook let webhook_service = WebhookService::new(pool.clone()); @@ -110,6 +136,7 @@ pub async fn publish_course( "course_id": id, "title": payload.course.title, "pacing_mode": payload.course.pacing_mode, + "target_org": target_org_id, "published_at": Utc::now() }), ) @@ -185,8 +212,18 @@ pub async fn create_course( StatusCode::INTERNAL_SERVER_ERROR })?; + let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + let target_org_id = if is_super_admin { + payload.get("organization_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or(org_ctx.id) + } else { + org_ctx.id + }; + let course = sqlx::query_as::<_, Course>("SELECT * FROM fn_create_course($1, $2, $3, $4)") - .bind(org_ctx.id) + .bind(target_org_id) .bind(instructor_id) .bind(title) .bind(pacing_mode) @@ -206,13 +243,22 @@ pub async fn create_course( } pub async fn get_courses( Org(org_ctx): Org, + claims: Claims, State(pool): State, ) -> Result>, StatusCode> { - let courses = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1") - .bind(org_ctx.id) - .fetch_all(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + + let courses = if is_super_admin { + sqlx::query_as::<_, Course>("SELECT * FROM courses") + .fetch_all(&pool) + .await + } else { + sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1") + .bind(org_ctx.id) + .fetch_all(&pool) + .await + } + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(courses)) } @@ -1383,11 +1429,8 @@ pub async fn register( }); let role = payload.role.unwrap_or_else(|| "instructor".to_string()); - // Find or create organization based on email domain - let org_name = payload.organization_name.unwrap_or_else(|| { - let parts: Vec<&str> = payload.email.split('@').collect(); - parts.get(1).unwrap_or(&"default.com").to_string() - }); + // Find or create organization based on email domain or use default + let org_name = payload.organization_name.unwrap_or_default(); let mut tx = pool .begin() @@ -1901,16 +1944,18 @@ pub async fn update_user( } let role = payload.get("role").and_then(|r| r.as_str()); + let full_name = payload.get("full_name").and_then(|f| f.as_str()); let organization_id = payload .get("organization_id") .and_then(|o| o.as_str()) .and_then(|o| Uuid::parse_str(o).ok()); sqlx::query( - "UPDATE users SET role = COALESCE($1, role), organization_id = COALESCE($2, organization_id) WHERE id = $3 AND organization_id = $4" + "UPDATE users SET role = COALESCE($1, role), organization_id = COALESCE($2, organization_id), full_name = COALESCE($3, full_name) WHERE id = $4 AND organization_id = $5" ) .bind(role) .bind(organization_id) + .bind(full_name) .bind(id) .bind(org_ctx.id) .execute(&pool) diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 37ad69e..f93105c 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -120,24 +120,28 @@ pub async fn register( .to_string() }); - // Find or create organization - let org_name = payload.organization_name.unwrap_or_else(|| { - let parts: Vec<&str> = payload.email.split('@').collect(); - parts.get(1).unwrap_or(&"default.com").to_string() - }); - + // Use provided organization name or Default Organization let mut tx = pool .begin() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let organization = sqlx::query_as::<_, Organization>( - "INSERT INTO organizations (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING *" - ) - .bind(&org_name) - .fetch_one(&mut *tx) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to find or create organization: {}", e)))?; + let organization = if let Some(org_name) = payload.organization_name { + sqlx::query_as::<_, Organization>( + "INSERT INTO organizations (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING *" + ) + .bind(&org_name) + .fetch_one(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to find or create organization: {}", e)))? + } else { + sqlx::query_as::<_, Organization>( + "SELECT * FROM organizations WHERE id = '00000000-0000-0000-0000-000000000001'" + ) + .fetch_one(&mut *tx) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Default organization not found".into()))? + }; let user = sqlx::query_as::<_, User>( "INSERT INTO users (email, password_hash, full_name, organization_id, role) VALUES ($1, $2, $3, $4, 'student') RETURNING *" @@ -218,26 +222,51 @@ pub async fn login( #[derive(Deserialize)] pub struct CatalogQuery { pub organization_id: Option, + pub user_id: Option, } pub async fn get_course_catalog( State(pool): State, Query(query): Query, ) -> Result>, StatusCode> { - let courses = match query.organization_id { - Some(org_id) => { - sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1") + let courses = match (query.organization_id, query.user_id) { + (Some(org_id), Some(user_id)) => { + sqlx::query_as::<_, Course>( + "SELECT DISTINCT c.* FROM courses c + LEFT JOIN enrollments e ON c.id = e.course_id AND e.user_id = $2 + WHERE c.organization_id = $1 OR c.organization_id = '00000000-0000-0000-0000-000000000001' OR e.id IS NOT NULL" + ) + .bind(org_id) + .bind(user_id) + .fetch_all(&pool) + .await + } + (Some(org_id), None) => { + sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1 OR organization_id = '00000000-0000-0000-0000-000000000001'") .bind(org_id) .fetch_all(&pool) .await } - None => { + (None, Some(user_id)) => { + sqlx::query_as::<_, Course>( + "SELECT DISTINCT c.* FROM courses c + JOIN enrollments e ON c.id = e.course_id + WHERE e.user_id = $1 OR c.organization_id = '00000000-0000-0000-0000-000000000001'" + ) + .bind(user_id) + .fetch_all(&pool) + .await + } + (None, None) => { sqlx::query_as::<_, Course>("SELECT * FROM courses") .fetch_all(&pool) .await } } - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!("Catalog fetch failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; Ok(Json(courses)) } @@ -825,3 +854,29 @@ pub async fn get_advanced_analytics( retention: retention_data, })) } + +pub async fn update_user( + Org(org_ctx): Org, + claims: common::auth::Claims, + State(pool): State, + Path(id): Path, + Json(payload): Json, +) -> Result { + if claims.sub != id { + return Err((StatusCode::FORBIDDEN, "Not authorized".into())); + } + + let full_name = payload.get("full_name").and_then(|f| f.as_str()); + + sqlx::query( + "UPDATE users SET full_name = COALESCE($1, full_name) WHERE id = $2 AND organization_id = $3" + ) + .bind(full_name) + .bind(id) + .bind(org_ctx.id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::OK) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 306bf7e..aaf2dac 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -56,6 +56,7 @@ async fn main() { "/users/{id}/gamification", get(handlers::get_user_gamification), ) + .route("/users/{id}", post(handlers::update_user)) .route("/analytics/leaderboard", get(handlers::get_leaderboard)) .route_layer(middleware::from_fn( common::middleware::org_extractor_middleware, diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 9bf2a9d..22d8c49 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use serde_json; use uuid::Uuid; -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct Course { pub id: Uuid, pub organization_id: Uuid, @@ -19,7 +19,7 @@ pub struct Course { pub updated_at: DateTime, } -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct Module { pub id: Uuid, pub organization_id: Uuid, @@ -29,7 +29,7 @@ pub struct Module { pub created_at: DateTime, } -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct Lesson { pub id: Uuid, pub organization_id: Uuid, diff --git a/web/experience/src/app/page.tsx b/web/experience/src/app/page.tsx index 4d98b5e..df6b3cd 100644 --- a/web/experience/src/app/page.tsx +++ b/web/experience/src/app/page.tsx @@ -21,7 +21,7 @@ export default function CatalogPage() { useEffect(() => { const fetchData = async () => { try { - const coursesData = await lmsApi.getCatalog(user?.organization_id); + const coursesData = await lmsApi.getCatalog(user?.organization_id, user?.id); setCourses(coursesData); if (user) { diff --git a/web/experience/src/app/profile/page.tsx b/web/experience/src/app/profile/page.tsx new file mode 100644 index 0000000..b859635 --- /dev/null +++ b/web/experience/src/app/profile/page.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useAuth } from "@/context/AuthContext"; +import { lmsApi } from "@/lib/api"; +import { User, Save, Shield, Mail, User as UserIcon, Building, Trophy, Flame } from "lucide-react"; + +export default function ProfilePage() { + const { user, logout } = useAuth(); + const [fullName, setFullName] = useState(user?.full_name || ""); + const [email, setEmail] = useState(user?.email || ""); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); + + useEffect(() => { + if (user) { + setFullName(user.full_name); + setEmail(user.email); + } + }, [user]); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + if (!user) return; + + try { + setSaving(true); + setMessage(null); + + await lmsApi.updateUser(user.id, { + full_name: fullName + }); + + setMessage({ type: 'success', text: 'Profile updated successfully!' }); + } catch (err) { + console.error(err); + setMessage({ type: 'error', text: 'Failed to update profile.' }); + } finally { + setSaving(false); + } + }; + + if (!user) return null; + + return ( +
+
+

My Profile

+

Personalize your learning experience and track your progress.

+
+ +
+ {/* Profile Card & Stats */} +
+
+
+ {user.full_name.charAt(0)} +
+

{user.full_name}

+ Student + +
+ +
+
+
+ + Level +
+ {user.level || 1} +
+
+
+ + XP +
+ {user.xp || 0} +
+
+ + +
+
+ + {/* Settings Form */} +
+
+
+ + setFullName(e.target.value)} + className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-blue-500/50 transition-colors placeholder:text-gray-700" + placeholder="Enter your full name" + required + /> +
+ +
+ + +

Email cannot be changed currently.

+
+ + {message && ( +
+ {message.text} +
+ )} + + +
+ +
+
+
+ +
+
+

Organization

+

{user.organization_id}

+
+
+ Active Tenant +
+
+
+
+ ); +} diff --git a/web/experience/src/components/AppHeader.tsx b/web/experience/src/components/AppHeader.tsx index d443cd0..82ad90b 100644 --- a/web/experience/src/components/AppHeader.tsx +++ b/web/experience/src/components/AppHeader.tsx @@ -2,9 +2,12 @@ import Link from "next/link"; import { useBranding } from "@/context/BrandingContext"; +import { useAuth } from "@/context/AuthContext"; +import { LogOut } from "lucide-react"; export default function AppHeader() { const { branding } = useBranding(); + const { user, logout } = useAuth(); return (
@@ -26,8 +29,22 @@ export default function AppHeader() {
); diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 3289744..28fef33 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -149,8 +149,11 @@ const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = }; export const lmsApi = { - async getCatalog(orgId?: string): Promise { - const query = orgId ? `?organization_id=${orgId}` : ''; + async getCatalog(orgId?: string, userId?: string): Promise { + const params = new URLSearchParams(); + if (orgId) params.append('organization_id', orgId); + if (userId) params.append('user_id', userId); + const query = params.toString() ? `?${params.toString()}` : ''; return apiFetch(`/catalog${query}`); }, @@ -208,5 +211,12 @@ export const lmsApi = { async getBranding(orgId: string): Promise { return apiFetch(`/organizations/${orgId}/branding`, {}, true); + }, + + async updateUser(userId: string, payload: { full_name?: string }): Promise { + return apiFetch(`/users/${userId}`, { + method: 'POST', + body: JSON.stringify(payload) + }); } }; diff --git a/web/studio/src/app/courses/[id]/page.tsx b/web/studio/src/app/courses/[id]/page.tsx index 92a7575..5616c5a 100644 --- a/web/studio/src/app/courses/[id]/page.tsx +++ b/web/studio/src/app/courses/[id]/page.tsx @@ -1,9 +1,10 @@ "use client"; import { useEffect, useState } from "react"; -import { cmsApi, Course, Module, Lesson } from "@/lib/api"; +import { cmsApi, Course, Module, Lesson, Organization } from "@/lib/api"; import { useRouter } from "next/navigation"; import Link from "next/link"; +import { useAuth } from "@/context/AuthContext"; import { Plus, Pencil, @@ -16,9 +17,11 @@ import { X, GripVertical, Trash2, - ArrowLeft + ArrowLeft, + Send, } from "lucide-react"; import CourseEditorLayout from "@/components/CourseEditorLayout"; +import OrganizationSelector from "@/components/OrganizationSelector"; interface FullModule extends Module { lessons: Lesson[]; @@ -32,6 +35,10 @@ export default function CourseEditor({ params }: { params: { id: string } }) { const [error, setError] = useState(null); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); + const [organizations, setOrganizations] = useState([]); + const [isOrgModalOpen, setIsOrgModalOpen] = useState(false); + const [saving, setSaving] = useState(false); // Added saving state + const { user } = useAuth(); const startEditing = (id: string, currentTitle: string) => { setEditingId(id); @@ -56,6 +63,20 @@ export default function CourseEditor({ params }: { params: { id: string } }) { loadData(); }, [params.id]); + useEffect(() => { + const loadOrgs = async () => { + if (user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001') { + try { + const orgs = await cmsApi.getOrganizations(); + setOrganizations(orgs); + } catch (err) { + console.error("Failed to load organizations", err); + } + } + }; + loadOrgs(); + }, [user]); + const handleAddModule = async () => { const title = ""; try { @@ -70,13 +91,13 @@ export default function CourseEditor({ params }: { params: { id: string } }) { }; const handleAddLesson = async (moduleId: string) => { - const mod = modules.find(m => m.id === moduleId); + const mod = modules.find((m: FullModule) => m.id === moduleId); if (!mod) return; const title = "New Lesson"; try { const newLesson = await cmsApi.createLesson(moduleId, title, "video", mod.lessons.length + 1); - setModules(modules.map(m => + setModules(modules.map((m: FullModule) => m.id === moduleId ? { ...m, lessons: [...m.lessons, newLesson] } : m @@ -96,12 +117,12 @@ export default function CourseEditor({ params }: { params: { id: string } }) { try { if (type === 'module') { await cmsApi.updateModule(id, { title: editValue }); - setModules(modules.map(m => m.id === id ? { ...m, title: editValue } : m)); + setModules(modules.map((m: FullModule) => m.id === id ? { ...m, title: editValue } : m)); } else { await cmsApi.updateLesson(id, { title: editValue }); - setModules(modules.map(mod => ({ + setModules(modules.map((mod: FullModule) => ({ ...mod, - lessons: mod.lessons.map(l => l.id === id ? { ...l, title: editValue } : l) + lessons: mod.lessons.map((l: Lesson) => l.id === id ? { ...l, title: editValue } : l) }))); } setEditingId(null); @@ -114,7 +135,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) { if (!confirm("Are you sure you want to delete this module and all its lessons?")) return; try { await cmsApi.deleteModule(id); - setModules(modules.filter(m => m.id !== id)); + setModules(modules.filter((m: FullModule) => m.id !== id)); } catch { alert("Failed to delete module"); } @@ -124,9 +145,9 @@ export default function CourseEditor({ params }: { params: { id: string } }) { if (!confirm("Are you sure you want to delete this lesson?")) return; try { await cmsApi.deleteLesson(lessonId); - setModules(modules.map(m => + setModules(modules.map((m: FullModule) => m.id === moduleId - ? { ...m, lessons: m.lessons.filter(l => l.id !== lessonId) } + ? { ...m, lessons: m.lessons.filter((l: Lesson) => l.id !== lessonId) } : m )); } catch { @@ -141,8 +162,8 @@ export default function CourseEditor({ params }: { params: { id: string } }) { [newModules[index], newModules[targetIndex]] = [newModules[targetIndex], newModules[index]]; - const items = newModules.map((m, i) => ({ id: m.id, position: i + 1 })); - setModules(newModules.map((m, i) => ({ ...m, position: i + 1 }))); + const items = newModules.map((m: FullModule, i: number) => ({ id: m.id, position: i + 1 })); + setModules(newModules.map((m: FullModule, i: number) => ({ ...m, position: i + 1 }))); try { await cmsApi.reorderModules({ items }); @@ -152,7 +173,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) { }; const handleReorderLesson = async (moduleId: string, lessonIndex: number, direction: 'up' | 'down') => { - const mod = modules.find(m => m.id === moduleId); + const mod = modules.find((m: FullModule) => m.id === moduleId); if (!mod) return; const newLessons = [...mod.lessons]; @@ -161,8 +182,8 @@ export default function CourseEditor({ params }: { params: { id: string } }) { [newLessons[lessonIndex], newLessons[targetIndex]] = [newLessons[targetIndex], newLessons[lessonIndex]]; - const items = newLessons.map((l, i) => ({ id: l.id, position: i + 1 })); - setModules(modules.map(m => m.id === moduleId ? { ...m, lessons: newLessons.map((l, i) => ({ ...l, position: i + 1 })) } : m)); + const items = newLessons.map((l: Lesson, i: number) => ({ id: l.id, position: i + 1 })); + setModules(modules.map((m: FullModule) => m.id === moduleId ? { ...m, lessons: newLessons.map((l: Lesson, i: number) => ({ ...l, position: i + 1 })) } : m)); try { await cmsApi.reorderLessons({ items }); @@ -171,19 +192,29 @@ export default function CourseEditor({ params }: { params: { id: string } }) { } }; - const [isPublishing, setIsPublishing] = useState(false); - const handlePublish = async () => { if (!course) return; - setIsPublishing(true); + + const isSuperAdmin = user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001'; + + if (isSuperAdmin && organizations.length > 0) { + setIsOrgModalOpen(true); + } else { + publishCourse(); + } + }; + + const publishCourse = async (targetOrgId?: string) => { try { - await cmsApi.publishCourse(params.id); - alert("Course published successfully to LMS!"); + setSaving(true); + await cmsApi.publishCourse(params.id as string, targetOrgId); + alert("Course published successfully!"); } catch (err) { - console.error("Publish failed:", err); + console.error("Failed to publish course", err); alert("Failed to publish course."); } finally { - setIsPublishing(false); + setSaving(false); + setIsOrgModalOpen(false); // Close modal after publishing attempt } }; @@ -220,18 +251,22 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
- {modules.map((module, mIndex) => ( + {modules.map((module: FullModule, mIndex: number) => (
@@ -296,7 +331,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
- {module.lessons.map((lesson, lIndex) => ( + {module.lessons.map((lesson: Lesson, lIndex: number) => (
+ {/* Organization Selector Modal */} + setIsOrgModalOpen(false)} + organizations={organizations} + title="Publish to Organization" + actionLabel="Publish Course" + onConfirm={(orgId) => publishCourse(orgId)} + />
); } diff --git a/web/studio/src/app/page.tsx b/web/studio/src/app/page.tsx index a89f5cf..2572fe0 100644 --- a/web/studio/src/app/page.tsx +++ b/web/studio/src/app/page.tsx @@ -1,10 +1,12 @@ "use client"; import { useEffect, useState } from "react"; -import { cmsApi, Course } from "@/lib/api"; +import { cmsApi, Course, Organization } from "@/lib/api"; import Link from "next/link"; import { useAuth } from "@/context/AuthContext"; import { Plus, BookOpen } from "lucide-react"; +import OrganizationSelector from "@/components/OrganizationSelector"; +import Modal from "@/components/Modal"; export default function StudioDashboard() { const [courses, setCourses] = useState([]); @@ -29,16 +31,50 @@ export default function StudioDashboard() { loadCourses(); }, [user]); - const handleCreateCourse = async () => { - const title = prompt("Enter new course title:"); - if (title) { - try { - const newCourse = await cmsApi.createCourse(title); - setCourses(prev => [...prev, newCourse]); - } catch (err) { - console.error("Failed to create course", err); - alert("Failed to create course. Please ensure the backend is running."); + const [organizations, setOrganizations] = useState([]); + const [isOrgModalOpen, setIsOrgModalOpen] = useState(false); + const [isTitleModalOpen, setIsTitleModalOpen] = useState(false); + const [newCourseTitle, setNewCourseTitle] = useState(""); + + useEffect(() => { + const loadOrgs = async () => { + if (user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001') { + try { + const orgs = await cmsApi.getOrganizations(); + setOrganizations(orgs); + } catch (err) { + console.error("Failed to load organizations", err); + } } + }; + loadOrgs(); + }, [user]); + + const handleCreateCourse = async () => { + setIsTitleModalOpen(true); + }; + + const onTitleConfirm = (e: React.FormEvent) => { + e.preventDefault(); + if (!newCourseTitle) return; + setIsTitleModalOpen(false); + + const isSuperAdmin = user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001'; + if (isSuperAdmin && organizations.length > 0) { + setIsOrgModalOpen(true); + } else { + createCourse(); + } + }; + + const createCourse = async (targetOrgId?: string) => { + try { + const newCourse = await cmsApi.createCourse(newCourseTitle, targetOrgId); + setCourses((prev: Course[]) => [...prev, newCourse]); + setNewCourseTitle(""); + } catch (err) { + console.error("Failed to create course", err); + alert("Failed to create course. Please ensure the backend is running."); } }; @@ -74,7 +110,7 @@ export default function StudioDashboard() {
) : (
- {courses.map(course => ( + {courses.map((course: Course) => (
@@ -94,6 +130,55 @@ export default function StudioDashboard() {
)}
+ + {/* New Course Title Modal */} + setIsTitleModalOpen(false)} + title="Create New Course" + > +
+
+ + setNewCourseTitle(e.target.value)} + placeholder="e.g. Advanced Rust Development" + 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 text-white" + /> +
+
+ + +
+
+
+ + {/* Organization Selector Modal */} + setIsOrgModalOpen(false)} + organizations={organizations} + title="Target Organization" + actionLabel="Create Course" + onConfirm={(orgId) => createCourse(orgId)} + />
); } \ No newline at end of file diff --git a/web/studio/src/app/profile/page.tsx b/web/studio/src/app/profile/page.tsx new file mode 100644 index 0000000..7d02742 --- /dev/null +++ b/web/studio/src/app/profile/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useAuth } from "@/context/AuthContext"; +import { cmsApi } from "@/lib/api"; +import { User, Save, Shield, Mail, User as UserIcon, Building } from "lucide-react"; + +export default function ProfilePage() { + const { user, token, logout } = useAuth(); + const [fullName, setFullName] = useState(user?.full_name || ""); + const [email, setEmail] = useState(user?.email || ""); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); + + useEffect(() => { + if (user) { + setFullName(user.full_name); + setEmail(user.email); + } + }, [user]); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + if (!user) return; + + try { + setSaving(true); + setMessage(null); + + await cmsApi.updateUser(user.id, { + full_name: fullName, + // In this simplified version, we don't allow email change here to avoid complexity + }); + + setMessage({ type: 'success', text: 'Profile updated successfully!' }); + + // Optionally update the local user state if needed, + // but usually a page refresh or context update would be better. + // For now, let's just show the message. + } catch (err) { + console.error(err); + setMessage({ type: 'error', text: 'Failed to update profile.' }); + } finally { + setSaving(false); + } + }; + + if (!user) return null; + + return ( +
+
+

User Profile

+

Manage your personal information and account settings.

+
+ +
+ {/* Profile Card */} +
+
+
+ {user.full_name.charAt(0)} +
+

{user.full_name}

+ {user.role} + +
+ +
+
+ + Org: {user.organization_id} +
+
+ + Role: {user.role} +
+
+ + +
+
+ + {/* Settings Form */} +
+
+
+ + setFullName(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-blue-500/50 transition-colors" + placeholder="Enter your full name" + required + /> +
+ +
+ + +

Email cannot be changed currently.

+
+ + {message && ( +
+ {message.text} +
+ )} + + +
+ +
+
+

Danger Zone

+

Deleting your account is permanent.

+
+ +
+
+
+
+ ); +} diff --git a/web/studio/src/components/AuthHeader.tsx b/web/studio/src/components/AuthHeader.tsx index 4d36b2e..b153cae 100644 --- a/web/studio/src/components/AuthHeader.tsx +++ b/web/studio/src/components/AuthHeader.tsx @@ -20,9 +20,9 @@ export default function AuthHeader() { )} {user && ( <> -
+ {user.full_name.charAt(0)} -
+ diff --git a/web/studio/src/components/Combobox.tsx b/web/studio/src/components/Combobox.tsx new file mode 100644 index 0000000..13261d5 --- /dev/null +++ b/web/studio/src/components/Combobox.tsx @@ -0,0 +1,91 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { Search, ChevronDown, Check } from "lucide-react"; + +interface Option { + id: string; + name: string; +} + +interface ComboboxProps { + options: Option[]; + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +export default function Combobox({ options, value, onChange, placeholder = "Search..." }: ComboboxProps) { + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(""); + const containerRef = useRef(null); + + const filteredOptions = options.filter(option => + option.name.toLowerCase().includes(search.toLowerCase()) + ); + + const selectedOption = options.find(o => o.id === value); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+
setIsOpen(!isOpen)} + className="flex items-center justify-between w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 cursor-pointer hover:border-white/20 transition-all focus-within:ring-2 focus-within:ring-blue-500/50" + > + + {selectedOption ? selectedOption.name : placeholder} + + +
+ + {isOpen && ( +
+
+
+ + setSearch(e.target.value)} + /> +
+
+
+ {filteredOptions.length === 0 ? ( +
No results found
+ ) : ( + filteredOptions.map(option => ( +
{ + onChange(option.id); + setIsOpen(false); + setSearch(""); + }} + className={`flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors ${value === option.id ? "bg-blue-600 text-white" : "hover:bg-white/5 text-gray-300" + }`} + > + {option.name} + {value === option.id && } +
+ )) + )} +
+
+ )} +
+ ); +} diff --git a/web/studio/src/components/Modal.tsx b/web/studio/src/components/Modal.tsx new file mode 100644 index 0000000..8379dfc --- /dev/null +++ b/web/studio/src/components/Modal.tsx @@ -0,0 +1,55 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import { X } from "lucide-react"; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; +} + +export default function Modal({ isOpen, onClose, title, children }: ModalProps) { + const modalRef = useRef(null); + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + + if (isOpen) { + document.body.style.overflow = "hidden"; + window.addEventListener("keydown", handleEscape); + } + + return () => { + document.body.style.overflow = "unset"; + window.removeEventListener("keydown", handleEscape); + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+
+
+

+ {title} +

+ +
+ {children} +
+
+ ); +} diff --git a/web/studio/src/components/OrganizationSelector.tsx b/web/studio/src/components/OrganizationSelector.tsx new file mode 100644 index 0000000..2442517 --- /dev/null +++ b/web/studio/src/components/OrganizationSelector.tsx @@ -0,0 +1,67 @@ +"use client"; + +import React, { useState } from "react"; +import Modal from "./Modal"; +import Combobox from "./Combobox"; +import { Organization } from "@/lib/api"; + +interface OrganizationSelectorProps { + isOpen: boolean; + onClose: () => void; + organizations: Organization[]; + onConfirm: (orgId: string | undefined) => void; + title: string; + actionLabel: string; +} + +export default function OrganizationSelector({ + isOpen, + onClose, + organizations, + onConfirm, + title, + actionLabel +}: OrganizationSelectorProps) { + const [selectedId, setSelectedId] = useState(""); + + const handleConfirm = () => { + onConfirm(selectedId || undefined); + onClose(); + }; + + return ( + +
+
+ + +

+ Leave empty to use the Default Organization. +

+
+ +
+ + +
+
+
+ ); +} diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index abff626..cbed0ad 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -16,6 +16,7 @@ export interface Course { description?: string; instructor_id: string; pacing_mode: 'self_paced' | 'instructor_led'; + organization_id: string; start_date?: string; end_date?: string; passing_percentage: number; @@ -233,11 +234,11 @@ export const cmsApi = { // Courses getCourses: (): Promise => apiFetch('/courses'), - createCourse: (title: string): Promise => apiFetch('/courses', { method: 'POST', body: JSON.stringify({ title }) }), + createCourse: (title: string, organizationId?: string): Promise => apiFetch('/courses', { method: 'POST', body: JSON.stringify({ title, organization_id: organizationId }) }), getCourse: (id: string): Promise => apiFetch(`/courses/${id}`), getCourseWithFullOutline: (id: string): Promise => apiFetch(`/courses/${id}/outline`), updateCourse: (id: string, payload: Partial): Promise => apiFetch(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), - publishCourse: (id: string): Promise => apiFetch(`/courses/${id}/publish`, { method: 'POST' }), + publishCourse: (id: string, targetOrganizationId?: string): Promise => apiFetch(`/courses/${id}/publish`, { method: 'POST', body: JSON.stringify({ target_organization_id: targetOrganizationId }) }), // Modules & Lessons createModule: (course_id: string, title: string, position: number): Promise => apiFetch('/modules', { method: 'POST', body: JSON.stringify({ course_id, title, position }) }),