feat: implement admin authentication, role-based permissions, and backoffice layout components
This commit is contained in:
52
admin/src/app/actions/auth.ts
Normal file
52
admin/src/app/actions/auth.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
"use server";
|
||||
|
||||
import { loginAdmin, logoutAdmin, getCurrentAdminUser } from "@/lib/auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export interface LoginActionInput {
|
||||
identifier?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
export async function loginAdminAction(input: LoginActionInput | FormData) {
|
||||
let identifier = "";
|
||||
let password = "";
|
||||
let rememberMe = true;
|
||||
|
||||
if (input instanceof FormData) {
|
||||
identifier = (input.get("identifier") as string) || (input.get("email") as string) || "";
|
||||
password = (input.get("password") as string) || "";
|
||||
rememberMe = input.get("rememberMe") === "true" || input.get("rememberMe") === "on";
|
||||
} else if (input && typeof input === "object") {
|
||||
identifier = input.identifier || input.email || "";
|
||||
password = input.password || "";
|
||||
rememberMe = input.rememberMe !== false;
|
||||
}
|
||||
|
||||
if (!identifier || !password) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Veuillez renseigner votre identifiant et votre mot de passe.",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await loginAdmin(identifier, password, rememberMe);
|
||||
|
||||
if (result.success) {
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function logoutAdminAction() {
|
||||
const result = await logoutAdmin();
|
||||
revalidatePath("/", "layout");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getCurrentAdminAction() {
|
||||
return await getCurrentAdminUser();
|
||||
}
|
||||
326
admin/src/app/actions/team.ts
Normal file
326
admin/src/app/actions/team.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
"use server";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { requireSuperAdminSession } from "@/lib/auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { ADMIN_ROLES, type AdminRoleType, ROLE_DEFAULT_PERMISSIONS } from "@/lib/permissions";
|
||||
|
||||
export interface CreateTeamMemberInput {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
adminRole: AdminRoleType;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateTeamMemberInput {
|
||||
id: string;
|
||||
name?: string;
|
||||
adminRole?: AdminRoleType;
|
||||
permissions?: string[];
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all staff / administration members.
|
||||
* Strictly restricted to Super Admin.
|
||||
*/
|
||||
export async function getTeamMembersAction() {
|
||||
try {
|
||||
await requireSuperAdminSession();
|
||||
|
||||
const members = await prisma.user.findMany({
|
||||
where: {
|
||||
role: 'ADMIN',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
adminRole: true,
|
||||
adminPermissions: true,
|
||||
isSuspended: true,
|
||||
suspensionReason: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
avatar: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ adminRole: 'asc' },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
members: members.map(m => ({
|
||||
...m,
|
||||
adminRole: m.adminRole || (m.email === 'admin@afrohub.com' ? 'SUPER_ADMIN' : 'ADMIN'),
|
||||
adminPermissions: m.adminPermissions || [],
|
||||
})),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Error in getTeamMembersAction:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || "Erreur lors de la récupération des membres de l'équipe",
|
||||
members: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new administration staff account with designated role and tab permissions.
|
||||
*/
|
||||
export async function createTeamMemberAction(input: CreateTeamMemberInput) {
|
||||
try {
|
||||
await requireSuperAdminSession();
|
||||
|
||||
const name = input.name?.trim();
|
||||
const email = input.email?.toLowerCase().trim();
|
||||
const password = input.password;
|
||||
const adminRole = input.adminRole || 'ADMIN';
|
||||
const permissions = input.permissions || [];
|
||||
|
||||
if (!name || !email || !password) {
|
||||
return { success: false, error: "Nom, email et mot de passe sont obligatoires." };
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return { success: false, error: "Le mot de passe doit comporter au moins 6 caractères." };
|
||||
}
|
||||
|
||||
if (!ADMIN_ROLES[adminRole]) {
|
||||
return { success: false, error: "Rôle administrateur invalide." };
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return { success: false, error: "Cette adresse email est déjà associée à un compte." };
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role: 'ADMIN',
|
||||
adminRole,
|
||||
adminPermissions: permissions,
|
||||
emailVerified: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
adminRole: true,
|
||||
adminPermissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/team");
|
||||
return { success: true, user: newUser };
|
||||
} catch (error: any) {
|
||||
console.error("Error in createTeamMemberAction:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || "Erreur lors de la création du compte administrateur.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a staff member's profile, role, custom tab permissions, or password.
|
||||
*/
|
||||
export async function updateTeamMemberAction(input: UpdateTeamMemberInput) {
|
||||
try {
|
||||
const currentSession = await requireSuperAdminSession();
|
||||
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id: input.id },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return { success: false, error: "Compte introuvable." };
|
||||
}
|
||||
|
||||
// Safety: Prevent removing the last Super Admin
|
||||
if (
|
||||
targetUser.adminRole === 'SUPER_ADMIN' &&
|
||||
input.adminRole &&
|
||||
input.adminRole !== 'SUPER_ADMIN'
|
||||
) {
|
||||
const superAdminCount = await prisma.user.count({
|
||||
where: {
|
||||
role: 'ADMIN',
|
||||
adminRole: 'SUPER_ADMIN',
|
||||
isSuspended: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (superAdminCount <= 1) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Impossible de modifier ce compte : la plateforme doit conserver au moins un Super Administrateur actif.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
if (input.name && input.name.trim()) {
|
||||
updateData.name = input.name.trim();
|
||||
}
|
||||
|
||||
if (input.adminRole && ADMIN_ROLES[input.adminRole]) {
|
||||
updateData.adminRole = input.adminRole;
|
||||
}
|
||||
|
||||
if (Array.isArray(input.permissions)) {
|
||||
updateData.adminPermissions = input.permissions;
|
||||
}
|
||||
|
||||
if (input.password && input.password.trim()) {
|
||||
if (input.password.trim().length < 6) {
|
||||
return { success: false, error: "Le nouveau mot de passe doit comporter au moins 6 caractères." };
|
||||
}
|
||||
updateData.password = await bcrypt.hash(input.password.trim(), 10);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: input.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
revalidatePath("/team");
|
||||
revalidatePath("/", "layout");
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error("Error in updateTeamMemberAction:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || "Erreur lors de la mise à jour du collaborateur.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends or reactivates a staff member's account.
|
||||
*/
|
||||
export async function toggleTeamMemberSuspensionAction(id: string, reason?: string) {
|
||||
try {
|
||||
const currentSession = await requireSuperAdminSession();
|
||||
|
||||
if (id === currentSession.userId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Vous ne pouvez pas suspendre votre propre compte Super Administrateur.",
|
||||
};
|
||||
}
|
||||
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return { success: false, error: "Compte introuvable." };
|
||||
}
|
||||
|
||||
if (targetUser.adminRole === 'SUPER_ADMIN' && !targetUser.isSuspended) {
|
||||
const superAdminCount = await prisma.user.count({
|
||||
where: {
|
||||
role: 'ADMIN',
|
||||
adminRole: 'SUPER_ADMIN',
|
||||
isSuspended: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (superAdminCount <= 1) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Impossible de suspendre le dernier Super Administrateur actif.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const newSuspended = !targetUser.isSuspended;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
isSuspended: newSuspended,
|
||||
suspensionReason: newSuspended ? (reason || "Suspension administrative") : null,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/team");
|
||||
return { success: true, isSuspended: newSuspended };
|
||||
} catch (error: any) {
|
||||
console.error("Error in toggleTeamMemberSuspensionAction:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || "Erreur lors de la suspension/réactivation.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a staff member account.
|
||||
*/
|
||||
export async function deleteTeamMemberAction(id: string) {
|
||||
try {
|
||||
const currentSession = await requireSuperAdminSession();
|
||||
|
||||
if (id === currentSession.userId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Vous ne pouvez pas supprimer votre propre compte Super Administrateur.",
|
||||
};
|
||||
}
|
||||
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return { success: false, error: "Compte introuvable." };
|
||||
}
|
||||
|
||||
if (targetUser.adminRole === 'SUPER_ADMIN') {
|
||||
const superAdminCount = await prisma.user.count({
|
||||
where: {
|
||||
role: 'ADMIN',
|
||||
adminRole: 'SUPER_ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
if (superAdminCount <= 1) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Impossible de supprimer le dernier Super Administrateur.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
revalidatePath("/team");
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error("Error in deleteTeamMemberAction:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || "Erreur lors de la suppression du collaborateur.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,25 @@ interface DashboardData {
|
||||
}>;
|
||||
}
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function DashboardClient({ initialData }: { initialData: DashboardData }) {
|
||||
const { stats, latestBusinesses, activities } = initialData;
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const unauthorized = searchParams.get('unauthorized');
|
||||
if (unauthorized) {
|
||||
toast.error(`Accès restreint : vous n'avez pas les autorisations pour accéder à ce module (${unauthorized}).`, {
|
||||
duration: 5000,
|
||||
});
|
||||
// Clean query param
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
||||
@@ -117,9 +134,9 @@ export default function DashboardClient({ initialData }: { initialData: Dashboar
|
||||
{b.plan}
|
||||
</span>
|
||||
{b.verified ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500" title="Vérifié" />
|
||||
<span title="Vérifié"><CheckCircle2 className="w-4 h-4 text-emerald-500" /></span>
|
||||
) : (
|
||||
<Clock className="w-4 h-4 text-amber-500" title="En attente" />
|
||||
<span title="En attente"><Clock className="w-4 h-4 text-amber-500" /></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import AdminLayoutWrapper from "@/components/AdminLayoutWrapper";
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
@@ -20,14 +20,12 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="fr">
|
||||
<body className={inter.className}>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<main className="admin-content flex-1 min-w-0">
|
||||
<Toaster position="top-right" />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<Toaster position="top-right" />
|
||||
<AdminLayoutWrapper>
|
||||
{children}
|
||||
</AdminLayoutWrapper>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
203
admin/src/app/login/page.tsx
Normal file
203
admin/src/app/login/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useTransition, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ShieldCheck, Lock, Mail, Eye, EyeOff, ArrowRight, Loader2, AlertCircle } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { loginAdminAction } from "@/app/actions/auth";
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const from = searchParams.get("from") || "/dashboard";
|
||||
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!identifier.trim() || !password) {
|
||||
setErrorMessage("Veuillez remplir tous les champs.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await loginAdminAction({
|
||||
identifier: identifier.trim(),
|
||||
password,
|
||||
rememberMe,
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
setErrorMessage(res.error || "Échec de l'authentification.");
|
||||
toast.error(res.error || "Erreur lors de la connexion");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(`Bienvenue, ${res.user?.name || "Administrateur"} !`);
|
||||
// Full page redirect ensures middleware & server cookies are freshly synchronized
|
||||
window.location.href = from.startsWith("/") ? from : "/dashboard";
|
||||
} catch (err) {
|
||||
console.error("Login client error:", err);
|
||||
setErrorMessage("Une erreur imprévue est survenue. Veuillez réessayer.");
|
||||
toast.error("Erreur serveur");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 sm:p-6 bg-[#0b0f19] relative overflow-hidden">
|
||||
{/* Background ambient lighting */}
|
||||
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-indigo-600/15 rounded-full blur-[120px] pointer-events-none" />
|
||||
<div className="absolute bottom-10 right-10 w-80 h-80 bg-violet-600/10 rounded-full blur-[100px] pointer-events-none" />
|
||||
|
||||
<div className="w-full max-w-md relative z-10">
|
||||
{/* Card Header & Brand */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-tr from-indigo-600 to-violet-500 shadow-xl shadow-indigo-500/25 mb-4 ring-4 ring-indigo-500/20">
|
||||
<ShieldCheck className="w-9 h-9 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-extrabold text-white tracking-tight">AfroAdmin</h1>
|
||||
<p className="text-sm text-slate-400 mt-2">
|
||||
Portail d'administration sécurisé Afrohub
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card Form */}
|
||||
<div className="bg-slate-900/80 backdrop-blur-xl border border-slate-800/80 rounded-2xl shadow-2xl p-6 sm:p-8">
|
||||
{errorMessage && (
|
||||
<div className="mb-6 p-4 rounded-xl bg-red-950/40 border border-red-800/60 flex items-start gap-3 text-red-200 text-sm animate-shake">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">{errorMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Identifier (Email / Username) */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
|
||||
Email ou Identifiant
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||
<Mail className="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
id="admin-identifier"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
placeholder="admin@afrohub.com"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
disabled={isPending}
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-800/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 text-sm focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 transition-all disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider">
|
||||
Mot de passe
|
||||
</label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||
<Lock className="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
id="admin-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="••••••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
className="w-full pl-11 pr-11 py-3 bg-slate-800/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 text-sm focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 transition-all disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-slate-400 hover:text-slate-200 focus:outline-none transition-colors"
|
||||
aria-label={showPassword ? "Masquer le mot de passe" : "Afficher le mot de passe"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remember Me */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none text-slate-400 hover:text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
disabled={isPending}
|
||||
className="w-4 h-4 rounded border-slate-700 bg-slate-800 text-indigo-600 focus:ring-indigo-500/30 focus:ring-offset-0"
|
||||
/>
|
||||
<span>Mémoriser ma session (30 jours)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full mt-2 py-3.5 px-4 bg-gradient-to-r from-indigo-600 to-indigo-500 hover:from-indigo-500 hover:to-indigo-400 text-white font-medium rounded-xl shadow-lg shadow-indigo-600/30 hover:shadow-indigo-600/40 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition-all flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>Vérification en cours...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Connexion à l'espace Admin</span>
|
||||
<ArrowRight className="w-4 h-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Security footnote */}
|
||||
<div className="mt-8 pt-6 border-t border-slate-800 text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 text-xs text-slate-500">
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Chiffrement SHA-256 de session • Accès strictement restreint</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand copyright */}
|
||||
<p className="text-center text-xs text-slate-600 mt-6">
|
||||
© {new Date().getFullYear()} Afrohub. Tous droits réservés.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#0b0f19] text-white">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
1029
admin/src/app/team/TeamClient.tsx
Normal file
1029
admin/src/app/team/TeamClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
31
admin/src/app/team/page.tsx
Normal file
31
admin/src/app/team/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { requireSuperAdminSession } from "@/lib/auth";
|
||||
import { getTeamMembersAction } from "@/app/actions/team";
|
||||
import TeamClient from "./TeamClient";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = {
|
||||
title: "Équipe & Rôles - AfroAdmin",
|
||||
description: "Gestion des comptes administrateurs, modérateurs et contributeurs",
|
||||
};
|
||||
|
||||
export default async function TeamPage() {
|
||||
let session;
|
||||
try {
|
||||
session = await requireSuperAdminSession();
|
||||
} catch (error) {
|
||||
redirect("/dashboard?unauthorized=team");
|
||||
}
|
||||
|
||||
const { members } = await getTeamMembersAction();
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8 max-w-7xl mx-auto">
|
||||
<TeamClient
|
||||
initialMembers={members as any}
|
||||
currentUserId={session.userId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user