feat: add admin dashboard and core API routes with Docker configuration
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache libc6-compat openssl
|
||||
FROM node:22.23.4-alpine
|
||||
RUN apk update && apk upgrade --no-cache && apk add --no-cache libc6-compat openssl
|
||||
WORKDIR /app
|
||||
ENV PATH /app/node_modules/.bin:$PATH
|
||||
|
||||
# Copie et installation
|
||||
COPY . .
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
@@ -21,5 +21,5 @@ USER nextjs
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=5 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1
|
||||
|
||||
# On lance directement via npm (avec push de la BD si besoin, ou juste start)
|
||||
CMD ["sh", "-c", "npx prisma db push --schema=prisma/schema.prisma --accept-data-loss && (node ../scripts/ensure-admin.cjs || node scripts/ensure-admin.cjs || true) && npm start"]
|
||||
# On lance les migrations sans perte de données puis le serveur
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy --schema=prisma/schema.prisma && (node ../scripts/ensure-admin.cjs || node scripts/ensure-admin.cjs || true) && npm start"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "path";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// output: 'standalone',
|
||||
allowedDevOrigins: ['10.0.2.2'],
|
||||
@@ -10,6 +10,33 @@ const nextConfig: NextConfig = {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
// 2. En-têtes de sécurité HTTP
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'DENY',
|
||||
},
|
||||
{
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff',
|
||||
},
|
||||
{
|
||||
key: 'Referrer-Policy',
|
||||
value: 'strict-origin-when-cross-origin',
|
||||
},
|
||||
{
|
||||
key: 'Permissions-Policy',
|
||||
value: 'camera=(), microphone=(), geolocation=()',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// 3. (Optionnel) Pour faciliter le chargement des images si tu en as
|
||||
images: {
|
||||
remotePatterns: [
|
||||
@@ -21,4 +48,4 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
[phases.setup]
|
||||
# Ce hash force Nixpacks à utiliser la version contenant Node 22.13.1
|
||||
nixpkgsArchive = "51ad838b03a05b1de6f9f2a0fffecee64a9788ee"
|
||||
nixPkgs = ["nodejs_22", "openssl"]
|
||||
|
||||
[phases.install]
|
||||
@@ -10,4 +8,4 @@ cmds = ["npm ci"]
|
||||
cmds = ["npx prisma generate", "npm run build"]
|
||||
|
||||
[start]
|
||||
cmd = "npx prisma db push --accept-data-loss && (node ../scripts/ensure-admin.cjs || node scripts/ensure-admin.cjs || true) && npm start"
|
||||
cmd = "npx prisma migrate deploy && (node ../scripts/ensure-admin.cjs || node scripts/ensure-admin.cjs || true) && npm start"
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"name": "admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.23.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate --schema=../prisma/schema.prisma && next build",
|
||||
@@ -10,13 +13,13 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"afrohub": "file:..",
|
||||
"@prisma/client": "6.19.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dotenv": "^17.4.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "16.2.3",
|
||||
"next": "^16.4.3",
|
||||
"nodemailer": "^10.3.1",
|
||||
"pg": "^8.20.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -29,14 +32,15 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"eslint-config-next": "^16.4.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { generateSlug } from "@/lib/utils";
|
||||
import { ContentStatus } from "@prisma/client";
|
||||
import { requireTabPermission } from "@/lib/auth";
|
||||
|
||||
export async function createBlogPost(data: {
|
||||
title: string;
|
||||
@@ -22,6 +23,7 @@ export async function createBlogPost(data: {
|
||||
sources?: any;
|
||||
}) {
|
||||
try {
|
||||
await requireTabPermission("actualites");
|
||||
const slug = data.slug || generateSlug(data.title);
|
||||
|
||||
const post = await prisma.blogPost.create({
|
||||
@@ -59,6 +61,7 @@ export async function updateBlogPost(id: string, data: {
|
||||
sources?: any;
|
||||
}) {
|
||||
try {
|
||||
await requireTabPermission("actualites");
|
||||
const slug = data.slug || generateSlug(data.title);
|
||||
|
||||
const post = await prisma.blogPost.update({
|
||||
@@ -80,6 +83,7 @@ export async function updateBlogPost(id: string, data: {
|
||||
|
||||
export async function deleteBlogPost(id: string) {
|
||||
try {
|
||||
await requireTabPermission("actualites");
|
||||
await prisma.blogPost.delete({
|
||||
where: { id },
|
||||
});
|
||||
@@ -94,6 +98,7 @@ export async function deleteBlogPost(id: string) {
|
||||
|
||||
export async function getBlogPosts() {
|
||||
try {
|
||||
await requireTabPermission("actualites");
|
||||
return await prisma.blogPost.findMany({
|
||||
orderBy: { date: 'desc' }
|
||||
});
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { ContentStatus } from "@prisma/client";
|
||||
import { requireTabPermission } from "@/lib/auth";
|
||||
|
||||
export async function approveBlogPost(id: string) {
|
||||
try {
|
||||
await requireTabPermission("actualites");
|
||||
await prisma.blogPost.update({
|
||||
where: { id },
|
||||
data: { status: ContentStatus.PUBLISHED }
|
||||
@@ -21,6 +23,7 @@ export async function approveBlogPost(id: string) {
|
||||
|
||||
export async function approveEvent(id: string) {
|
||||
try {
|
||||
await requireTabPermission("actualites");
|
||||
await prisma.event.update({
|
||||
where: { id },
|
||||
data: { status: ContentStatus.PUBLISHED }
|
||||
@@ -36,7 +39,7 @@ export async function approveEvent(id: string) {
|
||||
|
||||
export async function rejectBlogPost(id: string) {
|
||||
try {
|
||||
// We could delete or archive it. Let's archive it.
|
||||
await requireTabPermission("actualites");
|
||||
await prisma.blogPost.update({
|
||||
where: { id },
|
||||
data: { status: ContentStatus.ARCHIVED }
|
||||
@@ -51,6 +54,7 @@ export async function rejectBlogPost(id: string) {
|
||||
|
||||
export async function rejectEvent(id: string) {
|
||||
try {
|
||||
await requireTabPermission("actualites");
|
||||
await prisma.event.update({
|
||||
where: { id },
|
||||
data: { status: ContentStatus.ARCHIVED }
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireAdminSession, requireTabPermission } from "@/lib/auth";
|
||||
|
||||
export async function toggleVerification(id: string, currentStatus: boolean) {
|
||||
try {
|
||||
await requireTabPermission("users");
|
||||
await prisma.business.update({
|
||||
where: { id },
|
||||
data: { verified: !currentStatus },
|
||||
@@ -24,6 +26,7 @@ export async function setFeaturedBusiness(id: string, data: {
|
||||
keyMetric: string;
|
||||
}) {
|
||||
try {
|
||||
await requireTabPermission("users");
|
||||
// 1. Réinitialiser les autres pour n'avoir qu'une seule Tête d'affiche Afroshine active
|
||||
await prisma.business.updateMany({
|
||||
where: { isFeatured: true },
|
||||
@@ -53,6 +56,7 @@ export async function setFeaturedBusiness(id: string, data: {
|
||||
|
||||
export async function toggleHomeFeatured(id: string, currentStatus: boolean) {
|
||||
try {
|
||||
await requireTabPermission("users");
|
||||
await prisma.business.update({
|
||||
where: { id },
|
||||
data: { isHomeFeatured: !currentStatus },
|
||||
@@ -68,6 +72,7 @@ export async function toggleHomeFeatured(id: string, currentStatus: boolean) {
|
||||
|
||||
export async function removeFeaturedBusiness(id: string) {
|
||||
try {
|
||||
await requireTabPermission("users");
|
||||
await prisma.business.update({
|
||||
where: { id },
|
||||
data: { isFeatured: false },
|
||||
@@ -83,6 +88,7 @@ export async function removeFeaturedBusiness(id: string) {
|
||||
|
||||
export async function getPendingVerificationCount() {
|
||||
try {
|
||||
await requireAdminSession();
|
||||
return await prisma.business.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
@@ -98,11 +104,13 @@ export async function getPendingVerificationCount() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBusinessSeo(id: string, data: {
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
}) {
|
||||
try {
|
||||
await requireTabPermission("users");
|
||||
await prisma.business.update({
|
||||
where: { id },
|
||||
data: {
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireAdminSession, requireTabPermission } from "@/lib/auth";
|
||||
|
||||
export async function getCategories() {
|
||||
try {
|
||||
await requireAdminSession();
|
||||
return await prisma.businessCategory.findMany({
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
@@ -16,6 +18,7 @@ export async function getCategories() {
|
||||
|
||||
export async function createCategory(data: { name: string; slug: string; icon?: string; suggestionId?: string }) {
|
||||
try {
|
||||
await requireTabPermission("taxonomies");
|
||||
const { suggestionId, ...categoryData } = data;
|
||||
|
||||
const category = await prisma.businessCategory.create({
|
||||
@@ -46,6 +49,7 @@ export async function createCategory(data: { name: string; slug: string; icon?:
|
||||
|
||||
export async function updateCategory(id: string, data: { name: string; slug: string; icon?: string; isActive: boolean }) {
|
||||
try {
|
||||
await requireTabPermission("taxonomies");
|
||||
// 1. Récupérer l'ancien nom pour la migration
|
||||
const oldCategory = await prisma.businessCategory.findUnique({
|
||||
where: { id },
|
||||
@@ -77,6 +81,7 @@ export async function updateCategory(id: string, data: { name: string; slug: str
|
||||
|
||||
export async function deleteCategory(id: string) {
|
||||
try {
|
||||
await requireTabPermission("taxonomies");
|
||||
await prisma.businessCategory.delete({
|
||||
where: { id }
|
||||
});
|
||||
@@ -91,6 +96,7 @@ export async function deleteCategory(id: string) {
|
||||
|
||||
export async function getSuggestedCategories() {
|
||||
try {
|
||||
await requireAdminSession();
|
||||
return await prisma.business.findMany({
|
||||
where: {
|
||||
suggestedCategory: { not: null },
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireTabPermission } from "@/lib/auth";
|
||||
|
||||
export async function deleteComment(id: string) {
|
||||
try {
|
||||
await requireTabPermission("comments");
|
||||
await prisma.comment.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { requireAdminSession, requireTabPermission } from '@/lib/auth';
|
||||
|
||||
export async function getCountries() {
|
||||
try {
|
||||
await requireAdminSession();
|
||||
return await prisma.country.findMany({
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
@@ -16,6 +18,7 @@ export async function getCountries() {
|
||||
|
||||
export async function addCountry(data: { name: string, code: string, flag?: string, description?: string }) {
|
||||
try {
|
||||
await requireTabPermission('countries');
|
||||
const country = await prisma.country.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
@@ -35,6 +38,7 @@ export async function addCountry(data: { name: string, code: string, flag?: stri
|
||||
|
||||
export async function deleteCountry(id: string) {
|
||||
try {
|
||||
await requireTabPermission('countries');
|
||||
await prisma.country.delete({
|
||||
where: { id }
|
||||
});
|
||||
@@ -48,6 +52,7 @@ export async function deleteCountry(id: string) {
|
||||
|
||||
export async function toggleCountryStatus(id: string, isActive: boolean) {
|
||||
try {
|
||||
await requireTabPermission('countries');
|
||||
await prisma.country.update({
|
||||
where: { id },
|
||||
data: { isActive }
|
||||
@@ -62,6 +67,7 @@ export async function toggleCountryStatus(id: string, isActive: boolean) {
|
||||
|
||||
export async function updateCountryDescription(id: string, description: string) {
|
||||
try {
|
||||
await requireTabPermission('countries');
|
||||
await prisma.country.update({
|
||||
where: { id },
|
||||
data: { description }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { generateSlug } from "@/lib/utils";
|
||||
import { ContentStatus } from "@prisma/client";
|
||||
import { requireTabPermission } from "@/lib/auth";
|
||||
|
||||
export async function createEvent(data: {
|
||||
title: string;
|
||||
@@ -22,6 +23,7 @@ export async function createEvent(data: {
|
||||
coverZoom?: number;
|
||||
}) {
|
||||
try {
|
||||
await requireTabPermission("actualites");
|
||||
const slug = data.slug || generateSlug(data.title);
|
||||
|
||||
const event = await prisma.event.create({
|
||||
@@ -59,6 +61,7 @@ export async function updateEvent(id: string, data: {
|
||||
coverZoom?: number;
|
||||
}) {
|
||||
try {
|
||||
await requireTabPermission("actualites");
|
||||
const slug = data.slug || generateSlug(data.title);
|
||||
|
||||
const event = await prisma.event.update({
|
||||
@@ -80,6 +83,7 @@ export async function updateEvent(id: string, data: {
|
||||
|
||||
export async function deleteEvent(id: string) {
|
||||
try {
|
||||
await requireTabPermission("actualites");
|
||||
await prisma.event.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { InterviewType, ContentStatus } from "@prisma/client";
|
||||
import { generateSlug } from "@/lib/utils";
|
||||
import { requireTabPermission } from "@/lib/auth";
|
||||
|
||||
export async function createInterview(data: {
|
||||
title: string;
|
||||
@@ -24,6 +25,7 @@ export async function createInterview(data: {
|
||||
status?: ContentStatus;
|
||||
}) {
|
||||
try {
|
||||
await requireTabPermission("actualites");
|
||||
const slug = data.slug || generateSlug(data.title);
|
||||
|
||||
const interview = await prisma.interview.create({
|
||||
@@ -62,6 +64,7 @@ export async function updateInterview(id: string, data: {
|
||||
status?: ContentStatus;
|
||||
}) {
|
||||
try {
|
||||
await requireTabPermission("actualites");
|
||||
const slug = data.slug || generateSlug(data.title);
|
||||
|
||||
const interview = await prisma.interview.update({
|
||||
@@ -82,6 +85,7 @@ export async function updateInterview(id: string, data: {
|
||||
|
||||
export async function deleteInterview(id: string) {
|
||||
try {
|
||||
await requireTabPermission("actualites");
|
||||
await prisma.interview.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { requireAdminSession, requireTabPermission } from '@/lib/auth';
|
||||
|
||||
export async function getLegalDocument(type: 'CGU' | 'CGV') {
|
||||
try {
|
||||
await requireAdminSession();
|
||||
return await prisma.legalDocument.findUnique({
|
||||
where: { type }
|
||||
});
|
||||
@@ -16,6 +18,7 @@ export async function getLegalDocument(type: 'CGU' | 'CGV') {
|
||||
|
||||
export async function updateLegalDocument(type: 'CGU' | 'CGV', title: string, content: string) {
|
||||
try {
|
||||
await requireTabPermission('legal');
|
||||
const doc = await prisma.legalDocument.upsert({
|
||||
where: { type },
|
||||
update: { title, content },
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { requireTabPermission } from '@/lib/auth';
|
||||
|
||||
export async function getReports() {
|
||||
await requireTabPermission('moderation');
|
||||
return await prisma.messageReport.findMany({
|
||||
include: {
|
||||
message: {
|
||||
@@ -40,6 +42,7 @@ export async function getReports() {
|
||||
|
||||
export async function updateReportStatus(id: string, status: 'RESOLVED' | 'DISMISSED') {
|
||||
try {
|
||||
await requireTabPermission('moderation');
|
||||
await prisma.messageReport.update({
|
||||
where: { id },
|
||||
data: { status }
|
||||
@@ -52,6 +55,7 @@ export async function updateReportStatus(id: string, status: 'RESOLVED' | 'DISMI
|
||||
}
|
||||
|
||||
export async function getPendingRatings() {
|
||||
await requireTabPermission('moderation');
|
||||
return await prisma.rating.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
include: {
|
||||
@@ -68,6 +72,7 @@ export async function getPendingRatings() {
|
||||
|
||||
export async function updateRatingStatus(id: string, status: 'APPROVED' | 'REJECTED') {
|
||||
try {
|
||||
await requireTabPermission('moderation');
|
||||
const updated = await prisma.rating.update({
|
||||
where: { id },
|
||||
data: { status }
|
||||
@@ -98,6 +103,7 @@ export async function updateRatingStatus(id: string, status: 'APPROVED' | 'REJEC
|
||||
|
||||
export async function deleteRating(id: string) {
|
||||
try {
|
||||
await requireTabPermission('moderation');
|
||||
const rating = await prisma.rating.findUnique({
|
||||
where: { id },
|
||||
select: { businessId: true }
|
||||
@@ -129,4 +135,3 @@ export async function deleteRating(id: string) {
|
||||
return { success: false, error: "Erreur lors de la suppression de l'avis" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { OneShotType } from "@prisma/client";
|
||||
import { requireAdminSession, requireTabPermission } from "@/lib/auth";
|
||||
|
||||
export async function getOneShotPlans() {
|
||||
try {
|
||||
await requireAdminSession();
|
||||
return await prisma.oneShotPlan.findMany({
|
||||
orderBy: { type: 'asc' }
|
||||
});
|
||||
@@ -22,6 +24,7 @@ export async function updateOneShotPlan(type: OneShotType, data: {
|
||||
description?: string;
|
||||
}) {
|
||||
try {
|
||||
await requireTabPermission("plans");
|
||||
await prisma.oneShotPlan.upsert({
|
||||
where: { type },
|
||||
update: data,
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { requireAdminSession, requireTabPermission } from '@/lib/auth';
|
||||
import { hasTabPermission } from '@/lib/permissions';
|
||||
|
||||
export async function getPricingPlans() {
|
||||
try {
|
||||
await requireAdminSession();
|
||||
return await prisma.pricingPlan.findMany({
|
||||
orderBy: { offerLimit: 'asc' }
|
||||
});
|
||||
@@ -16,6 +19,7 @@ export async function getPricingPlans() {
|
||||
|
||||
export async function updatePricingPlanDetail(id: string, data: any) {
|
||||
try {
|
||||
await requireTabPermission('plans');
|
||||
await prisma.pricingPlan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -42,6 +46,14 @@ export async function updatePricingPlanDetail(id: string, data: any) {
|
||||
|
||||
export async function updateBusinessPlan(id: string, plan: 'STARTER' | 'BOOSTER' | 'EMPIRE') {
|
||||
try {
|
||||
const session = await requireAdminSession();
|
||||
if (
|
||||
!hasTabPermission(session.adminRole, session.permissions, 'users') &&
|
||||
!hasTabPermission(session.adminRole, session.permissions, 'plans')
|
||||
) {
|
||||
return { success: false, error: 'Permission refusée' };
|
||||
}
|
||||
|
||||
await prisma.business.update({
|
||||
where: { id },
|
||||
data: { plan }
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { requireAdminSession, requireTabPermission } from '@/lib/auth';
|
||||
|
||||
export async function getSiteSettings() {
|
||||
try {
|
||||
await requireAdminSession();
|
||||
const settings = await prisma.siteSetting.findUnique({
|
||||
where: { id: 'singleton' }
|
||||
});
|
||||
@@ -66,6 +68,7 @@ export async function getSiteSettings() {
|
||||
|
||||
export async function updateSiteSettings(data: any) {
|
||||
try {
|
||||
await requireTabPermission('settings');
|
||||
const settings = await prisma.siteSetting.upsert({
|
||||
where: { id: 'singleton' },
|
||||
update: data,
|
||||
@@ -86,5 +89,6 @@ export async function updateSiteSettings(data: any) {
|
||||
}
|
||||
|
||||
export async function getExternalApiKey() {
|
||||
return process.env.EXTERNAL_API_KEY || "ezfzeFZEfztZEgZEFzETGZEGZERZERF";
|
||||
await requireTabPermission('settings');
|
||||
return process.env.EXTERNAL_API_KEY || "Non configurée (définir EXTERNAL_API_KEY)";
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireTabPermission } from "@/lib/auth";
|
||||
|
||||
export async function getSlides() {
|
||||
try {
|
||||
await requireTabPermission("slides");
|
||||
return await prisma.homeSlide.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
include: { business: true }
|
||||
@@ -17,6 +19,7 @@ export async function getSlides() {
|
||||
|
||||
export async function createSlide(data: any) {
|
||||
try {
|
||||
await requireTabPermission("slides");
|
||||
await prisma.homeSlide.create({
|
||||
data: {
|
||||
type: data.type,
|
||||
@@ -41,6 +44,7 @@ export async function createSlide(data: any) {
|
||||
|
||||
export async function updateSlide(id: string, data: any) {
|
||||
try {
|
||||
await requireTabPermission("slides");
|
||||
await prisma.homeSlide.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -66,6 +70,7 @@ export async function updateSlide(id: string, data: any) {
|
||||
|
||||
export async function deleteSlide(id: string) {
|
||||
try {
|
||||
await requireTabPermission("slides");
|
||||
await prisma.homeSlide.delete({
|
||||
where: { id }
|
||||
});
|
||||
@@ -79,6 +84,7 @@ export async function deleteSlide(id: string) {
|
||||
|
||||
export async function toggleSlideStatus(id: string, currentStatus: boolean) {
|
||||
try {
|
||||
await requireTabPermission("slides");
|
||||
await prisma.homeSlide.update({
|
||||
where: { id },
|
||||
data: { isActive: !currentStatus }
|
||||
@@ -93,6 +99,7 @@ export async function toggleSlideStatus(id: string, currentStatus: boolean) {
|
||||
|
||||
export async function searchBusinesses(query: string) {
|
||||
try {
|
||||
await requireTabPermission("slides");
|
||||
return await prisma.business.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
|
||||
@@ -2,20 +2,23 @@
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireTabPermission, ADMIN_SAFE_USER_SELECT } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Suspend a user account
|
||||
*/
|
||||
export async function suspendUser(userId: string, reason: string) {
|
||||
try {
|
||||
await requireTabPermission("users");
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
isSuspended: true,
|
||||
suspensionReason: reason,
|
||||
},
|
||||
include: {
|
||||
businesses: true
|
||||
select: {
|
||||
...ADMIN_SAFE_USER_SELECT,
|
||||
businesses: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,14 +50,16 @@ export async function suspendUser(userId: string, reason: string) {
|
||||
*/
|
||||
export async function unsuspendUser(userId: string) {
|
||||
try {
|
||||
await requireTabPermission("users");
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
isSuspended: false,
|
||||
suspensionReason: null,
|
||||
},
|
||||
include: {
|
||||
businesses: true
|
||||
select: {
|
||||
...ADMIN_SAFE_USER_SELECT,
|
||||
businesses: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -85,6 +90,7 @@ export async function unsuspendUser(userId: string) {
|
||||
*/
|
||||
export async function suspendBusiness(businessId: string, reason: string) {
|
||||
try {
|
||||
await requireTabPermission("users");
|
||||
const business = await prisma.business.update({
|
||||
where: { id: businessId },
|
||||
data: {
|
||||
@@ -107,6 +113,7 @@ export async function suspendBusiness(businessId: string, reason: string) {
|
||||
*/
|
||||
export async function unsuspendBusiness(businessId: string) {
|
||||
try {
|
||||
await requireTabPermission("users");
|
||||
const business = await prisma.business.update({
|
||||
where: { id: businessId },
|
||||
data: {
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { requireAdminSession, requireTabPermission } from '@/lib/auth';
|
||||
|
||||
export async function getAllTags() {
|
||||
try {
|
||||
await requireAdminSession();
|
||||
const [blogPosts, interviews, events] = await Promise.all([
|
||||
prisma.blogPost.findMany({ select: { tags: true } }),
|
||||
prisma.interview.findMany({ select: { tags: true } }),
|
||||
@@ -25,9 +27,7 @@ export async function getAllTags() {
|
||||
|
||||
export async function deleteTagGlobally(tagName: string) {
|
||||
try {
|
||||
// This is expensive as we need to update all records
|
||||
// In a real app, a Tag model would be better
|
||||
|
||||
await requireTabPermission('taxonomies');
|
||||
const [blogPosts, interviews, events] = await Promise.all([
|
||||
prisma.blogPost.findMany({ where: { tags: { has: tagName } } }),
|
||||
prisma.interview.findMany({ where: { tags: { has: tagName } } }),
|
||||
@@ -59,6 +59,7 @@ export async function deleteTagGlobally(tagName: string) {
|
||||
|
||||
export async function renameTagGlobally(oldName: string, newName: string) {
|
||||
try {
|
||||
await requireTabPermission('taxonomies');
|
||||
const cleanNewName = newName.trim().toLowerCase();
|
||||
if (!cleanNewName) return { success: false, error: 'Nouveau nom invalide' };
|
||||
|
||||
@@ -93,6 +94,7 @@ export async function renameTagGlobally(oldName: string, newName: string) {
|
||||
|
||||
export async function getTagUsage(tagName: string) {
|
||||
try {
|
||||
await requireTabPermission('taxonomies');
|
||||
const [blogPosts, interviews, events] = await Promise.all([
|
||||
prisma.blogPost.findMany({
|
||||
where: { tags: { has: tagName } },
|
||||
|
||||
@@ -2,22 +2,85 @@
|
||||
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { requireAdminSession } from '@/lib/auth';
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
function detectImageExtension(buffer: Buffer): 'jpg' | 'png' | 'webp' | 'gif' | null {
|
||||
if (buffer.length < 12) return null;
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return 'jpg';
|
||||
}
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (
|
||||
buffer[0] === 0x89 &&
|
||||
buffer[1] === 0x50 &&
|
||||
buffer[2] === 0x4e &&
|
||||
buffer[3] === 0x47 &&
|
||||
buffer[4] === 0x0d &&
|
||||
buffer[5] === 0x0a &&
|
||||
buffer[6] === 0x1a &&
|
||||
buffer[7] === 0x0a
|
||||
) {
|
||||
return 'png';
|
||||
}
|
||||
// GIF: GIF87a or GIF89a
|
||||
if (
|
||||
buffer[0] === 0x47 &&
|
||||
buffer[1] === 0x49 &&
|
||||
buffer[2] === 0x46 &&
|
||||
buffer[3] === 0x38 &&
|
||||
(buffer[4] === 0x37 || buffer[4] === 0x39) &&
|
||||
buffer[5] === 0x61
|
||||
) {
|
||||
return 'gif';
|
||||
}
|
||||
// WebP: RIFF....WEBP
|
||||
if (
|
||||
buffer[0] === 0x52 &&
|
||||
buffer[1] === 0x49 &&
|
||||
buffer[2] === 0x46 &&
|
||||
buffer[3] === 0x46 &&
|
||||
buffer[8] === 0x57 &&
|
||||
buffer[9] === 0x45 &&
|
||||
buffer[10] === 0x42 &&
|
||||
buffer[11] === 0x50
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function uploadImage(formData: FormData) {
|
||||
try {
|
||||
await requireAdminSession();
|
||||
|
||||
const file = formData.get('file') as File;
|
||||
if (!file) {
|
||||
return { success: false, error: 'Aucun fichier fourni' };
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return { success: false, error: 'Le fichier dépasse la taille maximale autorisée (5 Mo)' };
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// Create a unique filename
|
||||
let fileExtension = file.name.split('.').pop()?.toLowerCase() || 'jpg';
|
||||
if (fileExtension === 'jpeg') fileExtension = 'jpg';
|
||||
|
||||
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
|
||||
// Validate magic bytes & determine extension from whitelist only
|
||||
const fileExtension = detectImageExtension(buffer);
|
||||
if (!fileExtension) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Format de fichier non autorisé. Seuls JPG, PNG, WebP et GIF sont acceptés.',
|
||||
};
|
||||
}
|
||||
|
||||
const fileName = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}.${fileExtension}`;
|
||||
|
||||
// Path for the ROOT public/uploads (where the main site will look)
|
||||
const rootPublicDir = join(process.cwd(), '..', 'public', 'uploads');
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireTabPermission, ADMIN_SAFE_USER_SELECT } from "@/lib/auth";
|
||||
|
||||
export async function getClients() {
|
||||
try {
|
||||
await requireTabPermission("users");
|
||||
const clients = await prisma.user.findMany({
|
||||
where: {
|
||||
role: { not: "ADMIN" },
|
||||
@@ -12,7 +14,8 @@ export async function getClients() {
|
||||
{ businesses: { isActive: false } }
|
||||
]
|
||||
},
|
||||
include: {
|
||||
select: {
|
||||
...ADMIN_SAFE_USER_SELECT,
|
||||
businesses: true
|
||||
},
|
||||
orderBy: {
|
||||
@@ -28,6 +31,7 @@ export async function getClients() {
|
||||
|
||||
export async function verifyUserEmail(userId: string) {
|
||||
try {
|
||||
await requireTabPermission("users");
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
@@ -41,10 +45,13 @@ export async function verifyUserEmail(userId: string) {
|
||||
return { success: false, error: "Erreur lors de la vérification de l'email" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleUserDeletion(userId: string, reason: string, delayDays: number) {
|
||||
try {
|
||||
await requireTabPermission("users");
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
where: { id: userId },
|
||||
select: ADMIN_SAFE_USER_SELECT
|
||||
});
|
||||
|
||||
if (!user) return { success: false, error: "Utilisateur non trouvé" };
|
||||
@@ -99,13 +106,15 @@ export async function scheduleUserDeletion(userId: string, reason: string, delay
|
||||
|
||||
export async function reactivateUser(userId: string, reason: string) {
|
||||
try {
|
||||
await requireTabPermission("users");
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
deletedAt: null,
|
||||
deletionScheduledAt: null,
|
||||
reactivationReason: reason
|
||||
}
|
||||
},
|
||||
select: ADMIN_SAFE_USER_SELECT
|
||||
});
|
||||
|
||||
const settings = await prisma.siteSetting.findUnique({
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function safeCompareSecret(provided: string | null, expected: string | undefined): boolean {
|
||||
if (!provided || !expected) return false;
|
||||
const bufA = Buffer.from(provided);
|
||||
const bufB = Buffer.from(expected);
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return crypto.timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route pour le nettoyage automatique des comptes
|
||||
* À appeler via une tâche cron (ex: chaque nuit à minuit)
|
||||
* GET /api/cron/cleanup-users?key=VOTRE_CLE_SECRET
|
||||
* Supporte `Authorization: Bearer <CRON_SECRET>` ou `?key=<CRON_SECRET>`
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get("key");
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const bearerToken = authHeader?.startsWith("Bearer ")
|
||||
? authHeader.slice("Bearer ".length).trim()
|
||||
: null;
|
||||
const key = bearerToken || searchParams.get("key");
|
||||
|
||||
if (key !== process.env.CRON_SECRET) {
|
||||
if (!safeCompareSecret(key, process.env.CRON_SECRET)) {
|
||||
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
}
|
||||
|
||||
|
||||
13
admin/src/app/api/external/articles/route.ts
vendored
13
admin/src/app/api/external/articles/route.ts
vendored
@@ -1,11 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { generateSlug } from '@/lib/utils'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
function verifyApiKey(provided: string | null, expected: string | undefined): boolean {
|
||||
if (!provided || !expected) return false
|
||||
const a = Buffer.from(provided)
|
||||
const b = Buffer.from(expected)
|
||||
if (a.length !== b.length) return false
|
||||
return crypto.timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Simple API Key authentication
|
||||
// Constant-time API Key authentication
|
||||
const apiKey = request.headers.get('x-api-key')
|
||||
const expectedApiKey = process.env.EXTERNAL_API_KEY
|
||||
|
||||
@@ -14,7 +23,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (apiKey !== expectedApiKey) {
|
||||
if (!verifyApiKey(apiKey, expectedApiKey)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
|
||||
13
admin/src/app/api/external/events/route.ts
vendored
13
admin/src/app/api/external/events/route.ts
vendored
@@ -1,11 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { generateSlug } from '@/lib/utils'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
function verifyApiKey(provided: string | null, expected: string | undefined): boolean {
|
||||
if (!provided || !expected) return false
|
||||
const a = Buffer.from(provided)
|
||||
const b = Buffer.from(expected)
|
||||
if (a.length !== b.length) return false
|
||||
return crypto.timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Simple API Key authentication
|
||||
// Constant-time API Key authentication
|
||||
const apiKey = request.headers.get('x-api-key')
|
||||
const expectedApiKey = process.env.EXTERNAL_API_KEY
|
||||
|
||||
@@ -14,7 +23,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (apiKey !== expectedApiKey) {
|
||||
if (!verifyApiKey(apiKey, expectedApiKey)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'ok',
|
||||
service: 'afrohub-admin',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ function LoginForm() {
|
||||
|
||||
toast.success(`Bienvenue, ${res.user?.name || "Administrateur"} !`);
|
||||
// Full page redirect ensures middleware & server cookies are freshly synchronized
|
||||
window.location.href = from.startsWith("/") ? from : "/dashboard";
|
||||
window.location.href = from.startsWith("/") && !from.startsWith("//") && !from.startsWith("/\\") ? from : "/dashboard";
|
||||
} catch (err) {
|
||||
console.error("Login client error:", err);
|
||||
setErrorMessage("Une erreur imprévue est survenue. Veuillez réessayer.");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { ADMIN_SAFE_USER_SELECT } from '@/lib/auth';
|
||||
import { getClients } from "@/app/actions/user";
|
||||
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
||||
import FeaturedModal from '@/components/FeaturedModal';
|
||||
@@ -17,7 +18,9 @@ async function getBusinesses() {
|
||||
isActive: true
|
||||
},
|
||||
include: {
|
||||
owner: true,
|
||||
owner: {
|
||||
select: ADMIN_SAFE_USER_SELECT
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
|
||||
@@ -9,6 +9,28 @@ import {
|
||||
REMEMBER_ME_DURATION,
|
||||
type AdminSessionPayload,
|
||||
} from './session';
|
||||
import { hasTabPermission } from './permissions';
|
||||
|
||||
export const ADMIN_SAFE_USER_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
avatar: true,
|
||||
bio: true,
|
||||
location: true,
|
||||
phone: true,
|
||||
countryId: true,
|
||||
isSuspended: true,
|
||||
suspensionReason: true,
|
||||
emailVerified: true,
|
||||
deletedAt: true,
|
||||
deletionScheduledAt: true,
|
||||
deletionReason: true,
|
||||
reactivationReason: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
};
|
||||
|
||||
export interface AdminLoginResult {
|
||||
success: boolean;
|
||||
@@ -24,7 +46,7 @@ export interface AdminLoginResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates admin credentials (email or username + password), checks ADMIN role,
|
||||
* Validates admin credentials (email + password), checks ADMIN role,
|
||||
* and sets the session cookie upon success with role and permissions.
|
||||
*/
|
||||
export async function loginAdmin(
|
||||
@@ -42,68 +64,12 @@ export async function loginAdmin(
|
||||
}
|
||||
|
||||
try {
|
||||
let user = null;
|
||||
try {
|
||||
// Search user by email (case-insensitive) or by exact name
|
||||
user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: { equals: cleanIdentifier, mode: 'insensitive' } },
|
||||
{ name: { equals: cleanIdentifier, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (queryErr: any) {
|
||||
// If column adminRole does not exist in production database yet, auto-heal schema!
|
||||
if (
|
||||
queryErr?.message?.includes('adminRole') ||
|
||||
queryErr?.message?.includes('column') ||
|
||||
queryErr?.code === 'P2021' ||
|
||||
queryErr?.code === 'P2022'
|
||||
) {
|
||||
console.log("⚠️ Colonnes manquantes détectées en production. Auto-migration de la table User...");
|
||||
try {
|
||||
await prisma.$executeRawUnsafe(`ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "adminRole" TEXT DEFAULT 'ADMIN';`);
|
||||
await prisma.$executeRawUnsafe(`ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "adminPermissions" TEXT[] DEFAULT ARRAY[]::TEXT[];`);
|
||||
// Retry findFirst
|
||||
user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: { equals: cleanIdentifier, mode: 'insensitive' } },
|
||||
{ name: { equals: cleanIdentifier, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (alterErr) {
|
||||
console.error("Auto-alter failed:", alterErr);
|
||||
}
|
||||
} else {
|
||||
throw queryErr;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-bootstrap for production: If admin@afrohub.com is requested and does not exist in DB yet,
|
||||
// create it automatically so that newly deployed production databases have a Super Admin immediately.
|
||||
if (!user && (cleanIdentifier.toLowerCase() === 'admin@afrohub.com' || cleanIdentifier.toLowerCase() === 'admin')) {
|
||||
try {
|
||||
const initialPassword = process.env.ADMIN_INITIAL_PASSWORD || 'afrohub2025';
|
||||
const hashedPassword = await bcrypt.hash(initialPassword, 10);
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Admin Afrohub',
|
||||
email: 'admin@afrohub.com',
|
||||
password: hashedPassword,
|
||||
role: 'ADMIN',
|
||||
adminRole: 'SUPER_ADMIN',
|
||||
adminPermissions: [],
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
console.log('✅ Compte Super Admin auto-initialisé avec succès lors de la première connexion.');
|
||||
} catch (e) {
|
||||
console.error('Erreur auto-création super admin:', e);
|
||||
}
|
||||
}
|
||||
// Search user by unique email (case-insensitive)
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: { equals: cleanIdentifier, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
@@ -219,6 +185,17 @@ export async function requireAdminSession(): Promise<AdminSessionPayload> {
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to ensure a server action is invoked by an admin with permission for a specific tab.
|
||||
*/
|
||||
export async function requireTabPermission(tabKey: string): Promise<AdminSessionPayload> {
|
||||
const session = await requireAdminSession();
|
||||
if (!hasTabPermission(session.adminRole, session.permissions, tabKey)) {
|
||||
throw new Error(`Non autorisé : permission '${tabKey}' requise`);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to ensure the current admin has SUPER_ADMIN role.
|
||||
*/
|
||||
@@ -229,4 +206,3 @@ export async function requireSuperAdminSession(): Promise<AdminSessionPayload> {
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
console.log('SMTP Config:', {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: process.env.SMTP_PORT,
|
||||
secure: process.env.SMTP_SECURE,
|
||||
user: process.env.SMTP_USER
|
||||
});
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
@@ -15,7 +9,7 @@ const transporter = nodemailer.createTransport({
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
rejectUnauthorized: process.env.NODE_ENV === 'production'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,7 +23,6 @@ export const sendEmail = async ({ to, subject, html }: { to: string; subject: st
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
console.log('Email sent: ' + info.response);
|
||||
return info;
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const connectionString = process.env.DATABASE_URL || "postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?schema=public"
|
||||
const connectionString = process.env.DATABASE_URL
|
||||
|
||||
if (!connectionString && process.env.NODE_ENV === 'production') {
|
||||
throw new Error('CRITICAL: DATABASE_URL environment variable must be set in production.')
|
||||
}
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma_admin_native?: PrismaClient }
|
||||
|
||||
export const prisma = globalForPrisma.prisma_admin_native || new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: connectionString,
|
||||
},
|
||||
},
|
||||
})
|
||||
export const prisma = globalForPrisma.prisma_admin_native || new PrismaClient(
|
||||
connectionString
|
||||
? {
|
||||
datasources: {
|
||||
db: {
|
||||
url: connectionString,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_admin_native = prisma
|
||||
|
||||
export default prisma
|
||||
|
||||
|
||||
|
||||
@@ -15,10 +15,20 @@ export interface AdminSessionPayload {
|
||||
iat: number;
|
||||
}
|
||||
|
||||
const JWT_SECRET =
|
||||
process.env.ADMIN_JWT_SECRET ||
|
||||
process.env.NEXTAUTH_SECRET ||
|
||||
'afrohub_admin_secure_jwt_token_secret_key_2025_9876543210';
|
||||
function getJwtSecret(): string {
|
||||
const secret =
|
||||
process.env.ADMIN_JWT_SECRET ||
|
||||
process.env.NEXTAUTH_SECRET ||
|
||||
process.env.JWT_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('CRITICAL: ADMIN_JWT_SECRET or NEXTAUTH_SECRET must be configured in production.');
|
||||
}
|
||||
return 'dev_only_afrohub_admin_jwt_secret_change_in_prod';
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function base64UrlEncode(str: string): string {
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
@@ -61,7 +71,7 @@ async function getCryptoKey(): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
return await crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(JWT_SECRET),
|
||||
enc.encode(getJwtSecret()),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
|
||||
@@ -15,11 +15,13 @@ const PUBLIC_PREFIXES = [
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname, search } = request.nextUrl;
|
||||
const isServerAction = request.headers.has('next-action');
|
||||
|
||||
// 1. Bypass static files and Next.js internal assets
|
||||
// 1. Bypass static files and Next.js internal assets (only for GET/HEAD, never for Server Action POSTs)
|
||||
if (
|
||||
pathname.startsWith('/_next') ||
|
||||
pathname.includes('.') // any file with an extension (e.g. favicon.ico, images, robots.txt)
|
||||
!isServerAction &&
|
||||
(request.method === 'GET' || request.method === 'HEAD') &&
|
||||
(pathname.startsWith('/_next') || pathname.includes('.'))
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
@@ -32,21 +34,24 @@ export async function middleware(request: NextRequest) {
|
||||
|
||||
// 3. If accessing /login
|
||||
if (isLoginPage) {
|
||||
// If already logged in, redirect directly to dashboard
|
||||
if (session) {
|
||||
// If already logged in and not invoking a login server action, redirect directly to dashboard
|
||||
if (session && !isServerAction) {
|
||||
return NextResponse.redirect(new URL('/dashboard', request.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 4. If accessing other public APIs (like cron or external integrations)
|
||||
// 4. If accessing other public APIs (like cron or external integrations), disallow Server Action invocations
|
||||
const isPublicPrefix = PUBLIC_PREFIXES.some(prefix => pathname.startsWith(prefix));
|
||||
if (isPublicPrefix) {
|
||||
if (isPublicPrefix && !isServerAction) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 5. Protected route: redirect to /login if unauthenticated
|
||||
// 5. Protected route: redirect to /login if unauthenticated (or 401 for Server Actions)
|
||||
if (!session) {
|
||||
if (isServerAction) {
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
const loginUrl = new URL('/login', request.url);
|
||||
const returnUrl = pathname + (search || '');
|
||||
if (pathname !== '/' && pathname !== '') {
|
||||
@@ -58,6 +63,9 @@ export async function middleware(request: NextRequest) {
|
||||
// 6. Check granular tab permission
|
||||
const tabKey = getTabForPathname(pathname);
|
||||
if (tabKey && !hasTabPermission(session.adminRole, session.permissions, tabKey)) {
|
||||
if (isServerAction) {
|
||||
return NextResponse.json({ error: 'Permission refusée' }, { status: 403 });
|
||||
}
|
||||
// If user lacks permission for this tab, redirect back to /dashboard
|
||||
const dashboardUrl = new URL('/dashboard', request.url);
|
||||
dashboardUrl.searchParams.set('unauthorized', tabKey);
|
||||
|
||||
Reference in New Issue
Block a user