feat: Implement multi-tenancy with new database migrations, API updates across services, and refactor frontend API calls.

This commit is contained in:
2025-12-26 10:59:07 -03:00
parent 8c440def23
commit e772d430b1
22 changed files with 819 additions and 745 deletions
+43 -21
View File
@@ -3,13 +3,16 @@
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { cmsApi } from "@/lib/api";
import { BookOpen, Lock, Mail, User } from "lucide-react";
import { useAuth } from "@/context/AuthContext";
import { BookOpen, Lock, Mail, User, Building2 } from "lucide-react";
export default function StudioLoginPage() {
const router = useRouter();
const { login } = useAuth();
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [fullName, setFullName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -30,19 +33,18 @@ export default function StudioLoginPage() {
return;
}
localStorage.setItem("studio_token", response.token);
localStorage.setItem("studio_user", JSON.stringify(response.user));
login(response.user, response.token);
router.push("/");
} else {
const response = await cmsApi.register({
email,
password,
full_name: fullName,
role: "instructor"
role: "instructor",
organization_name: organizationName,
});
localStorage.setItem("studio_token", response.token);
localStorage.setItem("studio_user", JSON.stringify(response.user));
login(response.user, response.token);
router.push("/");
}
} catch (err) {
@@ -85,22 +87,42 @@ export default function StudioLoginPage() {
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Full Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Doe"
required
/>
<>
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Full Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Doe"
required
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Organization Name (Optional)
</label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Your School or Company"
/>
</div>
<p className="text-xs text-gray-500 mt-2 pl-1">
If left blank, an organization will be created based on your email domain.
</p>
</div>
</>
)}
<div>
+18 -2
View File
@@ -5,12 +5,13 @@ import { cmsApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus, Mail, Lock, User } from "lucide-react";
import { UserPlus, Mail, Lock, User, Building2 } from "lucide-react";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [fullName, setFullName] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -22,7 +23,7 @@ export default function RegisterPage() {
setLoading(true);
setError("");
try {
const res = await cmsApi.register({ email, password, full_name: fullName });
const res = await cmsApi.register({ email, password, full_name: fullName, organization_name: organizationName });
login(res.user, res.token);
router.push("/");
} catch (err) {
@@ -97,6 +98,21 @@ export default function RegisterPage() {
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Organization Name (Optional)</label>
<div className="relative">
<Building2 className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="text"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder="Your School or Company"
className="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-12 pr-4 text-sm text-white focus:outline-none focus:border-blue-500 transition-all placeholder:text-gray-700"
/>
</div>
<p className="text-[10px] text-gray-600 px-1">If blank, we'll use your email domain.</p>
</div>
<button
disabled={loading}
type="submit"
+40 -23
View File
@@ -3,48 +3,65 @@
@tailwind utilities;
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 10, 10, 20;
--foreground-rgb: 229, 231, 235;
/* text-gray-200 */
--background-start-rgb: 15, 17, 21;
/* #0f1115 */
--background-end-rgb: 0, 0, 0;
--accent-primary: #3b82f6;
--accent-secondary: #8b5cf6;
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.1);
/* blue-500 */
--accent-secondary: #6366f1;
/* indigo-500 */
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: blur(16px);
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
min-height: 100vh;
background: var(--background-start-rgb);
}
.glass {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--glass-border);
border-radius: 12px;
}
.gradient-text {
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
.glass-card {
@apply glass rounded-2xl p-6;
}
.btn-premium {
@apply relative px-6 py-2 rounded-full font-medium transition-all duration-300;
@apply relative px-6 py-2.5 rounded-xl font-bold transition-all duration-300;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3);
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.2);
}
.btn-premium:hover {
@apply scale-105;
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5);
@apply scale-[1.02] -translate-y-0.5 shadow-blue-500/40;
}
.btn-premium:active {
@apply scale-95;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
+40 -20
View File
@@ -1,15 +1,41 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/context/AuthContext";
import Link from "next/link";
import { AuthProvider, useAuth } from "@/context/AuthContext";
import { BookOpen, LogOut, ShieldAlert } from "lucide-react";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "OpenCCB Studio | modern Course Management",
description: "Advanced LMS Content Management System inspired by Open edX",
title: "OpenCCB | Studio",
description: "Create and manage high-fidelity educational content.",
};
function AuthHeader() {
"use client";
const { user, logout } = useAuth();
return (
<div className="flex items-center gap-4">
{user?.role === 'admin' && (
<Link href="/admin/audit" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
<ShieldAlert size={16} /> Audit
</Link>
)}
{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">
{user.full_name.charAt(0)}
</div>
<button onClick={logout} className="p-2 hover:bg-white/10 rounded-full transition-colors">
<LogOut size={16} />
</button>
</>
)}
</div>
);
}
export default function RootLayout({
children,
}: Readonly<{
@@ -17,26 +43,20 @@ export default function RootLayout({
}>) {
return (
<html lang="en" className="dark">
<body className={`${inter.className} bg-[#050505] text-[#e5e5e5] min-h-screen`}>
<body className={`${inter.className} bg-gray-950 text-gray-200 min-h-screen flex flex-col`}>
<AuthProvider>
<div className="fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(59,130,246,0.15),transparent_50%)] pointer-events-none" />
<nav className="fixed top-0 w-full z-50 glass border-b border-white/10 bg-black/20">
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
<h1 className="text-xl font-bold tracking-tight">
Open<span className="gradient-text">CCB</span> Studio
</h1>
<div className="flex gap-4">
<button className="text-sm font-medium hover:text-blue-400 transition-colors">Courses</button>
<button className="text-sm font-medium hover:text-blue-400 transition-colors">Settings</button>
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 border border-white/20" />
<header className="h-20 glass sticky top-0 z-50 px-8 flex items-center justify-between border-b border-white/5 backdrop-blur-xl bg-black/40">
<Link href="/" className="flex items-center gap-3 group">
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-transform">
<BookOpen size={20} />
</div>
</div>
</nav>
<main className="pt-24 pb-12 px-4 max-w-7xl mx-auto">
{children}
</main>
<span className="font-black text-2xl tracking-tighter text-white">STUDIO</span>
</Link>
<AuthHeader />
</header>
<main className="flex-1">{children}</main>
</AuthProvider>
</body>
</html>
);
}
}
+54 -104
View File
@@ -1,137 +1,87 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { cmsApi, Course } from "@/lib/api";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
import { Plus, BookOpen } from "lucide-react";
export default function Home() {
const router = useRouter();
const { user } = useAuth();
export default function StudioDashboard() {
const [courses, setCourses] = useState<Course[]>([]);
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { user } = useAuth();
useEffect(() => {
setMounted(true);
// Authentication handled by AuthContext or manual check
// We keep this manual check as a legacy safe-guard or rely on AuthContext if we trust it fully.
// Given previous edits, we know AuthContext works.
if (typeof window !== 'undefined' && !localStorage.getItem("studio_user")) {
router.push("/auth/login");
return;
}
// Fetch courses
const loadCourses = async () => {
if (!user) {
setLoading(false);
return;
};
try {
setLoading(true);
const data = await cmsApi.getCourses();
setCourses(data);
setError(null);
} catch (err) {
console.error("Failed to fetch courses:", err);
setError("Could not connect to CMS service. showing offline mode.");
console.error("Failed to load courses", err);
} finally {
setLoading(false);
}
};
loadCourses();
}, [router]);
}, [user]);
const handleCreateCourse = async () => {
const title = prompt("Enter course title:");
if (!title) return;
try {
const newCourse = await cmsApi.createCourse(title);
setCourses([...courses, newCourse]);
} catch {
alert("Failed to create course. Is the backend running?");
const title = prompt("Enter new course title:");
if (title) {
try {
const newCourse = await cmsApi.createCourse(title);
setCourses(prev => [...prev, newCourse]);
} catch (err) {
alert("Failed to create course. Please ensure the backend is running.");
}
}
};
const placeholderCourses: Course[] = [
{
id: "p1",
title: "Introduction to Rust (Demo)",
description: "A demo course to get started",
instructor_id: "demo",
passing_percentage: 70,
certificate_template: undefined,
created_at: new Date().toISOString()
},
];
const displayCourses = courses.length > 0 ? courses : (loading ? [] : placeholderCourses);
return (
<div className="space-y-8">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold">My Courses</h2>
<p className="text-gray-400">Manage and create your learning content</p>
</div>
<div className="flex items-center gap-4">
{user?.role === 'admin' && (
<Link href="/admin/audit">
<button className="px-4 py-2 text-sm font-bold text-gray-400 hover:text-white border border-white/10 hover:border-white/30 rounded-lg transition-colors flex items-center gap-2">
🛡 Audit Logs
</button>
</Link>
)}
<button onClick={handleCreateCourse} className="btn-premium">
+ New Course
</button>
</div>
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">My Courses</h1>
<button onClick={handleCreateCourse} className="btn-premium flex items-center gap-2">
<Plus size={18} />
New Course
</button>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/50 p-4 rounded-lg text-red-400 text-sm">
{error}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map(i => (
<div key={i} className="h-64 glass-card animate-pulse bg-white/5 border-white/5"></div>
))}
</div>
) : courses.length === 0 ? (
<div className="text-center py-20 glass-card border-dashed border-white/10">
<p className="text-gray-500">You haven't created any courses yet.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map(course => (
<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="flex-1">
<div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4">
<BookOpen className="text-blue-400" />
</div>
<h3 className="font-bold text-lg mb-2 group-hover:text-blue-400 transition-colors">{course.title}</h3>
<p className="text-sm text-gray-400 line-clamp-2">{course.description || "No description provided."}</p>
</div>
<div className="flex items-center justify-between mt-6 pt-4 border-t border-white/5 text-xs text-gray-500">
<span>Last updated: {new Date(course.updated_at).toLocaleDateString()}</span>
<span>ID: {course.id.slice(0, 4)}...</span>
</div>
</div>
</Link>
))}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{loading ? (
<div className="col-span-full py-20 text-center text-gray-500">
<div className="animate-spin inline-block w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full mb-4"></div>
<p>Loading your courses...</p>
</div>
) : (
<>
{displayCourses.map((course) => (
<Link href={`/courses/${course.id}`} key={course.id}>
<div className="glass p-6 hover:border-blue-500/50 transition-all group cursor-pointer h-full">
<div className="h-32 bg-gradient-to-br from-blue-900/50 to-purple-900/50 rounded-lg mb-4 flex items-center justify-center border border-white/5">
<span className="text-4xl group-hover:scale-110 transition-transform">📚</span>
</div>
<h3 className="text-xl font-semibold mb-2 group-hover:text-blue-400">{course.title}</h3>
<div className="flex justify-between items-center pt-4 border-t border-white/5">
<span suppressHydrationWarning className="text-xs text-gray-500">
Created {mounted ? new Date(course.created_at).toLocaleDateString() : "---"}
</span>
<span className="text-xs font-medium text-blue-400">View Details </span>
</div>
</div>
</Link>
))}
<div
onClick={handleCreateCourse}
className="glass p-6 border-dashed border-2 border-white/10 flex flex-col items-center justify-center text-gray-500 hover:border-white/20 transition-all cursor-pointer min-h-[300px]"
>
<span className="text-3xl mb-2"></span>
<span className="text-sm">Add New Course</span>
</div>
</>
)}
</div>
</div>
);
}
}