feat: Add admin UI for organizations and users management
- Add Organizations page with create/edit functionality - Add Users page with role and organization assignment - Add modular Navbar component with role-based visibility - Implement admin-only access controls - Support multi-tenancy UI workflows
This commit is contained in:
@@ -0,0 +1,169 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { cmsApi, Organization } from '@/lib/api';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function OrganizationsPage() {
|
||||||
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [newDomain, setNewDomain] = useState('');
|
||||||
|
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 {
|
||||||
|
await cmsApi.createOrganization(newName, newDomain || undefined);
|
||||||
|
setNewName('');
|
||||||
|
setNewDomain('');
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadOrganizations();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create organization', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-400">Only system administrators can access this page.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in duration-500">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Organizations</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Manage tenants and isolated environments.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="flex items-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" />
|
||||||
|
New Organization
|
||||||
|
</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 glass 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 glass border border-white/10 hover:border-blue-500/50 transition-all hover:translate-y-[-2px] overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
|
<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-400">
|
||||||
|
<Building2 className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">{org.name}</h3>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
{org.domain || 'No custom domain'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-6">
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500 bg-black/20 p-2 rounded-lg">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
Created: {new Date(org.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-blue-500 font-mono">
|
||||||
|
{org.id.split('-')[0]}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="w-full py-2 px-4 text-sm font-medium border border-white/5 bg-white/5 hover:bg-white/10 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||||
|
Details <ExternalLink className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<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 className="w-full max-w-md glass border border-white/10 rounded-2xl p-8 shadow-2xl">
|
||||||
|
<h2 className="text-xl font-bold mb-6">Create New Organization</h2>
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium 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-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
||||||
|
placeholder="e.g. Acme Corp"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-400 mb-1.5">Domain (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newDomain}
|
||||||
|
onChange={(e) => setNewDomain(e.target.value)}
|
||||||
|
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
||||||
|
placeholder="e.g. acme.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 mt-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { cmsApi, User, Organization } from '@/lib/api';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { UserCog, Mail, Search, Filter, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [roleFilter, setRoleFilter] = useState('');
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [usersData, orgsData] = await Promise.all([
|
||||||
|
cmsApi.getAllUsers(),
|
||||||
|
cmsApi.getOrganizations()
|
||||||
|
]);
|
||||||
|
setUsers(usersData);
|
||||||
|
setOrganizations(orgsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load data', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateUser = async (userId: string, role: string, orgId: string) => {
|
||||||
|
try {
|
||||||
|
await cmsApi.updateUser(userId, role, orgId);
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update user', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUsers = users.filter(u => {
|
||||||
|
const matchesSearch = u.full_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
u.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesRole = roleFilter === '' || u.role === roleFilter;
|
||||||
|
return matchesSearch && matchesRole;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentUser?.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-400">Only system administrators can access this page.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in duration-500">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">User Management</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Manage global users, roles, and organization assignments.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search users..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full bg-black/40 border border-white/10 rounded-lg pl-10 pr-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
|
<select
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
|
className="bg-black/40 border border-white/10 rounded-lg pl-10 pr-8 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500/50 appearance-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">All Roles</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="instructor">Instructor</option>
|
||||||
|
<option value="student">Student</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass border border-white/10 rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead className="bg-white/5 border-b border-white/10">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-sm font-semibold">User</th>
|
||||||
|
<th className="px-6 py-4 text-sm font-semibold">Role</th>
|
||||||
|
<th className="px-6 py-4 text-sm font-semibold">Organization</th>
|
||||||
|
<th className="px-6 py-4 text-sm font-semibold text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{loading ? (
|
||||||
|
[1, 2, 3, 4, 5].map(i => (
|
||||||
|
<tr key={i} className="animate-pulse">
|
||||||
|
<td className="px-6 py-4"><div className="h-4 w-32 bg-white/10 rounded" /></td>
|
||||||
|
<td className="px-6 py-4"><div className="h-4 w-16 bg-white/10 rounded" /></td>
|
||||||
|
<td className="px-6 py-4"><div className="h-4 w-24 bg-white/10 rounded" /></td>
|
||||||
|
<td className="px-6 py-4 text-right"><div className="h-4 w-8 bg-white/10 ml-auto rounded" /></td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : filteredUsers.map((u) => (
|
||||||
|
<tr key={u.id} className="hover:bg-white/[0.02] transition-colors group">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-400">
|
||||||
|
{u.full_name[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{u.full_name}</div>
|
||||||
|
<div className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<Mail className="w-3 h-3" /> {u.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<select
|
||||||
|
value={u.role}
|
||||||
|
onChange={(e) => handleUpdateUser(u.id, e.target.value, u.organization_id || '00000000-0000-0000-0000-000000000000')}
|
||||||
|
className="bg-black/20 border border-white/5 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500/50"
|
||||||
|
>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="instructor">Instructor</option>
|
||||||
|
<option value="student">Student</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<select
|
||||||
|
value={u.organization_id || '00000000-0000-0000-0000-000000000000'}
|
||||||
|
onChange={(e) => handleUpdateUser(u.id, u.role, e.target.value)}
|
||||||
|
className="bg-black/20 border border-white/5 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500/50 max-w-[150px]"
|
||||||
|
>
|
||||||
|
{organizations.map(org => (
|
||||||
|
<option key={org.id} value={org.id}>{org.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<button className="p-2 hover:bg-white/10 rounded-lg transition-all text-gray-400 hover:text-white opacity-0 group-hover:opacity-100">
|
||||||
|
<UserCog className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{!loading && filteredUsers.length === 0 && (
|
||||||
|
<div className="p-12 text-center text-gray-500">
|
||||||
|
No users found matching your search.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { LayoutDashboard, Building2, Users2, LogOut } from 'lucide-react';
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight">
|
||||||
|
Open<span className="gradient-text">CCB</span> Studio
|
||||||
|
</h1>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm font-medium text-gray-400 hover:text-blue-400 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-4 h-4" />
|
||||||
|
Courses
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/admin/organizations"
|
||||||
|
className="text-sm font-medium text-gray-400 hover:text-blue-400 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
Organizations
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/users"
|
||||||
|
className="text-sm font-medium text-gray-400 hover:text-blue-400 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Users2 className="w-4 h-4" />
|
||||||
|
Users
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="text-sm font-medium text-gray-400 hover:text-blue-400 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
*/}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-white/10 mx-2" />
|
||||||
|
|
||||||
|
{user ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="text-xs font-medium text-white">{user.full_name}</span>
|
||||||
|
<span className="text-[10px] text-gray-500 uppercase tracking-wider">{user.role}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="p-2 hover:bg-red-500/10 hover:text-red-400 rounded-lg transition-all text-gray-500"
|
||||||
|
title="Sign Out"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/auth/login"
|
||||||
|
className="text-sm font-medium text-blue-400 hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user