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 }) {
|
export default function DashboardClient({ initialData }: { initialData: DashboardData }) {
|
||||||
const { stats, latestBusinesses, activities } = initialData;
|
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 = [
|
const statCards = [
|
||||||
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
||||||
@@ -117,9 +134,9 @@ export default function DashboardClient({ initialData }: { initialData: Dashboar
|
|||||||
{b.plan}
|
{b.plan}
|
||||||
</span>
|
</span>
|
||||||
{b.verified ? (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import Sidebar from "@/components/Sidebar";
|
import AdminLayoutWrapper from "@/components/AdminLayoutWrapper";
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
@@ -20,14 +20,12 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<div className="flex min-h-screen">
|
|
||||||
<Sidebar />
|
|
||||||
<main className="admin-content flex-1 min-w-0">
|
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
|
<AdminLayoutWrapper>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</AdminLayoutWrapper>
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
admin/src/components/AdminLayoutWrapper.tsx
Normal file
30
admin/src/components/AdminLayoutWrapper.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import Sidebar from "@/components/Sidebar";
|
||||||
|
|
||||||
|
export default function AdminLayoutWrapper({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isLoginPage = pathname === "/login";
|
||||||
|
|
||||||
|
if (isLoginPage) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen w-full bg-[#0b0f19] text-[#f8fafc]">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="admin-content flex-1 min-w-0">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,55 +9,86 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
LogOut,
|
LogOut,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
ShieldAlert,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Flag,
|
Flag,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Settings,
|
Settings,
|
||||||
Globe,
|
Globe,
|
||||||
FileText,
|
FileText,
|
||||||
Briefcase,
|
|
||||||
Tags,
|
Tags,
|
||||||
Image as ImageIcon
|
Image as ImageIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{ name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
|
|
||||||
{ name: 'Utilisateurs', icon: Users, href: '/users' },
|
|
||||||
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
|
|
||||||
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
|
||||||
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
|
||||||
{ name: 'Actualités & Interviews', icon: BookOpen, href: '/actualites' },
|
|
||||||
{ name: 'Forfaits & Services', icon: CreditCard, href: '/plans' },
|
|
||||||
{ name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
|
|
||||||
{ name: 'Pays', icon: Globe, href: '/countries' },
|
|
||||||
{ name: 'Bannière', icon: ImageIcon, href: '/slides' },
|
|
||||||
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
|
|
||||||
{ name: 'Configuration', icon: Settings, href: '/settings' },
|
|
||||||
];
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getPendingVerificationCount } from '@/app/actions/business';
|
import { getPendingVerificationCount } from '@/app/actions/business';
|
||||||
|
import { getCurrentAdminAction, logoutAdminAction } from '@/app/actions/auth';
|
||||||
|
import type { AdminSessionPayload } from '@/lib/session';
|
||||||
|
import { hasTabPermission, ADMIN_ROLES, type AdminRoleType } from '@/lib/permissions';
|
||||||
|
|
||||||
|
const ALL_MENU_ITEMS = [
|
||||||
|
{ id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
|
||||||
|
{ id: 'team', name: 'Équipe & Rôles', icon: ShieldAlert, href: '/team' },
|
||||||
|
{ id: 'users', name: 'Utilisateurs', icon: Users, href: '/users' },
|
||||||
|
{ id: 'metrics', name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
|
||||||
|
{ id: 'comments', name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
||||||
|
{ id: 'moderation', name: 'Modération', icon: Flag, href: '/moderation' },
|
||||||
|
{ id: 'actualites', name: 'Actualités & Interviews', icon: BookOpen, href: '/actualites' },
|
||||||
|
{ id: 'plans', name: 'Forfaits & Services', icon: CreditCard, href: '/plans' },
|
||||||
|
{ id: 'taxonomies', name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
|
||||||
|
{ id: 'countries', name: 'Pays', icon: Globe, href: '/countries' },
|
||||||
|
{ id: 'slides', name: 'Bannière', icon: ImageIcon, href: '/slides' },
|
||||||
|
{ id: 'legal', name: 'Documents Légaux', icon: FileText, href: '/legal' },
|
||||||
|
{ id: 'settings', name: 'Configuration', icon: Settings, href: '/settings' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
|
const [adminUser, setAdminUser] = useState<AdminSessionPayload | null>(null);
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getPendingVerificationCount().then(setPendingCount);
|
getPendingVerificationCount().then(setPendingCount);
|
||||||
}, [pathname]); // Refresh on navigation
|
}, [pathname]); // Refresh on navigation
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCurrentAdminAction().then(setAdminUser);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoggingOut(true);
|
||||||
|
await logoutAdminAction();
|
||||||
|
window.location.href = '/login';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout error:', err);
|
||||||
|
setIsLoggingOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleMenuItems = ALL_MENU_ITEMS.filter((item) => {
|
||||||
|
if (!adminUser) return true;
|
||||||
|
return hasTabPermission(adminUser.adminRole, adminUser.permissions, item.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleMeta = adminUser ? ADMIN_ROLES[adminUser.adminRole as AdminRoleType] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-sidebar z-50 p-6 flex flex-col">
|
<div className="admin-sidebar z-50 p-6 flex flex-col">
|
||||||
<div className="flex items-center gap-3 mb-10 px-2">
|
<div className="flex items-center gap-3 mb-8 px-2">
|
||||||
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
|
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center shadow-md shadow-indigo-600/30">
|
||||||
<ShieldCheck className="text-white" />
|
<ShieldCheck className="text-white w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-white leading-none">AfroAdmin</h1>
|
||||||
|
<span className="text-[10px] uppercase font-semibold tracking-wider text-indigo-400">Backoffice</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold tracking-tight text-white">AfroAdmin</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 space-y-1">
|
<nav className="flex-1 space-y-1 overflow-y-auto pr-1">
|
||||||
{menuItems.map((item) => {
|
{visibleMenuItems.map((item) => {
|
||||||
const isActive = pathname === item.href;
|
const isActive = pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href + '/'));
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
@@ -65,24 +96,55 @@ export default function Sidebar() {
|
|||||||
className={`nav-link ${isActive ? 'active' : ''} flex items-center justify-between group`}
|
className={`nav-link ${isActive ? 'active' : ''} flex items-center justify-between group`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<item.icon className="w-5 h-5 mr-3" />
|
<item.icon className="w-5 h-5 mr-3 shrink-0" />
|
||||||
<span>{item.name}</span>
|
<span className="truncate">{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.name === 'Utilisateurs' && pendingCount > 0 && (
|
{item.id === 'users' && pendingCount > 0 && (
|
||||||
<span className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shadow-lg group-hover:scale-110 transition-transform">
|
<span className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shadow-lg group-hover:scale-110 transition-transform">
|
||||||
{pendingCount}
|
{pendingCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{item.id === 'team' && (
|
||||||
|
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-300 border border-purple-500/30">
|
||||||
|
Super
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mt-auto pt-6 border-t border-slate-700">
|
<div className="mt-auto pt-4 border-t border-slate-700/80 space-y-3">
|
||||||
<button className="nav-link w-full text-left text-red-400 hover:bg-red-950/30">
|
{adminUser && (
|
||||||
<LogOut className="w-5 h-5 mr-3" />
|
<div className="px-3 py-2.5 rounded-xl bg-slate-800/60 border border-slate-700/50 flex items-center gap-3">
|
||||||
<span>Déconnexion</span>
|
<div className={`w-9 h-9 rounded-lg bg-gradient-to-tr ${roleMeta?.iconBgClass || 'from-indigo-500 to-violet-500'} flex items-center justify-center text-white font-bold text-sm shadow shrink-0`}>
|
||||||
|
{adminUser.name ? adminUser.name.charAt(0).toUpperCase() : 'A'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<p className="text-sm font-semibold text-white truncate leading-tight">
|
||||||
|
{adminUser.name || 'Admin'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400 truncate mb-1">
|
||||||
|
{adminUser.email}
|
||||||
|
</p>
|
||||||
|
<span className={`inline-block text-[10px] font-semibold px-2 py-0.5 rounded-full border ${roleMeta?.badgeClass || 'bg-slate-700 text-slate-300 border-slate-600'}`}>
|
||||||
|
{roleMeta?.badgeLabel || adminUser.adminRole}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={isLoggingOut}
|
||||||
|
className="nav-link w-full text-left text-red-400 hover:bg-red-950/30 hover:text-red-300 transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5 mr-3 shrink-0" />
|
||||||
|
<span>{isLoggingOut ? 'Déconnexion...' : 'Déconnexion'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
179
admin/src/lib/auth.ts
Normal file
179
admin/src/lib/auth.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import {
|
||||||
|
signAdminToken,
|
||||||
|
setAdminSessionCookie,
|
||||||
|
clearAdminSessionCookie,
|
||||||
|
getAdminSession,
|
||||||
|
DEFAULT_SESSION_DURATION,
|
||||||
|
REMEMBER_ME_DURATION,
|
||||||
|
type AdminSessionPayload,
|
||||||
|
} from './session';
|
||||||
|
|
||||||
|
export interface AdminLoginResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
adminRole: string;
|
||||||
|
permissions: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates admin credentials (email or username + password), checks ADMIN role,
|
||||||
|
* and sets the session cookie upon success with role and permissions.
|
||||||
|
*/
|
||||||
|
export async function loginAdmin(
|
||||||
|
identifier: string,
|
||||||
|
password: string,
|
||||||
|
rememberMe: boolean = true
|
||||||
|
): Promise<AdminLoginResult> {
|
||||||
|
const cleanIdentifier = identifier.trim();
|
||||||
|
|
||||||
|
if (!cleanIdentifier || !password) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Veuillez renseigner votre identifiant et votre mot de passe.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Search user by email (case-insensitive) or by exact name
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ email: { equals: cleanIdentifier, mode: 'insensitive' } },
|
||||||
|
{ name: { equals: cleanIdentifier, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Identifiants invalides. Vérifiez votre email ou mot de passe.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify bcrypt password
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Identifiants invalides. Vérifiez votre email ou mot de passe.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user role is ADMIN
|
||||||
|
if (user.role !== 'ADMIN') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Accès refusé : ce compte ne dispose pas des privilèges d'administrateur.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check account status
|
||||||
|
if (user.isSuspended) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Ce compte administrateur est actuellement suspendu. Contactez le super-administrateur.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.deletedAt) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Ce compte administrateur est désactivé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine adminRole (default to SUPER_ADMIN for master email or if designated)
|
||||||
|
const adminRole = user.adminRole || (user.email === 'admin@afrohub.com' ? 'SUPER_ADMIN' : 'ADMIN');
|
||||||
|
const permissions = user.adminPermissions || [];
|
||||||
|
|
||||||
|
// Calculate duration based on "Remember Me"
|
||||||
|
const sessionDuration = rememberMe ? REMEMBER_ME_DURATION : DEFAULT_SESSION_DURATION;
|
||||||
|
|
||||||
|
// Generate signed token
|
||||||
|
const token = await signAdminToken(
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
adminRole,
|
||||||
|
permissions,
|
||||||
|
},
|
||||||
|
sessionDuration
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save session in HTTP-only cookie
|
||||||
|
await setAdminSessionCookie(token, sessionDuration);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
adminRole,
|
||||||
|
permissions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la connexion administrateur:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Une erreur technique est survenue lors de la tentative de connexion.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs out the current admin by clearing the session cookie.
|
||||||
|
*/
|
||||||
|
export async function logoutAdmin(): Promise<{ success: boolean }> {
|
||||||
|
try {
|
||||||
|
await clearAdminSessionCookie();
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la déconnexion:', error);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently authenticated admin session or null.
|
||||||
|
*/
|
||||||
|
export async function getCurrentAdminUser(): Promise<AdminSessionPayload | null> {
|
||||||
|
return await getAdminSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to ensure a server action or page is invoked by an authenticated admin.
|
||||||
|
* Throws an error or returns the session payload.
|
||||||
|
*/
|
||||||
|
export async function requireAdminSession(): Promise<AdminSessionPayload> {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session || session.role !== 'ADMIN') {
|
||||||
|
throw new Error('Non autorisé : session administrateur requise');
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to ensure the current admin has SUPER_ADMIN role.
|
||||||
|
*/
|
||||||
|
export async function requireSuperAdminSession(): Promise<AdminSessionPayload> {
|
||||||
|
const session = await requireAdminSession();
|
||||||
|
if (session.adminRole !== 'SUPER_ADMIN') {
|
||||||
|
throw new Error('Accès réservé au Super Administrateur');
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
281
admin/src/lib/permissions.ts
Normal file
281
admin/src/lib/permissions.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
export type AdminRoleType = 'SUPER_ADMIN' | 'ADMIN' | 'MODERATOR' | 'EDITOR';
|
||||||
|
|
||||||
|
export interface AdminRoleConfig {
|
||||||
|
id: AdminRoleType;
|
||||||
|
label: string;
|
||||||
|
badgeLabel: string;
|
||||||
|
description: string;
|
||||||
|
badgeClass: string;
|
||||||
|
iconBgClass: string;
|
||||||
|
defaultTabs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTabDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
iconName: string;
|
||||||
|
description: string;
|
||||||
|
// Sub-routes that belong to this tab
|
||||||
|
routes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADMIN_ROLES: Record<AdminRoleType, AdminRoleConfig> = {
|
||||||
|
SUPER_ADMIN: {
|
||||||
|
id: 'SUPER_ADMIN',
|
||||||
|
label: 'Super Administrateur',
|
||||||
|
badgeLabel: 'Super Admin',
|
||||||
|
description: 'Accès absolu à tous les modules et gestion exclusive des comptes administrateurs & rôles.',
|
||||||
|
badgeClass: 'bg-purple-500/15 text-purple-300 border-purple-500/30',
|
||||||
|
iconBgClass: 'from-purple-600 to-indigo-600',
|
||||||
|
defaultTabs: [
|
||||||
|
'dashboard',
|
||||||
|
'team',
|
||||||
|
'users',
|
||||||
|
'metrics',
|
||||||
|
'comments',
|
||||||
|
'moderation',
|
||||||
|
'actualites',
|
||||||
|
'plans',
|
||||||
|
'taxonomies',
|
||||||
|
'countries',
|
||||||
|
'slides',
|
||||||
|
'legal',
|
||||||
|
'settings',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ADMIN: {
|
||||||
|
id: 'ADMIN',
|
||||||
|
label: 'Administrateur',
|
||||||
|
badgeLabel: 'Admin',
|
||||||
|
description: 'Gestion globale de la plateforme, des entreprises, des forfaits et des paramètres opérationnels.',
|
||||||
|
badgeClass: 'bg-indigo-500/15 text-indigo-300 border-indigo-500/30',
|
||||||
|
iconBgClass: 'from-indigo-600 to-blue-600',
|
||||||
|
defaultTabs: [
|
||||||
|
'dashboard',
|
||||||
|
'users',
|
||||||
|
'metrics',
|
||||||
|
'comments',
|
||||||
|
'moderation',
|
||||||
|
'actualites',
|
||||||
|
'plans',
|
||||||
|
'taxonomies',
|
||||||
|
'countries',
|
||||||
|
'slides',
|
||||||
|
'legal',
|
||||||
|
'settings',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
MODERATOR: {
|
||||||
|
id: 'MODERATOR',
|
||||||
|
label: 'Modérateur',
|
||||||
|
badgeLabel: 'Modérateur',
|
||||||
|
description: 'Contrôle des commentaires, gestion des signalements, avis et suspension de profils litigieux.',
|
||||||
|
badgeClass: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
|
||||||
|
iconBgClass: 'from-amber-600 to-orange-600',
|
||||||
|
defaultTabs: [
|
||||||
|
'dashboard',
|
||||||
|
'comments',
|
||||||
|
'moderation',
|
||||||
|
'users',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
EDITOR: {
|
||||||
|
id: 'EDITOR',
|
||||||
|
label: 'Contributeur / Rédacteur',
|
||||||
|
badgeLabel: 'Contributeur',
|
||||||
|
description: 'Gestion éditoriale : rédaction des articles de blog, interviews AfroLife, événements et bannières.',
|
||||||
|
badgeClass: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
|
||||||
|
iconBgClass: 'from-emerald-600 to-teal-600',
|
||||||
|
defaultTabs: [
|
||||||
|
'dashboard',
|
||||||
|
'actualites',
|
||||||
|
'slides',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROLE_DEFAULT_PERMISSIONS: Record<AdminRoleType, string[]> = {
|
||||||
|
SUPER_ADMIN: ADMIN_ROLES.SUPER_ADMIN.defaultTabs,
|
||||||
|
ADMIN: ADMIN_ROLES.ADMIN.defaultTabs,
|
||||||
|
MODERATOR: ADMIN_ROLES.MODERATOR.defaultTabs,
|
||||||
|
EDITOR: ADMIN_ROLES.EDITOR.defaultTabs,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ADMIN_TABS: Record<string, AdminTabDefinition> = {
|
||||||
|
dashboard: {
|
||||||
|
id: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
href: '/dashboard',
|
||||||
|
iconName: 'LayoutDashboard',
|
||||||
|
description: "Vue d'ensemble et statistiques clés",
|
||||||
|
routes: ['/dashboard'],
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
id: 'team',
|
||||||
|
name: 'Équipe & Rôles',
|
||||||
|
href: '/team',
|
||||||
|
iconName: 'ShieldAlert',
|
||||||
|
description: 'Gestion des comptes staff et attribution des permissions (Super Admin)',
|
||||||
|
routes: ['/team'],
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
id: 'users',
|
||||||
|
name: 'Utilisateurs & Entreprises',
|
||||||
|
href: '/users',
|
||||||
|
iconName: 'Users',
|
||||||
|
description: 'Validation, consultation et suivi des entrepreneurs et entreprises',
|
||||||
|
routes: ['/users'],
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
id: 'metrics',
|
||||||
|
name: 'Metrics & Analytics',
|
||||||
|
href: '/metrics',
|
||||||
|
iconName: 'BarChart3',
|
||||||
|
description: 'Trafic, appareils, navigateurs et géolocalisation des visiteurs',
|
||||||
|
routes: ['/metrics'],
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
id: 'comments',
|
||||||
|
name: 'Commentaires',
|
||||||
|
href: '/comments',
|
||||||
|
iconName: 'MessageSquare',
|
||||||
|
description: 'Modération des avis et commentaires publics',
|
||||||
|
routes: ['/comments'],
|
||||||
|
},
|
||||||
|
moderation: {
|
||||||
|
id: 'moderation',
|
||||||
|
name: 'Modération',
|
||||||
|
href: '/moderation',
|
||||||
|
iconName: 'Flag',
|
||||||
|
description: 'Traitement des signalements et des avis soumis',
|
||||||
|
routes: ['/moderation'],
|
||||||
|
},
|
||||||
|
actualites: {
|
||||||
|
id: 'actualites',
|
||||||
|
name: 'Actualités & Interviews',
|
||||||
|
href: '/actualites',
|
||||||
|
iconName: 'BookOpen',
|
||||||
|
description: 'Articles de blog, interviews AfroLife et événements',
|
||||||
|
routes: ['/actualites', '/interview', '/event'],
|
||||||
|
},
|
||||||
|
plans: {
|
||||||
|
id: 'plans',
|
||||||
|
name: 'Forfaits & Services',
|
||||||
|
href: '/plans',
|
||||||
|
iconName: 'CreditCard',
|
||||||
|
description: 'Configuration des abonnements et options de visibilité',
|
||||||
|
routes: ['/plans'],
|
||||||
|
},
|
||||||
|
taxonomies: {
|
||||||
|
id: 'taxonomies',
|
||||||
|
name: 'Taxonomies',
|
||||||
|
href: '/taxonomies',
|
||||||
|
iconName: 'Tags',
|
||||||
|
description: "Gestion des catégories et secteurs d'activité",
|
||||||
|
routes: ['/taxonomies'],
|
||||||
|
},
|
||||||
|
countries: {
|
||||||
|
id: 'countries',
|
||||||
|
name: 'Pays',
|
||||||
|
href: '/countries',
|
||||||
|
iconName: 'Globe',
|
||||||
|
description: 'Gestion des pays africains et internationaux',
|
||||||
|
routes: ['/countries'],
|
||||||
|
},
|
||||||
|
slides: {
|
||||||
|
id: 'slides',
|
||||||
|
name: 'Bannière & Slides',
|
||||||
|
href: '/slides',
|
||||||
|
iconName: 'Image',
|
||||||
|
description: 'Carrousel d’accueil et mises en avant visuelles',
|
||||||
|
routes: ['/slides'],
|
||||||
|
},
|
||||||
|
legal: {
|
||||||
|
id: 'legal',
|
||||||
|
name: 'Documents Légaux',
|
||||||
|
href: '/legal',
|
||||||
|
iconName: 'FileText',
|
||||||
|
description: 'Mentions légales, CGU et politiques de confidentialité',
|
||||||
|
routes: ['/legal'],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
id: 'settings',
|
||||||
|
name: 'Configuration',
|
||||||
|
href: '/settings',
|
||||||
|
iconName: 'Settings',
|
||||||
|
description: 'Paramètres système et modèles de courriels',
|
||||||
|
routes: ['/settings'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the matching tab identifier for a given pathname (e.g. /actualites/new -> actualites).
|
||||||
|
*/
|
||||||
|
export function getTabForPathname(pathname: string): string | null {
|
||||||
|
for (const [tabKey, tab] of Object.entries(ADMIN_TABS)) {
|
||||||
|
for (const route of tab.routes) {
|
||||||
|
if (pathname === route || pathname.startsWith(route + '/')) {
|
||||||
|
return tabKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a user with given adminRole and custom permissions has access to a tab.
|
||||||
|
*/
|
||||||
|
export function hasTabPermission(
|
||||||
|
adminRole: string = 'ADMIN',
|
||||||
|
customPermissions: string[] = [],
|
||||||
|
tabKey: string
|
||||||
|
): boolean {
|
||||||
|
// 1. Super Admin has unrestricted access to everything
|
||||||
|
if (adminRole === 'SUPER_ADMIN') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The team management tab is strictly reserved for Super Admin
|
||||||
|
if (tabKey === 'team') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If custom permissions are explicitly defined, respect them
|
||||||
|
if (customPermissions && customPermissions.length > 0) {
|
||||||
|
return customPermissions.includes(tabKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Otherwise fallback to the predefined role defaults
|
||||||
|
const roleConfig = ADMIN_ROLES[adminRole as AdminRoleType];
|
||||||
|
if (roleConfig) {
|
||||||
|
return roleConfig.defaultTabs.includes(tabKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback: only dashboard
|
||||||
|
return tabKey === 'dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of all tab keys accessible to a given role and permissions.
|
||||||
|
*/
|
||||||
|
export function getAllowedTabs(
|
||||||
|
adminRole: string = 'ADMIN',
|
||||||
|
customPermissions: string[] = []
|
||||||
|
): string[] {
|
||||||
|
if (adminRole === 'SUPER_ADMIN') {
|
||||||
|
return Object.keys(ADMIN_TABS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customPermissions && customPermissions.length > 0) {
|
||||||
|
// team can never be accessed by non-super admin
|
||||||
|
return customPermissions.filter(t => t !== 'team');
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleConfig = ADMIN_ROLES[adminRole as AdminRoleType];
|
||||||
|
if (roleConfig) {
|
||||||
|
return roleConfig.defaultTabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['dashboard'];
|
||||||
|
}
|
||||||
207
admin/src/lib/session.ts
Normal file
207
admin/src/lib/session.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export const ADMIN_COOKIE_NAME = 'afro_admin_session';
|
||||||
|
export const DEFAULT_SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds
|
||||||
|
export const REMEMBER_ME_DURATION = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||||
|
|
||||||
|
export interface AdminSessionPayload {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
adminRole: string;
|
||||||
|
permissions: string[];
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JWT_SECRET =
|
||||||
|
process.env.ADMIN_JWT_SECRET ||
|
||||||
|
process.env.NEXTAUTH_SECRET ||
|
||||||
|
'afrohub_admin_secure_jwt_token_secret_key_2025_9876543210';
|
||||||
|
|
||||||
|
function base64UrlEncode(str: string): string {
|
||||||
|
if (typeof Buffer !== 'undefined') {
|
||||||
|
return Buffer.from(str, 'utf-8').toString('base64url');
|
||||||
|
}
|
||||||
|
const bytes = new TextEncoder().encode(str);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlDecode(str: string): string {
|
||||||
|
if (typeof Buffer !== 'undefined') {
|
||||||
|
return Buffer.from(str, 'base64url').toString('utf-8');
|
||||||
|
}
|
||||||
|
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
while (base64.length % 4) {
|
||||||
|
base64 += '=';
|
||||||
|
}
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCryptoKey(): Promise<CryptoKey> {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
return await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
enc.encode(JWT_SECRET),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign', 'verify']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and cryptographically signs a session token using Web Crypto HMAC-SHA256.
|
||||||
|
* Safe for both Edge Middleware and Node.js Server runtimes.
|
||||||
|
*/
|
||||||
|
export async function signAdminToken(
|
||||||
|
payload: Omit<AdminSessionPayload, 'exp' | 'iat'>,
|
||||||
|
expiresInSeconds: number = DEFAULT_SESSION_DURATION
|
||||||
|
): Promise<string> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const fullPayload: AdminSessionPayload = {
|
||||||
|
...payload,
|
||||||
|
iat: now,
|
||||||
|
exp: now + expiresInSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
||||||
|
const payloadB64 = base64UrlEncode(JSON.stringify(fullPayload));
|
||||||
|
const dataToSign = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const key = await getCryptoKey();
|
||||||
|
const signatureBuffer = await crypto.subtle.sign(
|
||||||
|
'HMAC',
|
||||||
|
key,
|
||||||
|
new TextEncoder().encode(dataToSign)
|
||||||
|
);
|
||||||
|
|
||||||
|
const signatureB64 = arrayBufferToBase64Url(signatureBuffer);
|
||||||
|
return `${dataToSign}.${signatureB64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a token's HMAC-SHA256 signature, expiry, and ADMIN role.
|
||||||
|
* Returns null if invalid or expired.
|
||||||
|
*/
|
||||||
|
export async function verifyAdminToken(token?: string | null): Promise<AdminSessionPayload | null> {
|
||||||
|
if (!token || typeof token !== 'string') return null;
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
|
const dataToSign = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = await getCryptoKey();
|
||||||
|
|
||||||
|
let base64 = signatureB64.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
while (base64.length % 4) {
|
||||||
|
base64 += '=';
|
||||||
|
}
|
||||||
|
const binary = atob(base64);
|
||||||
|
const signatureBytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
signatureBytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await crypto.subtle.verify(
|
||||||
|
'HMAC',
|
||||||
|
key,
|
||||||
|
signatureBytes,
|
||||||
|
new TextEncoder().encode(dataToSign)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) return null;
|
||||||
|
|
||||||
|
const payloadJson = base64UrlDecode(payloadB64);
|
||||||
|
const payload = JSON.parse(payloadJson) as AdminSessionPayload;
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (payload.exp && payload.exp < now) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.role !== 'ADMIN') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee that admin@afrohub.com always has SUPER_ADMIN role
|
||||||
|
if (payload.email === 'admin@afrohub.com' || payload.adminRole === 'SUPER_ADMIN') {
|
||||||
|
payload.adminRole = 'SUPER_ADMIN';
|
||||||
|
} else if (!payload.adminRole) {
|
||||||
|
payload.adminRole = 'ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.permissions) {
|
||||||
|
payload.permissions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the admin session cookie from next/headers and verifies it.
|
||||||
|
*/
|
||||||
|
export async function getAdminSession(): Promise<AdminSessionPayload | null> {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(ADMIN_COOKIE_NAME)?.value;
|
||||||
|
return await verifyAdminToken(token);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the admin session HTTP-only cookie.
|
||||||
|
*/
|
||||||
|
export async function setAdminSessionCookie(
|
||||||
|
token: string,
|
||||||
|
maxAgeSeconds: number = DEFAULT_SESSION_DURATION
|
||||||
|
): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(ADMIN_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: maxAgeSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the admin session cookie.
|
||||||
|
*/
|
||||||
|
export async function clearAdminSessionCookie(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(ADMIN_COOKIE_NAME, '', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
79
admin/src/middleware.ts
Normal file
79
admin/src/middleware.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { verifyAdminToken, ADMIN_COOKIE_NAME } from '@/lib/session';
|
||||||
|
import { getTabForPathname, hasTabPermission } from '@/lib/permissions';
|
||||||
|
|
||||||
|
// Public endpoints and static resources
|
||||||
|
const PUBLIC_PREFIXES = [
|
||||||
|
'/login',
|
||||||
|
'/api/cron',
|
||||||
|
'/api/external',
|
||||||
|
'/_next',
|
||||||
|
'/favicon.ico',
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const { pathname, search } = request.nextUrl;
|
||||||
|
|
||||||
|
// 1. Bypass static files and Next.js internal assets
|
||||||
|
if (
|
||||||
|
pathname.startsWith('/_next') ||
|
||||||
|
pathname.includes('.') // any file with an extension (e.g. favicon.ico, images, robots.txt)
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Read session cookie
|
||||||
|
const token = request.cookies.get(ADMIN_COOKIE_NAME)?.value;
|
||||||
|
const session = await verifyAdminToken(token);
|
||||||
|
|
||||||
|
const isLoginPage = pathname === '/login';
|
||||||
|
|
||||||
|
// 3. If accessing /login
|
||||||
|
if (isLoginPage) {
|
||||||
|
// If already logged in, redirect directly to dashboard
|
||||||
|
if (session) {
|
||||||
|
return NextResponse.redirect(new URL('/dashboard', request.url));
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. If accessing other public APIs (like cron or external integrations)
|
||||||
|
const isPublicPrefix = PUBLIC_PREFIXES.some(prefix => pathname.startsWith(prefix));
|
||||||
|
if (isPublicPrefix) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Protected route: redirect to /login if unauthenticated
|
||||||
|
if (!session) {
|
||||||
|
const loginUrl = new URL('/login', request.url);
|
||||||
|
const returnUrl = pathname + (search || '');
|
||||||
|
if (pathname !== '/' && pathname !== '') {
|
||||||
|
loginUrl.searchParams.set('from', returnUrl);
|
||||||
|
}
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Check granular tab permission
|
||||||
|
const tabKey = getTabForPathname(pathname);
|
||||||
|
if (tabKey && !hasTabPermission(session.adminRole, session.permissions, tabKey)) {
|
||||||
|
// If user lacks permission for this tab, redirect back to /dashboard
|
||||||
|
const dashboardUrl = new URL('/dashboard', request.url);
|
||||||
|
dashboardUrl.searchParams.set('unauthorized', tabKey);
|
||||||
|
return NextResponse.redirect(dashboardUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except:
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
*/
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -40,6 +40,8 @@ model User {
|
|||||||
deletionScheduledAt DateTime?
|
deletionScheduledAt DateTime?
|
||||||
deletionReason String?
|
deletionReason String?
|
||||||
reactivationReason String?
|
reactivationReason String?
|
||||||
|
adminRole String? @default("ADMIN")
|
||||||
|
adminPermissions String[] @default([])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Business {
|
model Business {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ async function main() {
|
|||||||
email: 'admin@afrohub.com',
|
email: 'admin@afrohub.com',
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
role: UserRole.ADMIN,
|
role: UserRole.ADMIN,
|
||||||
|
adminRole: 'SUPER_ADMIN',
|
||||||
|
adminPermissions: [],
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user