feat: Introduce AI code hinting, enforce single-tenant organization model, and add a Code Lab block component.

This commit is contained in:
2026-03-09 17:24:15 -03:00
parent b9c17ce67b
commit bde5be22e7
26 changed files with 822 additions and 1378 deletions
@@ -1,649 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { cmsApi, Organization, getImageUrl, API_BASE_URL } from '@/lib/api';
import { useAuth } from '@/context/AuthContext';
import Image from 'next/image';
import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck, Palette, Upload, Save, X, Fingerprint, Key, Settings2, Edit2 } from 'lucide-react';
export default function OrganizationsPage() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
// Create/Edit States
const [isEditing, setIsEditing] = useState(false);
const [editingOrgId, setEditingOrgId] = useState<string | null>(null);
const [newName, setNewName] = useState('');
const [newDomain, setNewDomain] = useState('');
// Admin User States (Only for creation)
const [adminFullName, setAdminFullName] = useState('');
const [adminEmail, setAdminEmail] = useState('');
const [adminPassword, setAdminPassword] = useState('');
// Branding States
const [isBrandingModalOpen, setIsBrandingModalOpen] = useState(false);
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
const [primaryColor, setPrimaryColor] = useState('#3B82F6');
const [secondaryColor, setSecondaryColor] = useState('#8B5CF6');
const [isSavingBranding, setIsSavingBranding] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
// SSO States
const [isSSOModalOpen, setIsSSOModalOpen] = useState(false);
const [issuerUrl, setIssuerUrl] = useState('');
const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState('');
const [ssoEnabled, setSsoEnabled] = useState(false);
const [isSavingSSO, setIsSavingSSO] = useState(false);
const { user } = useAuth();
useEffect(() => {
loadOrganizations();
}, []);
const loadOrganizations = async () => {
try {
const data = await cmsApi.getOrganizations();
setOrganizations(data);
} catch (error) {
console.error('Failed to load organizations', error);
} finally {
setLoading(false);
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (isEditing && editingOrgId) {
await cmsApi.updateOrganization(editingOrgId, {
name: newName,
domain: newDomain || undefined
});
} else {
await cmsApi.provisionOrganization({
org_name: newName,
org_domain: newDomain || undefined,
admin_full_name: adminFullName,
admin_email: adminEmail,
admin_password: adminPassword
});
}
resetForm();
loadOrganizations();
} catch (error) {
console.error('Failed to save organization', error);
alert('Failed to save organization. Please check the details.');
}
};
const resetForm = () => {
setNewName('');
setNewDomain('');
setAdminFullName('');
setAdminEmail('');
setAdminPassword('');
setIsEditing(false);
setEditingOrgId(null);
setIsModalOpen(false);
};
const openEdit = (org: Organization) => {
setNewName(org.name);
setNewDomain(org.domain || '');
setEditingOrgId(org.id);
setIsEditing(true);
setIsModalOpen(true);
};
const openBranding = (org: Organization) => {
setSelectedOrg(org);
setPrimaryColor(org.primary_color || '#3B82F6');
setSecondaryColor(org.secondary_color || '#8B5CF6');
setIsBrandingModalOpen(true);
};
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !selectedOrg) return;
setUploadingLogo(true);
try {
const resp = await cmsApi.uploadOrganizationLogo(selectedOrg.id, file);
setSelectedOrg({ ...selectedOrg, logo_url: resp.url });
// Update in list
setOrganizations(orgs => orgs.map(o => o.id === selectedOrg.id ? { ...o, logo_url: resp.url } : o));
} catch (error) {
console.error('Failed to upload logo', error);
alert('Failed to upload logo. Please try again.');
} finally {
setUploadingLogo(false);
}
};
const handleBrandingSave = async () => {
if (!selectedOrg) return;
setIsSavingBranding(true);
try {
await cmsApi.updateOrganizationBranding(selectedOrg.id, {
primary_color: primaryColor,
secondary_color: secondaryColor
});
// Update in list
setOrganizations(orgs => orgs.map(o => o.id === selectedOrg.id ? { ...o, primary_color: primaryColor, secondary_color: secondaryColor } : o));
setIsBrandingModalOpen(false);
} catch (error) {
console.error('Failed to update branding', error);
alert('Failed to update branding. Please try again.');
} finally {
setIsSavingBranding(false);
}
};
const openSSOConfig = async (org: Organization) => {
setSelectedOrg(org);
setIsSSOModalOpen(true);
// Temporarily set org in localStorage for API calls
localStorage.setItem('studio_selected_org_id', org.id);
try {
const config = await cmsApi.getSSOConfig();
if (config) {
setIssuerUrl(config.issuer_url);
setClientId(config.client_id);
setClientSecret(config.client_secret);
setSsoEnabled(config.enabled);
} else {
setIssuerUrl('');
setClientId('');
setClientSecret('');
setSsoEnabled(false);
}
} catch (error) {
console.error('Failed to load SSO config', error);
}
};
const handleSSOSave = async () => {
if (!selectedOrg) return;
setIsSavingSSO(true);
try {
await cmsApi.updateSSOConfig({
issuer_url: issuerUrl,
client_id: clientId,
client_secret: clientSecret,
enabled: ssoEnabled
});
setIsSSOModalOpen(false);
alert('SSO configuration saved successfully!');
} catch (error) {
console.error('Failed to save SSO config', error);
alert('Failed to save SSO config. Please ensure all fields are correct.');
} finally {
setIsSavingSSO(false);
}
};
if (user?.role !== 'admin') {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
<div className="p-4 rounded-full bg-red-500/10 mb-4">
<ShieldCheck className="w-12 h-12 text-red-500" />
</div>
<h1 className="text-2xl font-bold mb-2">Access Denied</h1>
<p className="text-gray-600 dark:text-gray-400">Only system administrators can access this page.</p>
</div>
);
}
return (
<div className="space-y-6 md:space-y-8 animate-in fade-in duration-500 p-4 md:p-0">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-slate-900 dark:text-white">Organizations</h1>
<p className="text-slate-600 dark:text-gray-400 mt-1 text-sm">Manage tenants and isolated environments.</p>
</div>
<button
onClick={() => { resetForm(); setIsModalOpen(true); }}
className="w-full sm:w-auto flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 shadow-glow"
>
<Plus className="w-4 h-4" />
<span className="inline">New Organization</span>
</button>
</div>
{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-48 rounded-xl bg-slate-100 dark:bg-white/5 border border-slate-200 dark:border-white/10 animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{organizations.map((org) => (
<div
key={org.id}
className="group relative p-6 rounded-xl bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 hover:border-blue-500/50 transition-all hover:translate-y-[-2px] overflow-hidden shadow-sm dark:shadow-none"
>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-slate-400 dark:text-white">
<Building2 className="w-16 h-16" />
</div>
<div className="flex items-start gap-4 mb-4">
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400 overflow-hidden w-12 h-12 flex items-center justify-center relative border border-blue-500/20">
{org.logo_url ? (
<Image src={getImageUrl(org.logo_url)} alt={org.name} fill className="object-contain" unoptimized />
) : (
<Building2 className="w-6 h-6" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold text-lg text-slate-900 dark:text-white truncate">{org.name}</h3>
<button
onClick={() => openEdit(org)}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-white/10 text-slate-400 hover:text-slate-600 dark:hover:text-white transition-colors"
title="Edit Organization"
>
<Edit2 className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-1.5 text-sm text-slate-500 dark:text-gray-400">
<Globe className="w-3 h-3" />
<span className="truncate">{org.domain || 'No custom domain'}</span>
</div>
</div>
</div>
<div className="flex gap-2 mt-4 mb-2">
<div className="flex-1 h-1 rounded-full opacity-60" style={{ backgroundColor: org.primary_color || '#3B82F6' }} title="Primary Color" />
<div className="flex-1 h-1 rounded-full opacity-60" style={{ backgroundColor: org.secondary_color || '#8B5CF6' }} title="Secondary Color" />
</div>
<div className="space-y-3 mt-4">
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-gray-500 bg-slate-50 dark:bg-black/20 p-2 rounded-lg border border-slate-100 dark:border-white/5">
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(org.created_at).toLocaleDateString()}
</div>
<div className="text-blue-600 dark:text-blue-500 font-mono">
{org.id.split('-')[0]}...
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<button
onClick={() => openBranding(org)}
className="py-2 px-2 text-[10px] font-bold border border-blue-500/20 bg-blue-50 dark:bg-blue-500/5 hover:bg-blue-100 dark:hover:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-lg transition-colors flex items-center justify-center gap-1"
>
<Palette className="w-3 h-3" /> BRAND
</button>
<button
onClick={() => openSSOConfig(org)}
className="py-2 px-2 text-[10px] font-bold border border-indigo-500/20 bg-indigo-50 dark:bg-indigo-500/5 hover:bg-indigo-100 dark:hover:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 rounded-lg transition-colors flex items-center justify-center gap-1"
>
<Fingerprint className="w-3 h-3" /> SSO
</button>
<button className="py-2 px-2 text-[10px] font-bold border border-slate-200 dark:border-white/5 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors flex items-center justify-center gap-1 text-slate-600 dark:text-gray-400">
DOCS <ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Create/Edit Organization Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-full max-w-md bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-2xl p-8 shadow-2xl overflow-y-auto max-h-[90vh]">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-slate-900 dark:text-white">
{isEditing ? 'Edit Organization' : 'Create New Organization'}
</h2>
<button onClick={resetForm} className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-full transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Organization Name</label>
<input
type="text"
required
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400"
placeholder="e.g. Acme Corp"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Domain (Optional)</label>
<div className="relative">
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm text-slate-900 dark:text-white placeholder-slate-400"
placeholder="e.g. acme.com"
/>
</div>
</div>
{!isEditing && (
<div className="pt-4 border-t border-slate-100 dark:border-white/5">
<h3 className="text-xs font-black uppercase tracking-widest text-blue-600 dark:text-blue-500 mb-4">Initial Administrator</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Admin Full Name</label>
<input
type="text"
required
value={adminFullName}
onChange={(e) => setAdminFullName(e.target.value)}
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400"
placeholder="e.g. John Doe"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Admin Email</label>
<input
type="email"
required
value={adminEmail}
onChange={(e) => setAdminEmail(e.target.value)}
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400"
placeholder="admin@acme.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Admin Password</label>
<input
type="password"
required
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400"
placeholder="••••••••"
/>
</div>
</div>
</div>
)}
<div className="flex gap-3 mt-8">
<button
type="button"
onClick={resetForm}
className="flex-1 px-4 py-2.5 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-lg transition-all text-slate-600 dark:text-white font-medium"
>
Cancel
</button>
<button
type="submit"
className="flex-1 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"
>
{isEditing ? 'Save Changes' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Branding Management Modal */}
{isBrandingModalOpen && selectedOrg && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-full max-w-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-2xl p-8 shadow-2xl">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Branding Management</h2>
<p className="text-sm text-slate-600 dark:text-gray-400">{selectedOrg.name}</p>
</div>
<button onClick={() => setIsBrandingModalOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-full transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-6">
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-3 uppercase tracking-wider text-[10px] font-black">Organization Logo</label>
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-xl bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 flex items-center justify-center overflow-hidden relative">
{selectedOrg.logo_url ? (
<Image src={getImageUrl(selectedOrg.logo_url)} alt="Preview" fill className="object-contain" unoptimized />
) : (
<Building2 className="w-8 h-8 text-slate-300 dark:text-gray-600" />
)}
</div>
<div className="flex-1">
<label className="relative flex items-center justify-center gap-2 px-4 py-2 bg-blue-600/10 hover:bg-blue-600/20 text-blue-600 dark:text-blue-400 rounded-lg cursor-pointer transition-all border border-blue-500/20 font-bold text-xs uppercase">
<Upload className="w-4 h-4" />
{uploadingLogo ? 'Uploading...' : 'Upload Logo'}
<input type="file" className="hidden" accept="image/*" onChange={handleLogoUpload} disabled={uploadingLogo} />
</label>
<p className="text-[10px] text-slate-500 mt-2">PNG, JPG or SVG. Max 2MB.</p>
</div>
</div>
</div>
{/* Colors */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-2">Primary Color</label>
<div className="flex gap-2">
<input
type="color"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="w-10 h-10 rounded cursor-pointer border border-slate-200 dark:border-white/10 p-1 bg-white dark:bg-black/40"
/>
<input
type="text"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="flex-1 bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-3 py-2 text-sm font-mono text-slate-900 dark:text-white"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-2">Secondary Color</label>
<div className="flex gap-2">
<input
type="color"
value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)}
className="w-10 h-10 rounded cursor-pointer border border-slate-200 dark:border-white/10 p-1 bg-white dark:bg-black/40"
/>
<input
type="text"
value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)}
className="flex-1 bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-3 py-2 text-sm font-mono text-slate-900 dark:text-white"
/>
</div>
</div>
</div>
</div>
{/* Live Preview */}
<div className="space-y-4">
<label className="block text-sm font-medium text-slate-700 dark:text-gray-400 mb-2 uppercase tracking-wider text-[10px] font-black">Portal Preview</label>
<div className="rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden bg-white dark:bg-slate-950 shadow-inner">
{/* Mock Experience Header */}
<div className="h-10 px-4 flex items-center justify-between border-b border-black/5" style={{ backgroundColor: primaryColor }}>
<div className="flex items-center gap-2">
<div className="w-5 h-5 bg-white/20 rounded flex items-center justify-center overflow-hidden relative">
{selectedOrg.logo_url ? (
<Image src={getImageUrl(selectedOrg.logo_url)} alt="Logo" fill className="object-contain" unoptimized />
) : <div className="w-3 h-3 bg-white" />}
</div>
<div className="w-16 h-2 bg-white/30 rounded" />
</div>
<div className="flex gap-2">
<div className="w-6 h-2 bg-white/20 rounded" />
<div className="w-6 h-2 bg-white/20 rounded" />
</div>
</div>
{/* Mock Experience Content */}
<div className="p-4 space-y-3 bg-slate-50 dark:bg-[#0a0c10]">
<div className="w-2/3 h-4 bg-slate-200 dark:bg-white/10 rounded mb-2" />
<div className="w-full h-24 bg-white dark:bg-white/5 rounded-lg border border-slate-200 dark:border-white/5 p-3 shadow-sm">
<div className="w-1/3 h-3 rounded mb-2" style={{ backgroundColor: secondaryColor }} />
<div className="w-full h-2 bg-slate-100 dark:bg-white/5 rounded mb-1" />
<div className="w-full h-2 bg-slate-100 dark:bg-white/5 rounded mb-1" />
<div className="w-1/2 h-2 bg-slate-100 dark:bg-white/5 rounded" />
<div className="mt-4 flex justify-end">
<div className="px-3 py-1.5 rounded text-[8px] font-bold text-white shadow-sm" style={{ backgroundColor: primaryColor }}>
GET STARTED
</div>
</div>
</div>
</div>
</div>
<div className="p-3 rounded-lg bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20">
<p className="text-[10px] text-blue-700 dark:text-blue-400 leading-relaxed font-medium">
Real-time preview of the brand application.
</p>
</div>
</div>
</div>
<div className="flex gap-3 mt-10">
<button
onClick={() => setIsBrandingModalOpen(false)}
className="flex-1 px-4 py-3 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-xl transition-all font-medium text-slate-600 dark:text-white"
>
Cancel
</button>
<button
onClick={handleBrandingSave}
disabled={isSavingBranding}
className="flex-[2] px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-all shadow-lg shadow-blue-500/20 font-bold flex items-center justify-center gap-2 uppercase tracking-wide"
>
{isSavingBranding ? <div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" /> : <Save className="w-5 h-5" />}
Save Branding
</button>
</div>
</div>
</div>
)}
{/* SSO Configuration Modal */}
{isSSOModalOpen && selectedOrg && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-full max-w-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-2xl p-8 shadow-2xl">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-600 dark:text-indigo-400">
<Fingerprint className="w-6 h-6" />
</div>
<div>
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Single Sign-On (OIDC)</h2>
<p className="text-sm text-slate-600 dark:text-gray-400">{selectedOrg.name}</p>
</div>
</div>
<button onClick={() => setIsSSOModalOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-full transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-xl bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10">
<div className="pr-4">
<h3 className="font-semibold text-slate-900 dark:text-white">Enable OIDC SSO</h3>
<p className="text-xs text-slate-500 dark:text-gray-500">Allow users to log in via your identity provider.</p>
</div>
<button
onClick={() => setSsoEnabled(!ssoEnabled)}
className={`w-12 h-6 rounded-full transition-colors relative shrink-0 ${ssoEnabled ? 'bg-blue-600' : 'bg-slate-300 dark:bg-gray-700'}`}
>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-all ${ssoEnabled ? 'right-1' : 'left-1'}`} />
</button>
</div>
<div className="space-y-4 pt-2">
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Issuer URL</label>
<div className="relative">
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="url"
value={issuerUrl}
onChange={(e) => setIssuerUrl(e.target.value)}
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm text-slate-900 dark:text-white placeholder-slate-400"
placeholder="https://accounts.google.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Client ID</label>
<div className="relative">
<ShieldCheck className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm text-slate-900 dark:text-white placeholder-slate-400"
placeholder="Your OIDC Client ID"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 dark:text-gray-400 mb-1.5">Client Secret</label>
<div className="relative">
<Key className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="password"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm text-slate-900 dark:text-white placeholder-slate-400"
placeholder="••••••••••••••••"
/>
</div>
</div>
<div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 space-y-2">
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-400 text-xs font-black uppercase tracking-wider">
<Settings2 className="w-4 h-4" /> CONFIGURATION STEPS
</div>
<p className="text-[10px] text-blue-800 dark:text-blue-300 leading-relaxed font-medium">
1. Register OpenCCB in your IDP.<br />
2. Redirect URI: <span className="font-mono bg-blue-200 dark:bg-blue-500/20 px-1 rounded">{API_BASE_URL}/auth/sso/callback</span><br />
3. Copy the Issuer, ID and Secret here.
</p>
</div>
</div>
</div>
<div className="flex gap-3 mt-8">
<button
onClick={() => setIsSSOModalOpen(false)}
className="flex-1 px-4 py-3 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-xl transition-all font-medium text-slate-600 dark:text-white"
>
Cancel
</button>
<button
onClick={handleSSOSave}
disabled={isSavingSSO}
className="flex-[2] px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-all shadow-lg shadow-blue-500/20 font-bold flex items-center justify-center gap-2 uppercase tracking-wide"
>
{isSavingSSO ? <div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" /> : <Save className="w-5 h-5" />}
Save SSO Settings
</button>
</div>
</div>
</div>
)}
</div>
);
}
+3 -17
View File
@@ -5,13 +5,12 @@ 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, Building2 } from "lucide-react";
import { UserPlus, Mail, Lock, User } 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);
@@ -23,7 +22,7 @@ export default function RegisterPage() {
setLoading(true);
setError("");
try {
const res = await cmsApi.register({ email, password, full_name: fullName, organization_name: organizationName });
const res = await cmsApi.register({ email, password, full_name: fullName });
login(res.user, res.token);
router.push("/");
} catch (err) {
@@ -98,20 +97,7 @@ 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&apos;ll use your email domain.</p>
</div>
<button
disabled={loading}
@@ -38,6 +38,7 @@ import MemoryBlock from "@/components/blocks/MemoryBlock";
import RolePlayingBlock from "@/components/blocks/RolePlayingBlock";
import PeerReviewBlock from "@/components/blocks/PeerReviewBlock";
import MermaidBlock from "@/components/blocks/MermaidBlock";
import CodeLabBlock from "@/components/blocks/CodeLabBlock";
import SaveToLibraryModal from "@/components/modals/SaveToLibraryModal";
import LibraryPanel from "@/components/LibraryPanel";
import Modal from "@/components/Modal";
@@ -1106,6 +1107,20 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
onUpdate={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'code-lab' && (
<CodeLabBlock
id={block.id}
title={block.title}
language={block.language}
instructions={block.instructions}
initial_code={block.initial_code}
solution={block.solution}
test_cases={block.test_cases}
editMode={editMode}
lessonId={params.lessonId}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
</div>
</div>
))}
@@ -1148,6 +1163,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
{ type: 'audio-response', icon: '🎤', label: 'Oral Practice', color: 'blue' },
{ type: 'memory-match', icon: '🧩', label: 'Logic Game', color: 'indigo' },
{ type: 'peer-review', icon: '👥', label: 'Peer Review', color: 'slate' },
{ type: 'code-lab', icon: '🧑‍💻', label: 'Code Lab', color: 'indigo' },
{ type: 'mermaid', icon: '📊', label: 'Mermaid Diagram', color: 'indigo' },
{ type: 'role-playing', icon: '🎭', label: 'Role-Playing AI', color: 'purple' },
].map((item) => (
+5 -36
View File
@@ -21,7 +21,6 @@ import {
Send,
} from "lucide-react";
import CourseEditorLayout from "@/components/CourseEditorLayout";
import OrganizationSelector from "@/components/OrganizationSelector";
interface FullModule extends Module {
lessons: Lesson[];
@@ -35,8 +34,6 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
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();
@@ -63,19 +60,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
loadData();
}, [params.id]);
useEffect(() => {
const loadOrgs = async () => {
if (user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001') {
try {
const orgs = await cmsApi.getOrganizations();
setOrganizations(orgs);
} catch (err) {
console.error("Failed to load organizations", err);
}
}
};
loadOrgs();
}, [user]);
const handleAddModule = async () => {
const title = "";
@@ -194,27 +179,19 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
const handlePublish = async () => {
if (!course) return;
const isSuperAdmin = user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001';
if (isSuperAdmin && organizations.length > 0) {
setIsOrgModalOpen(true);
} else {
publishCourse();
}
publishCourse();
};
const publishCourse = async (targetOrgId?: string) => {
const publishCourse = async () => {
try {
setSaving(true);
await cmsApi.publishCourse(params.id as string, targetOrgId);
await cmsApi.publishCourse(params.id as string);
alert("Course published successfully!");
} catch (err) {
console.error("Failed to publish course", err);
alert("Failed to publish course.");
} finally {
setSaving(false);
setIsOrgModalOpen(false); // Close modal after publishing attempt
}
};
@@ -429,15 +406,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
</button>
</div>
</CourseEditorLayout>
{/* Organization Selector Modal */}
<OrganizationSelector
isOpen={isOrgModalOpen}
onClose={() => setIsOrgModalOpen(false)}
organizations={organizations}
title="Publish to Organization"
actionLabel="Publish Course"
onConfirm={(orgId) => publishCourse(orgId)}
/>
</>
);
}
+5 -34
View File
@@ -6,7 +6,6 @@ import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
import { useTranslation } from "@/context/I18nContext";
import { Plus, BookOpen, Download, Upload, Sparkles, Wand2, Trash2 } from "lucide-react";
import OrganizationSelector from "@/components/OrganizationSelector";
import Modal from "@/components/Modal";
import PageLayout from "@/components/PageLayout";
@@ -34,27 +33,13 @@ export default function StudioDashboard() {
loadCourses();
}, [user]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
const [isTitleModalOpen, setIsTitleModalOpen] = useState(false);
const [isAIModalOpen, setIsAIModalOpen] = useState(false);
const [newCourseTitle, setNewCourseTitle] = useState("");
const [aiPrompt, setAiPrompt] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
useEffect(() => {
const loadOrgs = async () => {
if (user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001') {
try {
const orgs = await cmsApi.getOrganizations();
setOrganizations(orgs);
} catch (err) {
console.error("Failed to load organizations", err);
}
}
};
loadOrgs();
}, [user]);
const handleCreateCourse = async () => {
setIsTitleModalOpen(true);
@@ -64,18 +49,12 @@ export default function StudioDashboard() {
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();
}
createCourse();
};
const createCourse = async (targetOrgId?: string) => {
const createCourse = async () => {
try {
const newCourse = await cmsApi.createCourse(newCourseTitle, targetOrgId);
const newCourse = await cmsApi.createCourse(newCourseTitle);
setCourses((prev: Course[]) => [...prev, newCourse]);
setNewCourseTitle("");
} catch (err) {
@@ -331,15 +310,7 @@ export default function StudioDashboard() {
</form>
</Modal>
{/* Organization Selector Modal */}
<OrganizationSelector
isOpen={isOrgModalOpen}
onClose={() => setIsOrgModalOpen(false)}
organizations={organizations}
title="Target Organization"
actionLabel="Create Course"
onConfirm={(orgId) => createCourse(orgId)}
/>
</PageLayout>
);
}