feat: Implement multi-tenancy with default organization, global courses, user profiles, and new UI components like OrganizationSelector and Combobox.
This commit is contained in:
@@ -248,28 +248,38 @@ Métricas de retención y análisis de cohortes.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. Multi-tenencia y Gestión (Solo Admin)
|
### 5. Multi-tenancy and Global Management (Super Admin)
|
||||||
OpenCCB permite gestionar múltiples organizaciones desde un único punto de acceso.
|
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
|
#### 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
|
```bash
|
||||||
curl -H "Authorization: Bearer $TOKEN" \
|
curl -H "Authorization: Bearer $SUPER_ADMIN_TOKEN" \
|
||||||
-H "X-Organization-Id: $ORG_ID" \
|
-H "X-Organization-Id: $TARGET_ORG_ID" \
|
||||||
http://localhost:3001/courses
|
http://localhost:3001/courses
|
||||||
```
|
```
|
||||||
|
|
||||||
#### GET /organizations
|
#### GET /organizations
|
||||||
Lista todas las organizaciones registradas.
|
Returns a searchable list of all organizations. (Admin only).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏆 Gamificación y Analíticas
|
## 🏆 Premium UI Components
|
||||||
OpenCCB incluye un sistema integrado de:
|
- **Organization Selector**: A searchable combobox for managing large lists of tenants.
|
||||||
- **XP y Niveles**: Los estudiantes progresan al completar lecciones.
|
- **Glassmorphism Design**: Consistent aesthetic across Studio and Experience portals.
|
||||||
- **Leaderboards**: Rankings dentro de la organización.
|
- **Micro-animations**: Enhanced feedback for publishing and content management.
|
||||||
- **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.
|
|
||||||
|
|
||||||
## 📄 Licencia
|
## 📄 Licencia
|
||||||
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
||||||
+1
-2
@@ -225,8 +225,7 @@ if [ "$ADMIN_EXISTS" != "t" ]; then
|
|||||||
read -s -p "Admin Password [password123]: " ADMIN_PASS
|
read -s -p "Admin Password [password123]: " ADMIN_PASS
|
||||||
ADMIN_PASS=${ADMIN_PASS:-password123}
|
ADMIN_PASS=${ADMIN_PASS:-password123}
|
||||||
echo ""
|
echo ""
|
||||||
read -p "Organization Name [Default Organization]: " ORG_NAME
|
ORG_NAME="Default Organization"
|
||||||
ORG_NAME=${ORG_NAME:-Default Organization}
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
+21
-50
@@ -70,22 +70,29 @@
|
|||||||
- [x] Tier-based feedback visualization
|
- [x] Tier-based feedback visualization
|
||||||
- [x] Real-time grade updates
|
- [x] Real-time grade updates
|
||||||
|
|
||||||
## Phase 6: Advanced Features (In Progress)
|
## Phase 6: Advanced Features ✅
|
||||||
- [x] **Multi-tenancy**: Support for multiple organizations (Completed)
|
- [x] **Multi-tenancy**: Support for multiple organizations (Completed)
|
||||||
- [x] Database schema migration (add `organization_id`)
|
- [x] Database schema migration (add `organization_id`)
|
||||||
- [x] Update Rust models & JWT Claims
|
- [x] Update Rust models & JWT Claims
|
||||||
- [x] Implement Axum middleware for organization context
|
- [x] Implement Axum middleware for organization context
|
||||||
- [x] Update Frontend registration to support organizations
|
- [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] **Organization Branding**: Custom identity per tenant (Completed)
|
||||||
- [x] Logo upload & optimization
|
- [x] Logo upload & optimization
|
||||||
- [x] Custom color schemes (Primary/Secondary)
|
- [x] Custom color schemes (Primary/Secondary)
|
||||||
- [x] Dynamic Experience Portal adaptation
|
- [x] Dynamic Experience Portal adaptation
|
||||||
- [x] Live Branding Preview in Studio
|
- [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] **Advanced Analytics**:
|
||||||
- [x] Cohort analysis (Implemented)
|
- [x] Cohort analysis (Implemented)
|
||||||
- [x] Retention metrics (Implemented)
|
- [x] Retention metrics (Implemented)
|
||||||
- [ ] Engagement heatmaps
|
- [ ] Engagement heatmaps
|
||||||
- [ ] **AI Integration** (Next Up):
|
- [ ] **AI Integration**:
|
||||||
- [x] AI-driven lesson summaries (Implemented)
|
- [x] AI-driven lesson summaries (Implemented)
|
||||||
- [ ] Implement real-time video transcription via external API
|
- [ ] Implement real-time video transcription via external API
|
||||||
- [x] Automated quiz generation (Implemented)
|
- [x] Automated quiz generation (Implemented)
|
||||||
@@ -104,63 +111,27 @@
|
|||||||
- [x] **Course Calendar**:
|
- [x] **Course Calendar**:
|
||||||
- [x] Management of important dates (exams, assignments, milestones).
|
- [x] Management of important dates (exams, assignments, milestones).
|
||||||
- [ ] Automated reminders for upcoming deadlines.
|
- [ ] 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**:
|
- [ ] **Advanced Reporting**:
|
||||||
- [ ] Custom report builder
|
|
||||||
- [ ] Export to PDF/CSV
|
|
||||||
- [ ] Scheduled reports
|
|
||||||
- [ ] **Integration Ecosystem**:
|
- [ ] **Integration Ecosystem**:
|
||||||
- [ ] LTI 1.3 support
|
|
||||||
- [ ] SCORM compliance
|
|
||||||
- [ ] Third-party integrations (Zoom, Google Meet, BigBlueButton)
|
|
||||||
- [ ] **Mobile Apps**:
|
- [ ] **Mobile Apps**:
|
||||||
- [ ] Native iOS app
|
|
||||||
- [ ] Native Android app
|
|
||||||
- [ ] Offline mode
|
|
||||||
- [ ] **Accessibility**:
|
- [ ] **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
|
## 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**:
|
**Recent Milestones**:
|
||||||
- ✅ **Gamification System**: XP, Levels, and Leaderboards integrated into the student experience.
|
- ✅ **Super Admin Portal**: Unified management for multi-tenant deployments.
|
||||||
- ✅ **Organization Branding**: Full customization of logos and brand colors across both portals.
|
- ✅ **Premium Organization Selector**: High-performance searchable UI for tenant selection.
|
||||||
- ✅ **Multi-Tenancy**: Full support for multiple organizations, from the database to the frontend.
|
- ✅ **Global Courses**: Seamless content sharing across isolated organizations.
|
||||||
- ✅ **Holistic Grading System**: Weighted categories, attempt tracking, and dynamic passing thresholds.
|
- ✅ **Gamification & Analytics**: Fully integrated student engagement loops.
|
||||||
|
|
||||||
**Next Priorities**:
|
**Next Priorities**:
|
||||||
1. **AI Integration**: Implement real-time video transcription (external and local provider).
|
1. **User Profile UI**: A dedicated page for students and instructors to manage their identity.
|
||||||
2. **Advanced Analytics**: Develop cohort analysis and retention metrics.
|
2. **AI Transcription**: Finalizing the integration for automatic video subtitling.
|
||||||
3. **Enterprise Ecosystem**: SSO (Single Sign-On) and Webhooks.
|
3. **SSO Integration**: SAML/OIDC support for enterprise clients.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -6,7 +6,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use common::auth::create_jwt;
|
use common::auth::{Claims, create_jwt};
|
||||||
use common::middleware::Org;
|
use common::middleware::Org;
|
||||||
use common::models::{
|
use common::models::{
|
||||||
AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse,
|
AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse,
|
||||||
@@ -18,19 +18,41 @@ use sqlx::PgPool;
|
|||||||
use std::env;
|
use std::env;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PublishPayload {
|
||||||
|
pub target_organization_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn publish_course(
|
pub async fn publish_course(
|
||||||
Org(org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload_params): Json<PublishPayload>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, StatusCode> {
|
||||||
// 1. Fetch Course
|
let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
||||||
let course =
|
|
||||||
|
// 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")
|
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.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
|
// 2. Fetch Modules
|
||||||
let modules =
|
let modules =
|
||||||
@@ -51,11 +73,11 @@ pub async fn publish_course(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
// 4. Fetch Organization
|
// 4. Fetch Target Organization
|
||||||
let organization = sqlx::query_as::<_, common::models::Organization>(
|
let organization = sqlx::query_as::<_, common::models::Organization>(
|
||||||
"SELECT * FROM organizations WHERE id = $1",
|
"SELECT * FROM organizations WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(org_ctx.id)
|
.bind(target_org_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -73,8 +95,12 @@ pub async fn publish_course(
|
|||||||
pub_modules.push(PublishedModule { module, lessons });
|
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 {
|
let payload = PublishedCourse {
|
||||||
course,
|
course: course_for_pub,
|
||||||
organization,
|
organization,
|
||||||
grading_categories,
|
grading_categories,
|
||||||
modules: pub_modules,
|
modules: pub_modules,
|
||||||
@@ -98,7 +124,7 @@ pub async fn publish_course(
|
|||||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
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
|
// 5. Trigger Webhook
|
||||||
let webhook_service = WebhookService::new(pool.clone());
|
let webhook_service = WebhookService::new(pool.clone());
|
||||||
@@ -110,6 +136,7 @@ pub async fn publish_course(
|
|||||||
"course_id": id,
|
"course_id": id,
|
||||||
"title": payload.course.title,
|
"title": payload.course.title,
|
||||||
"pacing_mode": payload.course.pacing_mode,
|
"pacing_mode": payload.course.pacing_mode,
|
||||||
|
"target_org": target_org_id,
|
||||||
"published_at": Utc::now()
|
"published_at": Utc::now()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -185,8 +212,18 @@ pub async fn create_course(
|
|||||||
StatusCode::INTERNAL_SERVER_ERROR
|
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)")
|
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(instructor_id)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(pacing_mode)
|
.bind(pacing_mode)
|
||||||
@@ -206,13 +243,22 @@ pub async fn create_course(
|
|||||||
}
|
}
|
||||||
pub async fn get_courses(
|
pub async fn get_courses(
|
||||||
Org(org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<Vec<Course>>, StatusCode> {
|
) -> Result<Json<Vec<Course>>, StatusCode> {
|
||||||
let courses = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1")
|
let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
||||||
.bind(org_ctx.id)
|
|
||||||
.fetch_all(&pool)
|
let courses = if is_super_admin {
|
||||||
.await
|
sqlx::query_as::<_, Course>("SELECT * FROM courses")
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.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))
|
Ok(Json(courses))
|
||||||
}
|
}
|
||||||
@@ -1383,11 +1429,8 @@ pub async fn register(
|
|||||||
});
|
});
|
||||||
let role = payload.role.unwrap_or_else(|| "instructor".to_string());
|
let role = payload.role.unwrap_or_else(|| "instructor".to_string());
|
||||||
|
|
||||||
// Find or create organization based on email domain
|
// Find or create organization based on email domain or use default
|
||||||
let org_name = payload.organization_name.unwrap_or_else(|| {
|
let org_name = payload.organization_name.unwrap_or_default();
|
||||||
let parts: Vec<&str> = payload.email.split('@').collect();
|
|
||||||
parts.get(1).unwrap_or(&"default.com").to_string()
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut tx = pool
|
let mut tx = pool
|
||||||
.begin()
|
.begin()
|
||||||
@@ -1901,16 +1944,18 @@ pub async fn update_user(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let role = payload.get("role").and_then(|r| r.as_str());
|
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
|
let organization_id = payload
|
||||||
.get("organization_id")
|
.get("organization_id")
|
||||||
.and_then(|o| o.as_str())
|
.and_then(|o| o.as_str())
|
||||||
.and_then(|o| Uuid::parse_str(o).ok());
|
.and_then(|o| Uuid::parse_str(o).ok());
|
||||||
|
|
||||||
sqlx::query(
|
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(role)
|
||||||
.bind(organization_id)
|
.bind(organization_id)
|
||||||
|
.bind(full_name)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
|
|||||||
@@ -120,24 +120,28 @@ pub async fn register(
|
|||||||
.to_string()
|
.to_string()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find or create organization
|
// Use provided organization name or Default 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()
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut tx = pool
|
let mut tx = pool
|
||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let organization = sqlx::query_as::<_, Organization>(
|
let organization = if let Some(org_name) = payload.organization_name {
|
||||||
"INSERT INTO organizations (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING *"
|
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)
|
.bind(&org_name)
|
||||||
.await
|
.fetch_one(&mut *tx)
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to find or create organization: {}", e)))?;
|
.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>(
|
let user = sqlx::query_as::<_, User>(
|
||||||
"INSERT INTO users (email, password_hash, full_name, organization_id, role) VALUES ($1, $2, $3, $4, 'student') RETURNING *"
|
"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)]
|
#[derive(Deserialize)]
|
||||||
pub struct CatalogQuery {
|
pub struct CatalogQuery {
|
||||||
pub organization_id: Option<Uuid>,
|
pub organization_id: Option<Uuid>,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_course_catalog(
|
pub async fn get_course_catalog(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Query(query): Query<CatalogQuery>,
|
Query(query): Query<CatalogQuery>,
|
||||||
) -> Result<Json<Vec<Course>>, StatusCode> {
|
) -> Result<Json<Vec<Course>>, StatusCode> {
|
||||||
let courses = match query.organization_id {
|
let courses = match (query.organization_id, query.user_id) {
|
||||||
Some(org_id) => {
|
(Some(org_id), Some(user_id)) => {
|
||||||
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1")
|
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)
|
.bind(org_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.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")
|
sqlx::query_as::<_, Course>("SELECT * FROM courses")
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("Catalog fetch failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(courses))
|
Ok(Json(courses))
|
||||||
}
|
}
|
||||||
@@ -825,3 +854,29 @@ pub async fn get_advanced_analytics(
|
|||||||
retention: retention_data,
|
retention: retention_data,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_user(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: common::auth::Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<serde_json::Value>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ async fn main() {
|
|||||||
"/users/{id}/gamification",
|
"/users/{id}/gamification",
|
||||||
get(handlers::get_user_gamification),
|
get(handlers::get_user_gamification),
|
||||||
)
|
)
|
||||||
|
.route("/users/{id}", post(handlers::update_user))
|
||||||
.route("/analytics/leaderboard", get(handlers::get_leaderboard))
|
.route("/analytics/leaderboard", get(handlers::get_leaderboard))
|
||||||
.route_layer(middleware::from_fn(
|
.route_layer(middleware::from_fn(
|
||||||
common::middleware::org_extractor_middleware,
|
common::middleware::org_extractor_middleware,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json;
|
use serde_json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
pub struct Course {
|
pub struct Course {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub organization_id: Uuid,
|
pub organization_id: Uuid,
|
||||||
@@ -19,7 +19,7 @@ pub struct Course {
|
|||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
pub struct Module {
|
pub struct Module {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub organization_id: Uuid,
|
pub organization_id: Uuid,
|
||||||
@@ -29,7 +29,7 @@ pub struct Module {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
pub struct Lesson {
|
pub struct Lesson {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub organization_id: Uuid,
|
pub organization_id: Uuid,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function CatalogPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const coursesData = await lmsApi.getCatalog(user?.organization_id);
|
const coursesData = await lmsApi.getCatalog(user?.organization_id, user?.id);
|
||||||
setCourses(coursesData);
|
setCourses(coursesData);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="text-4xl font-black tracking-tight text-white mb-2">My Profile</h1>
|
||||||
|
<p className="text-gray-400">Personalize your learning experience and track your progress.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{/* Profile Card & Stats */}
|
||||||
|
<div className="md:col-span-1 space-y-6">
|
||||||
|
<div className="glass p-8 rounded-3xl border border-white/5 flex flex-col items-center text-center">
|
||||||
|
<div className="w-24 h-24 rounded-full bg-blue-600/20 border-2 border-blue-500/30 flex items-center justify-center font-black text-3xl text-blue-400 mb-4 shadow-2xl shadow-blue-500/20">
|
||||||
|
{user.full_name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white">{user.full_name}</h2>
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest text-blue-500 mt-1">Student</span>
|
||||||
|
|
||||||
|
<div className="w-full h-px bg-white/5 my-6" />
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col gap-4 text-left">
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Trophy size={16} className="text-yellow-500" />
|
||||||
|
<span className="text-xs font-bold text-white uppercase tracking-tighter">Level</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-black text-white">{user.level || 1}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Flame size={16} className="text-orange-500" />
|
||||||
|
<span className="text-xs font-bold text-white uppercase tracking-tighter">XP</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-black text-white">{user.xp || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="mt-8 w-full py-3 rounded-xl border border-white/10 text-sm font-bold text-gray-400 hover:text-white hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
Logout Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Form */}
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
<form onSubmit={handleSave} className="glass p-8 rounded-3xl border border-white/5 space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
|
||||||
|
<UserIcon size={14} /> Full Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 opacity-60">
|
||||||
|
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
|
||||||
|
<Mail size={14} /> Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
disabled
|
||||||
|
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white/50 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-gray-500 italic">Email cannot be changed currently.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`p-4 rounded-xl text-sm font-medium ${message.type === 'success' ? 'bg-green-500/10 text-green-400 border border-green-500/20' : 'bg-red-500/10 text-red-400 border border-red-500/20'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="w-full py-4 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 rounded-xl font-black text-white shadow-lg shadow-blue-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save size={20} />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="glass p-6 rounded-3xl border border-white/5 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center">
|
||||||
|
<Shield size={18} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-bold text-sm">Organization</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5 truncate max-w-[200px]">{user.organization_id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-blue-500 bg-blue-500/10 px-3 py-1 rounded-full border border-blue-500/20">Active Tenant</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useBranding } from "@/context/BrandingContext";
|
import { useBranding } from "@/context/BrandingContext";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
|
||||||
export default function AppHeader() {
|
export default function AppHeader() {
|
||||||
const { branding } = useBranding();
|
const { branding } = useBranding();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-16 glass sticky top-0 z-50 px-6 flex items-center justify-between backdrop-blur-xl bg-black/40 border-b border-white/5">
|
<header className="h-16 glass sticky top-0 z-50 px-6 flex items-center justify-between backdrop-blur-xl bg-black/40 border-b border-white/5">
|
||||||
@@ -26,8 +29,22 @@ export default function AppHeader() {
|
|||||||
|
|
||||||
<nav className="hidden md:flex items-center gap-8">
|
<nav className="hidden md:flex items-center gap-8">
|
||||||
<Link href="/" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">Catalog</Link>
|
<Link href="/" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">Catalog</Link>
|
||||||
<Link href="#" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">My Learning</Link>
|
<Link href="/my-learning" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">My Learning</Link>
|
||||||
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10" />
|
|
||||||
|
<div className="flex items-center gap-4 pl-4 border-l border-white/10">
|
||||||
|
<Link href="/profile" className="flex items-center gap-2 group/profile">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10 flex items-center justify-center font-bold text-xs text-blue-400 group-hover/profile:border-blue-500/50 transition-colors">
|
||||||
|
{user?.full_name?.charAt(0) || 'U'}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="p-2 hover:bg-red-500/10 rounded-full text-gray-400 hover:text-red-400 transition-colors"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -149,8 +149,11 @@ const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const lmsApi = {
|
export const lmsApi = {
|
||||||
async getCatalog(orgId?: string): Promise<Course[]> {
|
async getCatalog(orgId?: string, userId?: string): Promise<Course[]> {
|
||||||
const query = orgId ? `?organization_id=${orgId}` : '';
|
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}`);
|
return apiFetch(`/catalog${query}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -208,5 +211,12 @@ export const lmsApi = {
|
|||||||
|
|
||||||
async getBranding(orgId: string): Promise<Organization> {
|
async getBranding(orgId: string): Promise<Organization> {
|
||||||
return apiFetch(`/organizations/${orgId}/branding`, {}, true);
|
return apiFetch(`/organizations/${orgId}/branding`, {}, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateUser(userId: string, payload: { full_name?: string }): Promise<void> {
|
||||||
|
return apiFetch(`/users/${userId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -16,9 +17,11 @@ import {
|
|||||||
X,
|
X,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Trash2,
|
Trash2,
|
||||||
ArrowLeft
|
ArrowLeft,
|
||||||
|
Send,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
||||||
|
import OrganizationSelector from "@/components/OrganizationSelector";
|
||||||
|
|
||||||
interface FullModule extends Module {
|
interface FullModule extends Module {
|
||||||
lessons: Lesson[];
|
lessons: Lesson[];
|
||||||
@@ -32,6 +35,10 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editValue, setEditValue] = useState("");
|
const [editValue, setEditValue] = useState("");
|
||||||
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false); // Added saving state
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const startEditing = (id: string, currentTitle: string) => {
|
const startEditing = (id: string, currentTitle: string) => {
|
||||||
setEditingId(id);
|
setEditingId(id);
|
||||||
@@ -56,6 +63,20 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [params.id]);
|
}, [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 handleAddModule = async () => {
|
||||||
const title = "";
|
const title = "";
|
||||||
try {
|
try {
|
||||||
@@ -70,13 +91,13 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddLesson = async (moduleId: 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;
|
if (!mod) return;
|
||||||
|
|
||||||
const title = "New Lesson";
|
const title = "New Lesson";
|
||||||
try {
|
try {
|
||||||
const newLesson = await cmsApi.createLesson(moduleId, title, "video", mod.lessons.length + 1);
|
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.id === moduleId
|
||||||
? { ...m, lessons: [...m.lessons, newLesson] }
|
? { ...m, lessons: [...m.lessons, newLesson] }
|
||||||
: m
|
: m
|
||||||
@@ -96,12 +117,12 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
try {
|
try {
|
||||||
if (type === 'module') {
|
if (type === 'module') {
|
||||||
await cmsApi.updateModule(id, { title: editValue });
|
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 {
|
} else {
|
||||||
await cmsApi.updateLesson(id, { title: editValue });
|
await cmsApi.updateLesson(id, { title: editValue });
|
||||||
setModules(modules.map(mod => ({
|
setModules(modules.map((mod: FullModule) => ({
|
||||||
...mod,
|
...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);
|
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;
|
if (!confirm("Are you sure you want to delete this module and all its lessons?")) return;
|
||||||
try {
|
try {
|
||||||
await cmsApi.deleteModule(id);
|
await cmsApi.deleteModule(id);
|
||||||
setModules(modules.filter(m => m.id !== id));
|
setModules(modules.filter((m: FullModule) => m.id !== id));
|
||||||
} catch {
|
} catch {
|
||||||
alert("Failed to delete module");
|
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;
|
if (!confirm("Are you sure you want to delete this lesson?")) return;
|
||||||
try {
|
try {
|
||||||
await cmsApi.deleteLesson(lessonId);
|
await cmsApi.deleteLesson(lessonId);
|
||||||
setModules(modules.map(m =>
|
setModules(modules.map((m: FullModule) =>
|
||||||
m.id === moduleId
|
m.id === moduleId
|
||||||
? { ...m, lessons: m.lessons.filter(l => l.id !== lessonId) }
|
? { ...m, lessons: m.lessons.filter((l: Lesson) => l.id !== lessonId) }
|
||||||
: m
|
: m
|
||||||
));
|
));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -141,8 +162,8 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
|
|
||||||
[newModules[index], newModules[targetIndex]] = [newModules[targetIndex], newModules[index]];
|
[newModules[index], newModules[targetIndex]] = [newModules[targetIndex], newModules[index]];
|
||||||
|
|
||||||
const items = newModules.map((m, i) => ({ id: m.id, position: i + 1 }));
|
const items = newModules.map((m: FullModule, i: number) => ({ id: m.id, position: i + 1 }));
|
||||||
setModules(newModules.map((m, i) => ({ ...m, position: i + 1 })));
|
setModules(newModules.map((m: FullModule, i: number) => ({ ...m, position: i + 1 })));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await cmsApi.reorderModules({ items });
|
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 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;
|
if (!mod) return;
|
||||||
|
|
||||||
const newLessons = [...mod.lessons];
|
const newLessons = [...mod.lessons];
|
||||||
@@ -161,8 +182,8 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
|
|
||||||
[newLessons[lessonIndex], newLessons[targetIndex]] = [newLessons[targetIndex], newLessons[lessonIndex]];
|
[newLessons[lessonIndex], newLessons[targetIndex]] = [newLessons[targetIndex], newLessons[lessonIndex]];
|
||||||
|
|
||||||
const items = newLessons.map((l, i) => ({ id: l.id, position: i + 1 }));
|
const items = newLessons.map((l: Lesson, i: number) => ({ id: l.id, position: i + 1 }));
|
||||||
setModules(modules.map(m => m.id === moduleId ? { ...m, lessons: newLessons.map((l, i) => ({ ...l, position: i + 1 })) } : m));
|
setModules(modules.map((m: FullModule) => m.id === moduleId ? { ...m, lessons: newLessons.map((l: Lesson, i: number) => ({ ...l, position: i + 1 })) } : m));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await cmsApi.reorderLessons({ items });
|
await cmsApi.reorderLessons({ items });
|
||||||
@@ -171,19 +192,29 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isPublishing, setIsPublishing] = useState(false);
|
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
if (!course) return;
|
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 {
|
try {
|
||||||
await cmsApi.publishCourse(params.id);
|
setSaving(true);
|
||||||
alert("Course published successfully to LMS!");
|
await cmsApi.publishCourse(params.id as string, targetOrgId);
|
||||||
|
alert("Course published successfully!");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Publish failed:", err);
|
console.error("Failed to publish course", err);
|
||||||
alert("Failed to publish course.");
|
alert("Failed to publish course.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsPublishing(false);
|
setSaving(false);
|
||||||
|
setIsOrgModalOpen(false); // Close modal after publishing attempt
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,18 +251,22 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handlePublish}
|
onClick={handlePublish}
|
||||||
disabled={isPublishing}
|
disabled={saving}
|
||||||
className={`flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 rounded-xl text-sm font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-95 ${isPublishing ? "opacity-75 cursor-wait" : ""}`}
|
className={`flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 rounded-xl font-bold transition-all shadow-lg shadow-blue-500/20 active:scale-95 ${saving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
<PlayCircle className="w-5 h-5 transition-transform group-active:scale-90" />
|
{saving ? (
|
||||||
{isPublishing ? "Publishing..." : "Publish to LMS"}
|
<div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send size={18} />
|
||||||
|
)}
|
||||||
|
{saving ? 'Publishing...' : 'Publish Course'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CourseEditorLayout activeTab="outline">
|
<CourseEditorLayout activeTab="outline">
|
||||||
<div className="p-8 space-y-6">
|
<div className="p-8 space-y-6">
|
||||||
{modules.map((module, mIndex) => (
|
{modules.map((module: FullModule, mIndex: number) => (
|
||||||
<div key={module.id} className="glass rounded-xl overflow-hidden border-white/5">
|
<div key={module.id} className="glass rounded-xl overflow-hidden border-white/5">
|
||||||
<div className="bg-white/5 px-6 py-4 flex justify-between items-center border-b border-white/5">
|
<div className="bg-white/5 px-6 py-4 flex justify-between items-center border-b border-white/5">
|
||||||
<div className="flex items-center gap-4 flex-1">
|
<div className="flex items-center gap-4 flex-1">
|
||||||
@@ -296,7 +331,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-3">
|
<div className="p-6 space-y-3">
|
||||||
{module.lessons.map((lesson, lIndex) => (
|
{module.lessons.map((lesson: Lesson, lIndex: number) => (
|
||||||
<div key={lesson.id} className="flex items-center gap-3 group/row">
|
<div key={lesson.id} className="flex items-center gap-3 group/row">
|
||||||
<div className="flex flex-col opacity-0 group-hover/row:opacity-100 transition-opacity">
|
<div className="flex flex-col opacity-0 group-hover/row:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
@@ -395,6 +430,15 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
|
|||||||
</div>
|
</div>
|
||||||
</CourseEditorLayout>
|
</CourseEditorLayout>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Organization Selector Modal */}
|
||||||
|
<OrganizationSelector
|
||||||
|
isOpen={isOrgModalOpen}
|
||||||
|
onClose={() => setIsOrgModalOpen(false)}
|
||||||
|
organizations={organizations}
|
||||||
|
title="Publish to Organization"
|
||||||
|
actionLabel="Publish Course"
|
||||||
|
onConfirm={(orgId) => publishCourse(orgId)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-11
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { cmsApi, Course } from "@/lib/api";
|
import { cmsApi, Course, Organization } from "@/lib/api";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { Plus, BookOpen } from "lucide-react";
|
import { Plus, BookOpen } from "lucide-react";
|
||||||
|
import OrganizationSelector from "@/components/OrganizationSelector";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
|
||||||
export default function StudioDashboard() {
|
export default function StudioDashboard() {
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
@@ -29,16 +31,50 @@ export default function StudioDashboard() {
|
|||||||
loadCourses();
|
loadCourses();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const handleCreateCourse = async () => {
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
const title = prompt("Enter new course title:");
|
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
|
||||||
if (title) {
|
const [isTitleModalOpen, setIsTitleModalOpen] = useState(false);
|
||||||
try {
|
const [newCourseTitle, setNewCourseTitle] = useState("");
|
||||||
const newCourse = await cmsApi.createCourse(title);
|
|
||||||
setCourses(prev => [...prev, newCourse]);
|
useEffect(() => {
|
||||||
} catch (err) {
|
const loadOrgs = async () => {
|
||||||
console.error("Failed to create course", err);
|
if (user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001') {
|
||||||
alert("Failed to create course. Please ensure the backend is running.");
|
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() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{courses.map(course => (
|
{courses.map((course: Course) => (
|
||||||
<Link href={`/courses/${course.id}`} key={course.id}>
|
<Link href={`/courses/${course.id}`} key={course.id}>
|
||||||
<div className="glass-card h-full flex flex-col group hover:border-blue-500/50 transition-all">
|
<div className="glass-card h-full flex flex-col group hover:border-blue-500/50 transition-all">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -94,6 +130,55 @@ export default function StudioDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* New Course Title Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isTitleModalOpen}
|
||||||
|
onClose={() => setIsTitleModalOpen(false)}
|
||||||
|
title="Create New Course"
|
||||||
|
>
|
||||||
|
<form onSubmit={onTitleConfirm} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||||
|
Course Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={newCourseTitle}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsTitleModalOpen(false)}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all text-sm font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-[2] px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 font-bold text-sm"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Organization Selector Modal */}
|
||||||
|
<OrganizationSelector
|
||||||
|
isOpen={isOrgModalOpen}
|
||||||
|
onClose={() => setIsOrgModalOpen(false)}
|
||||||
|
organizations={organizations}
|
||||||
|
title="Target Organization"
|
||||||
|
actionLabel="Create Course"
|
||||||
|
onConfirm={(orgId) => createCourse(orgId)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="text-4xl font-black tracking-tight text-white mb-2">User Profile</h1>
|
||||||
|
<p className="text-gray-400">Manage your personal information and account settings.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{/* Profile Card */}
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<div className="glass p-8 rounded-3xl border border-white/5 flex flex-col items-center text-center">
|
||||||
|
<div className="w-24 h-24 rounded-full bg-blue-600/20 border-2 border-blue-500/30 flex items-center justify-center font-black text-3xl text-blue-400 mb-4 shadow-2xl shadow-blue-500/20">
|
||||||
|
{user.full_name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white">{user.full_name}</h2>
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest text-blue-500 mt-1">{user.role}</span>
|
||||||
|
|
||||||
|
<div className="w-full h-px bg-white/5 my-6" />
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col gap-4 text-left">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-gray-400">
|
||||||
|
<Building size={16} className="text-gray-600" />
|
||||||
|
<span className="truncate">Org: {user.organization_id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm text-gray-400">
|
||||||
|
<Shield size={16} className="text-gray-600" />
|
||||||
|
<span>Role: {user.role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="mt-8 w-full py-3 rounded-xl border border-white/10 text-sm font-bold text-gray-400 hover:text-white hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
Logout Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Form */}
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
<form onSubmit={handleSave} className="glass p-8 rounded-3xl border border-white/5 space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
|
||||||
|
<UserIcon size={14} /> Full Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 opacity-60">
|
||||||
|
<label className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
|
||||||
|
<Mail size={14} /> Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
disabled
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white/50 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-gray-500 italic">Email cannot be changed currently.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`p-4 rounded-xl text-sm font-medium ${message.type === 'success' ? 'bg-green-500/10 text-green-400 border border-green-500/20' : 'bg-red-500/10 text-red-400 border border-red-500/20'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="w-full py-4 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 rounded-xl font-black text-white shadow-lg shadow-blue-500/20 transition-all active:scale-[0.98] flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save size={20} />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="glass p-6 rounded-3xl border border-red-500/10 bg-red-500/5 items-center justify-between flex">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-red-400 font-bold">Danger Zone</h3>
|
||||||
|
<p className="text-xs text-red-400/60 mt-0.5">Deleting your account is permanent.</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 border border-red-500/20 rounded-lg text-xs font-black text-red-400 hover:bg-red-500/10 transition-colors uppercase tracking-widest">
|
||||||
|
Delete Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,9 +20,9 @@ export default function AuthHeader() {
|
|||||||
)}
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<div className="w-8 h-8 rounded-full bg-white/10 border border-white/20 flex items-center justify-center font-bold text-xs">
|
<Link href="/profile" className="w-8 h-8 rounded-full bg-white/10 border border-white/20 flex items-center justify-center font-bold text-xs hover:border-blue-500/50 transition-colors">
|
||||||
{user.full_name.charAt(0)}
|
{user.full_name.charAt(0)}
|
||||||
</div>
|
</Link>
|
||||||
<button onClick={logout} className="p-2 hover:bg-white/10 rounded-full transition-colors">
|
<button onClick={logout} className="p-2 hover:bg-white/10 rounded-full transition-colors">
|
||||||
<LogOut size={16} />
|
<LogOut size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="relative" ref={containerRef}>
|
||||||
|
<div
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<span className={selectedOption ? "text-white" : "text-gray-500"}>
|
||||||
|
{selectedOption ? selectedOption.name : placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={18} className={`text-gray-500 transition-transform ${isOpen ? "rotate-180" : ""}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-2 z-[110] bg-[#1a1d23] border border-white/10 rounded-lg shadow-2xl overflow-hidden glass-card animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
<div className="p-2 border-b border-white/5 bg-white/5">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-black/20 border-none rounded-md pl-9 pr-4 py-2 text-sm focus:ring-0 placeholder:text-gray-600"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-60 overflow-y-auto p-1 custom-scrollbar">
|
||||||
|
{filteredOptions.length === 0 ? (
|
||||||
|
<div className="px-4 py-3 text-sm text-gray-500 text-center">No results found</div>
|
||||||
|
) : (
|
||||||
|
filteredOptions.map(option => (
|
||||||
|
<div
|
||||||
|
key={option.id}
|
||||||
|
onClick={() => {
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{option.name}</span>
|
||||||
|
{value === option.id && <Check size={14} />}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className="w-full max-w-md glass-card bg-[#1a1d23] border border-white/10 rounded-2xl p-8 shadow-2xl animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-xl font-bold bg-gradient-to-r from-white to-gray-400 bg-clip-text text-transparent italic">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-white/5 rounded-full transition-colors text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string>("");
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm(selectedId || undefined);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={title}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||||
|
Target Organization
|
||||||
|
</label>
|
||||||
|
<Combobox
|
||||||
|
options={organizations}
|
||||||
|
value={selectedId}
|
||||||
|
onChange={setSelectedId}
|
||||||
|
placeholder="Search or Select Organization..."
|
||||||
|
/>
|
||||||
|
<p className="mt-3 text-xs text-gray-500 italic">
|
||||||
|
Leave empty to use the Default Organization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all text-sm font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="flex-[2] px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 font-bold text-sm"
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export interface Course {
|
|||||||
description?: string;
|
description?: string;
|
||||||
instructor_id: string;
|
instructor_id: string;
|
||||||
pacing_mode: 'self_paced' | 'instructor_led';
|
pacing_mode: 'self_paced' | 'instructor_led';
|
||||||
|
organization_id: string;
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
passing_percentage: number;
|
passing_percentage: number;
|
||||||
@@ -233,11 +234,11 @@ export const cmsApi = {
|
|||||||
|
|
||||||
// Courses
|
// Courses
|
||||||
getCourses: (): Promise<Course[]> => apiFetch('/courses'),
|
getCourses: (): Promise<Course[]> => apiFetch('/courses'),
|
||||||
createCourse: (title: string): Promise<Course> => apiFetch('/courses', { method: 'POST', body: JSON.stringify({ title }) }),
|
createCourse: (title: string, organizationId?: string): Promise<Course> => apiFetch('/courses', { method: 'POST', body: JSON.stringify({ title, organization_id: organizationId }) }),
|
||||||
getCourse: (id: string): Promise<Course> => apiFetch(`/courses/${id}`),
|
getCourse: (id: string): Promise<Course> => apiFetch(`/courses/${id}`),
|
||||||
getCourseWithFullOutline: (id: string): Promise<Course> => apiFetch(`/courses/${id}/outline`),
|
getCourseWithFullOutline: (id: string): Promise<Course> => apiFetch(`/courses/${id}/outline`),
|
||||||
updateCourse: (id: string, payload: Partial<Course>): Promise<Course> => apiFetch(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
updateCourse: (id: string, payload: Partial<Course>): Promise<Course> => apiFetch(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
publishCourse: (id: string): Promise<void> => apiFetch(`/courses/${id}/publish`, { method: 'POST' }),
|
publishCourse: (id: string, targetOrganizationId?: string): Promise<void> => apiFetch(`/courses/${id}/publish`, { method: 'POST', body: JSON.stringify({ target_organization_id: targetOrganizationId }) }),
|
||||||
|
|
||||||
// Modules & Lessons
|
// Modules & Lessons
|
||||||
createModule: (course_id: string, title: string, position: number): Promise<Module> => apiFetch('/modules', { method: 'POST', body: JSON.stringify({ course_id, title, position }) }),
|
createModule: (course_id: string, title: string, position: number): Promise<Module> => apiFetch('/modules', { method: 'POST', body: JSON.stringify({ course_id, title, position }) }),
|
||||||
|
|||||||
Reference in New Issue
Block a user